feat(rules)!: retire rulebook subscriptions and per-project suppressions (#4052)
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

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
This commit is contained in:
2026-09-15 12:20:57 -04:00
co-authored by Claude Opus 5
parent 188e78bbcd
commit 0bcd4b5540
43 changed files with 579 additions and 1610 deletions
@@ -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")
+1 -1
View File
@@ -89,7 +89,7 @@ table here. The tools are grouped by family:
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes | | 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 | | 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 | | 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 | | Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes | | Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
| Admin | `get_app_logs` (write/admin key) | Diagnostics | | Admin | `get_app_logs` (write/admin key) | Diagnostics |
+2 -5
View File
@@ -77,7 +77,7 @@ endpoint at `/mcp`, not these REST routes.
|--------|------|-------------| |--------|------|-------------|
| GET / POST | `/api/projects` | List (owned + shared) / create | | GET / POST | `/api/projects` | List (owned + shared) / create |
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete | | 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/inception/defaults` | What binds if nobody decides — the inception card's payload |
| GET | `/api/projects/:id/notes` | Notes + tasks in this project | | GET | `/api/projects/:id/notes` | Notes + tasks in this project |
| GET / POST | `/api/projects/:id/milestones` | List / create milestones | | 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 | | GET | `/api/rules` | List rules |
| POST | `/api/rulebook-topics/:tid/rules` | Add a rule to a topic | | POST | `/api/rulebook-topics/:tid/rules` | Add a rule to a topic |
| GET / PATCH / DELETE | `/api/rules/:id` | Read / update / delete a rule | | 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` | A project's own rules, and the global rules tagged to its areas |
| GET | `/api/projects/:id/rules` | Applicable rules for a project |
| POST | `/api/projects/:id/rules` | Create a project-scoped rule | | 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 ## Sharing
+5 -2
View File
@@ -64,8 +64,11 @@ across sessions.
session when what the agent is about to do matches its trigger: a command, 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 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. therefore the field that decides whether a rule is ever seen.
- **Per-project scope** — A project subscribes to rulebooks, and can add - **Global or project scope** — A rule in a rulebook is global: it applies in
project-scoped rules or suppress individual inherited rules/topics. 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 ## Stored Processes
+2 -5
View File
@@ -2,7 +2,6 @@
import { apiGet, apiPost } from "@/api/client"; import { apiGet, apiPost } from "@/api/client";
export interface InceptionChoices { export interface InceptionChoices {
subscribe_rulebooks: number[];
design_system_id: number | null; design_system_id: number | null;
seed_systems: boolean; seed_systems: boolean;
} }
@@ -15,8 +14,6 @@ export interface InceptionRecord {
} }
export interface InceptionDefaults { export interface InceptionDefaults {
rulebooks: { id: number; title: string }[];
subscribed_rulebooks: { id: number; title: string }[];
design_system_id: number | null; design_system_id: number | null;
design_systems: { id: number; title: string }[]; design_systems: { id: number; title: string }[];
systems: number; systems: number;
@@ -25,11 +22,11 @@ export interface InceptionDefaults {
export interface InceptionDecision { export interface InceptionDecision {
project_id: number; project_id: number;
inception: InceptionRecord; 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 => ({ 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) => export const fetchInceptionDefaults = (projectId: number) =>
+4 -46
View File
@@ -108,23 +108,7 @@ export interface ApplicableRules {
rulebook_title: string; rulebook_title: string;
})[]; })[];
project_rules: RuleHeader[]; 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; truncated: boolean;
subscribed_rulebooks: { id: number; title: string }[];
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
} }
// ── Rulebooks ─────────────────────────────────────────────────────── // ── Rulebooks ───────────────────────────────────────────────────────
@@ -272,15 +256,7 @@ export async function deleteRule(id: number): Promise<void> {
return apiDelete(`/api/rules/${id}`); return apiDelete(`/api/rules/${id}`);
} }
// ── Subscriptions ────────────────────────────────────────────────── // ── A project's rules ──────────────────────────────────────────────
export async function subscribeProject(projectId: number, rulebookId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/rulebook-subscriptions`, { rulebook_id: rulebookId });
}
export async function unsubscribeProject(projectId: number, rulebookId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/rulebook-subscriptions/${rulebookId}`);
}
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> { export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
return apiGet(`/api/projects/${projectId}/rules`); return apiGet(`/api/projects/${projectId}/rules`);
@@ -293,24 +269,6 @@ export async function createProjectRule(
return apiPost(`/api/projects/${projectId}/rules`, data); return apiPost(`/api/projects/${projectId}/rules`, data);
} }
// ── Suppressions ───────────────────────────────────────────────────
export async function suppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/suppressions/rules/${ruleId}`, {});
}
export async function unsuppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/rules/${ruleId}`);
}
export async function suppressTopicForProject(projectId: number, topicId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/suppressions/topics/${topicId}`, {});
}
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
}
/** /**
* One row of the staleness sweep. Unlike RuleHeader this carries the CHECK * 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: * first, never-checked at the top. Rules without a check never appear:
* they are decisions, and there is nothing to go and check. * they are decisions, and there is nothing to go and check.
* *
* Not filterable by project — a project reaches rules through project * Not filterable by project — a project is bound by its own rules and by
* scope, subscriptions, always-on rulebooks and exclusions, and a filter * every global rule, and a filter that dropped the global ones would
* missing one of those paths would under-report. * under-report.
*/ */
export async function listRulesDueForVerification(opts: { export async function listRulesDueForVerification(opts: {
olderThanDays?: number; olderThanDays?: number;
+7 -35
View File
@@ -14,7 +14,6 @@ import {
decideInception, emptyChoices, fetchInceptionDefaults, decideInception, emptyChoices, fetchInceptionDefaults,
type InceptionChoices, type InceptionDecision, type InceptionDefaults, type InceptionChoices, type InceptionDecision, type InceptionDefaults,
} from "@/api/inception"; } from "@/api/inception";
import { listRulebooks } from "@/api/rulebooks";
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
mode: "create" | "decide"; mode: "create" | "decide";
@@ -28,7 +27,6 @@ const emit = defineEmits<{
}>(); }>();
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices()); const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
const others = ref<{ id: number; title: string }[]>([]);
const designSystems = ref<{ id: number; title: string }[]>([]); const designSystems = ref<{ id: number; title: string }[]>([]);
const systemsCount = ref(0); const systemsCount = ref(0);
const loading = ref(true); const loading = ref(true);
@@ -46,18 +44,12 @@ async function load() {
try { try {
if (props.mode === "decide" && props.projectId) { if (props.mode === "decide" && props.projectId) {
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId); const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
others.value = d.rulebooks;
designSystems.value = d.design_systems; designSystems.value = d.design_systems;
systemsCount.value = d.systems; systemsCount.value = d.systems;
// Start from what stands today so "record" without changes is a true inherit-all. // Start from what stands today, so "record" without changes keeps it.
local.value = { local.value = { design_system_id: d.design_system_id, seed_systems: false };
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
design_system_id: d.design_system_id,
seed_systems: false,
};
} else { } else {
const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]); const ds = await fetchDesignSystems();
others.value = rulebooks.map((r) => ({ id: r.id, title: r.title }));
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title })); designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
} }
} catch (e: unknown) { } catch (e: unknown) {
@@ -67,17 +59,7 @@ async function load() {
} }
} }
function subscribed(id: number): boolean { const nothingToDecide = computed(() => !designSystems.value.length);
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,
);
async function record() { async function record() {
if (!props.projectId) return; if (!props.projectId) return;
@@ -101,22 +83,12 @@ onMounted(load);
<h3 id="inception-title" class="inception-title">What does this project inherit?</h3> <h3 id="inception-title" class="inception-title">What does this project inherit?</h3>
<p class="inception-lede"> <p class="inception-lede">
A project's inheritance is a decision, not a default. Until it is recorded, 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 there is no design system and no Systems. Rules aren't part of this: global
system or Systems. rules apply to every project, and a project's own rules are added on it.
</p> </p>
<p v-if="loading" class="inception-muted">Loading…</p> <p v-if="loading" class="inception-muted">Loading…</p>
<p v-else-if="error" class="error-msg">{{ error }}</p> <p v-else-if="error" class="error-msg">{{ error }}</p>
<template v-else> <template v-else>
<div v-if="others.length" class="inception-group">
<h4>Subscribe to rulebooks</h4>
<p class="inception-muted">
A rulebook binds this project only if it is subscribed — nothing is inherited automatically.
</p>
<label v-for="rb in others" :key="rb.id" class="inception-choice">
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
<span>{{ rb.title }}</span>
</label>
</div>
<div class="inception-group"> <div class="inception-group">
<h4>Design system</h4> <h4>Design system</h4>
<select v-model="local.design_system_id" class="inception-select" aria-label="Design system"> <select v-model="local.design_system_id" class="inception-select" aria-label="Design system">
@@ -134,7 +106,7 @@ onMounted(load);
</label> </label>
</div> </div>
<p v-if="nothingToDecide" class="inception-muted"> <p v-if="nothingToDecide" class="inception-muted">
Nothing to inherit yet on this install — recording still settles the question. No design systems on this install yet — recording still settles the question.
</p> </p>
<div v-if="mode === 'decide'" class="inception-actions"> <div v-if="mode === 'decide'" class="inception-actions">
<button class="btn-primary" :disabled="saving" @click="record"> <button class="btn-primary" :disabled="saving" @click="record">
+25 -186
View File
@@ -3,24 +3,26 @@ import { ref, onMounted, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { import {
getProjectApplicableRules, getProjectApplicableRules,
subscribeProject,
unsubscribeProject,
listRulebooks,
getRule, getRule,
createProjectRule, createProjectRule,
deleteRule, deleteRule,
suppressRuleForProject,
unsuppressRuleForProject,
suppressTopicForProject,
unsuppressTopicForProject,
} from "@/api/rulebooks"; } from "@/api/rulebooks";
import type { ApplicableRules, Rulebook } from "@/api/rulebooks"; import type { ApplicableRules } from "@/api/rulebooks";
/**
* A project's view of its rules (milestone 414). A rule's home is its reach:
* the project's own rules apply here and nowhere else, and every global rule
* (one in a rulebook) applies here too. There is no subscribing this project
* to a rulebook and no skipping a global rule for it — a project that departs
* from one writes its own rule and links it with an `overrides` relation.
*
* The second list is the global rules TAGGED to an area this project works in,
* not every global rule: those arrive by retrieval when the work matches them,
* and listing them all under every project would say nothing.
*/
const props = defineProps<{ projectId: number }>(); const props = defineProps<{ projectId: number }>();
const router = useRouter(); const router = useRouter();
const applicable = ref<ApplicableRules | null>(null); const applicable = ref<ApplicableRules | null>(null);
const allRulebooks = ref<Rulebook[]>([]);
const showPicker = ref(false);
const expandedRuleIds = ref<Set<number>>(new Set()); const expandedRuleIds = ref<Set<number>>(new Set());
const ruleDetails = ref<Record<number, { const ruleDetails = ref<Record<number, {
@@ -38,22 +40,6 @@ async function load() {
applicable.value = await getProjectApplicableRules(props.projectId); applicable.value = await getProjectApplicableRules(props.projectId);
} }
async function loadAllRulebooks() {
allRulebooks.value = await listRulebooks();
}
async function subscribe(rulebookId: number) {
await subscribeProject(props.projectId, rulebookId);
showPicker.value = false;
await load();
}
async function unsubscribe(rulebookId: number) {
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
await unsubscribeProject(props.projectId, rulebookId);
await load();
}
async function toggleRuleExpand(ruleId: number) { async function toggleRuleExpand(ruleId: number) {
if (expandedRuleIds.value.has(ruleId)) { if (expandedRuleIds.value.has(ruleId)) {
expandedRuleIds.value.delete(ruleId); expandedRuleIds.value.delete(ruleId);
@@ -143,66 +129,13 @@ async function removeProjectRule(ruleId: number) {
await load(); await load();
} }
const showSuppressed = ref(false); onMounted(load);
async function suppressRule(ruleId: number) {
await suppressRuleForProject(props.projectId, ruleId);
await load();
}
async function unsuppressRule(ruleId: number) {
await unsuppressRuleForProject(props.projectId, ruleId);
await load();
}
async function suppressTopic(topicId: number) {
await suppressTopicForProject(props.projectId, topicId);
await load();
}
async function unsuppressTopic(topicId: number) {
await unsuppressTopicForProject(props.projectId, topicId);
await load();
}
onMounted(async () => {
await load();
await loadAllRulebooks();
});
watch(() => props.projectId, load); watch(() => props.projectId, load);
</script> </script>
<template> <template>
<div class="rules-tab" v-if="applicable"> <div class="rules-tab" v-if="applicable">
<section class="subscribed">
<h3>Subscribed rulebooks</h3>
<div class="chips">
<span
v-for="rb in applicable.subscribed_rulebooks"
:key="rb.id"
class="chip"
>
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
<button class="chip-remove" @click="unsubscribe(rb.id)" aria-label="Unsubscribe">×</button>
</span>
<button v-if="!showPicker" class="add" @click="showPicker = true">+ Subscribe</button>
<select
v-else
@change="subscribe(Number(($event.target as HTMLSelectElement).value))"
>
<option value="">Choose a rulebook</option>
<option
v-for="rb in allRulebooks.filter((rb) => !applicable!.subscribed_rulebooks.some((s) => s.id === rb.id))"
:key="rb.id"
:value="rb.id"
>
{{ rb.title }}
</option>
</select>
</div>
</section>
<section class="project-rules"> <section class="project-rules">
<div class="section-head"> <div class="section-head">
<h3>Project rules</h3> <h3>Project rules</h3>
@@ -286,9 +219,13 @@ watch(() => props.projectId, load);
</section> </section>
<section class="applicable"> <section class="applicable">
<h3>Applicable rules</h3> <h3>Global rules for this project's areas</h3>
<p class="applicable-note">
Every global rule applies to this project and arrives when the work matches it.
These are the ones tagged to an area this project works in.
</p>
<p v-if="applicable.rules.length === 0" class="empty"> <p v-if="applicable.rules.length === 0" class="empty">
No rules yet — subscribe to a rulebook above, or create one at None tagged to this project's areas. Global rules live in
<a @click="router.push('/rules')">Rulebooks</a>. <a @click="router.push('/rules')">Rulebooks</a>.
</p> </p>
<div <div
@@ -298,26 +235,12 @@ watch(() => props.projectId, load);
> >
<h4>{{ rb.rulebook_title }}</h4> <h4>{{ rb.rulebook_title }}</h4>
<div v-for="topic in rb.topics" :key="topic.topic_id" class="topic-group"> <div v-for="topic in rb.topics" :key="topic.topic_id" class="topic-group">
<h5> <h5>{{ topic.topic_title }}</h5>
<span>{{ topic.topic_title }}</span>
<button
class="skip-btn"
:title="`Skip the entire ${topic.topic_title} topic for this project`"
@click="suppressTopic(topic.topic_id)"
>× skip topic</button>
</h5>
<ul> <ul>
<li v-for="r in topic.rules" :key="r.id" class="rule"> <li v-for="r in topic.rules" :key="r.id" class="rule">
<div class="rule-head"> <div class="rule-head" @click="toggleRuleExpand(r.id)">
<div class="rule-head-text" @click="toggleRuleExpand(r.id)"> <span class="rule-title">{{ r.title }}</span>
<span class="rule-title">{{ r.title }}</span> <span class="rule-statement">{{ r.statement }}</span>
<span class="rule-statement">{{ r.statement }}</span>
</div>
<button
class="skip-btn"
title="Skip this rule for this project"
@click.stop="suppressRule(r.id)"
>× skip</button>
</div> </div>
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail"> <div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
<div v-if="ruleDetails[r.id].why"> <div v-if="ruleDetails[r.id].why">
@@ -349,64 +272,25 @@ watch(() => props.projectId, load);
</p> </p>
</section> </section>
<section
v-if="applicable.suppressed_rules.length + applicable.suppressed_topics.length > 0"
class="suppressed"
>
<button class="suppressed-toggle" @click="showSuppressed = !showSuppressed">
<span>Suppressed ({{ applicable.suppressed_rules.length + applicable.suppressed_topics.length }})</span>
<span class="caret">{{ showSuppressed ? "▾" : "▸" }}</span>
</button>
<div v-if="showSuppressed" class="suppressed-body">
<ul v-if="applicable.suppressed_topics.length > 0" class="suppressed-list">
<li v-for="t in applicable.suppressed_topics" :key="`topic-${t.id}`">
<span class="suppressed-kind">topic</span>
<span class="suppressed-path">{{ t.rulebook_title }} → {{ t.title }}</span>
<button class="reenable-btn" @click="unsuppressTopic(t.id)">↻ re-enable</button>
</li>
</ul>
<ul v-if="applicable.suppressed_rules.length > 0" class="suppressed-list">
<li v-for="r in applicable.suppressed_rules" :key="`rule-${r.id}`">
<span class="suppressed-kind">rule</span>
<span class="suppressed-path">{{ r.rulebook_title }} → {{ r.topic_title }} → {{ r.title }}</span>
<button class="reenable-btn" @click="unsuppressRule(r.id)">↻ re-enable</button>
</li>
</ul>
</div>
</section>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.trigger-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); } .trigger-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); }
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
.rules-tab { padding: 1rem; } .rules-tab { padding: 1rem; }
h3 { h3 {
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
margin-top: 0; margin-top: 0;
} }
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.chip {
display: inline-flex; align-items: center; gap: 0.25rem;
background: var(--fs-accent-soft);
padding: 0.25rem 0.5rem; border-radius: 999px;
}
.chip a { cursor: pointer; }
.chip-remove { background: none; border: none; cursor: pointer; opacity: 0.5; font-size: 1.1em; }
.chip-remove:hover { opacity: 1; }
.add { .add {
background: none; background: none;
border: 1px dashed var(--fs-border-color); border: 1px dashed var(--fs-border-color);
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer; padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
color: inherit; color: inherit;
} }
select {
background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.25rem 0.5rem;
}
.applicable { margin-top: 2rem; } .applicable { margin-top: 2rem; }
.applicable-note { margin: 0 0 0.75rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
.rb-group { margin-bottom: 1.5rem; } .rb-group { margin-bottom: 1.5rem; }
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; } .rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
/* `.topic-group` is deliberately bare — a namespace for the two h5 rules (this /* `.topic-group` is deliberately bare — a namespace for the two h5 rules (this
@@ -441,7 +325,7 @@ ul { list-style: none; padding: 0; margin: 0; }
} }
.empty, .truncated { opacity: 0.7; font-style: italic; } .empty, .truncated { opacity: 0.7; font-style: italic; }
.empty a { cursor: pointer; text-decoration: underline; } .empty a { cursor: pointer; text-decoration: underline; }
.project-rules { margin-top: 1.5rem; } .project-rules { margin-top: 0; }
.section-head { display: flex; justify-content: space-between; align-items: center; } .section-head { display: flex; justify-content: space-between; align-items: center; }
.new-rule-form { .new-rule-form {
display: flex; flex-direction: column; gap: 0.5rem; display: flex; flex-direction: column; gap: 0.5rem;
@@ -459,49 +343,4 @@ ul { list-style: none; padding: 0; margin: 0; }
background: none; border: none; cursor: pointer; background: none; border: none; cursor: pointer;
color: var(--fs-destructive); padding: 0.5rem 0 0 0; color: var(--fs-destructive); padding: 0.5rem 0 0 0;
} }
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
.topic-group h5 {
display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;
}
.rule-head {
display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem;
}
.rule-head-text { flex: 1; cursor: pointer; }
.skip-btn {
background: none; border: none; cursor: pointer;
color: var(--fs-text-tertiary); font-size: 0.75rem;
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
white-space: nowrap;
}
.topic-group h5:hover .skip-btn,
.rule:hover .skip-btn,
.skip-btn:focus { opacity: 1; }
.skip-btn:hover { color: var(--fs-destructive); }
/* Suppressed section */
.suppressed { margin-top: 1.5rem; }
.suppressed-toggle {
display: flex; align-items: center; gap: 0.4rem;
background: none; border: none; cursor: pointer;
font-size: 0.85rem; opacity: 0.7; padding: 0.25rem 0; color: inherit;
}
.suppressed-toggle:hover { opacity: 1; }
.suppressed-toggle .caret { font-size: 0.7em; }
.suppressed-body { margin-top: 0.5rem; }
.suppressed-list { padding-left: 0; }
.suppressed-list li {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.25rem 0; opacity: 0.75;
}
.suppressed-kind {
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.1rem 0.4rem; border-radius: 3px;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
}
.suppressed-path { flex: 1; }
.reenable-btn {
background: none; border: none; cursor: pointer;
color: var(--fs-accent); font-size: 0.85em;
}
.reenable-btn:hover { text-decoration: underline; }
</style> </style>
@@ -221,7 +221,7 @@ watch(() => props.ruleId, load);
</ul> </ul>
<p class="field-note"> <p class="field-note">
Rules that <em>fail together</em> are linked, never merged a merged rule cannot be Rules that <em>fail together</em> are linked, never merged a merged rule cannot be
cited, surfaced or suppressed a clause at a time. cited or surfaced a clause at a time.
</p> </p>
</section> </section>
@@ -1,12 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from "vue"; import { ref } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks"; import { useRulebooksStore } from "@/stores/rulebooks";
import { apiGet } from "@/api/client";
import {
subscribeProject, unsubscribeProject, getProjectApplicableRules,
} from "@/api/rulebooks";
import type { RulebookTopic } from "@/api/rulebooks"; import type { RulebookTopic } from "@/api/rulebooks";
// A rulebook's rules are global — they apply to every project — so this pane
// lists topics and nothing else. It carried a "Subscribers" checklist of
// projects until milestone 414 retired subscriptions.
const props = defineProps<{ const props = defineProps<{
rulebookId: number; rulebookId: number;
topics: RulebookTopic[]; topics: RulebookTopic[];
@@ -18,42 +17,6 @@ const store = useRulebooksStore();
const isCreating = ref(false); const isCreating = ref(false);
const newTitle = ref(""); const newTitle = ref("");
interface ProjectLite { id: number; title: string }
const projects = ref<ProjectLite[]>([]);
// Map<project_id, Set<rulebook_id>>
const subscribedRulebookIds = ref<Map<number, Set<number>>>(new Map());
async function loadProjects() {
const data = await apiGet<{ projects: ProjectLite[] }>("/api/projects");
projects.value = data.projects;
for (const p of projects.value) {
const result = await getProjectApplicableRules(p.id);
subscribedRulebookIds.value.set(
p.id,
new Set(result.subscribed_rulebooks.map((rb) => rb.id)),
);
}
}
function isSubscribed(projectId: number): boolean {
return subscribedRulebookIds.value.get(projectId)?.has(props.rulebookId) ?? false;
}
async function toggleSubscription(projectId: number, checked: boolean) {
if (checked) {
await subscribeProject(projectId, props.rulebookId);
const set = subscribedRulebookIds.value.get(projectId) || new Set<number>();
set.add(props.rulebookId);
subscribedRulebookIds.value.set(projectId, set);
} else {
await unsubscribeProject(projectId, props.rulebookId);
subscribedRulebookIds.value.get(projectId)?.delete(props.rulebookId);
}
// trigger reactivity on Map mutation
subscribedRulebookIds.value = new Map(subscribedRulebookIds.value);
}
async function submitNew() { async function submitNew() {
const title = newTitle.value.trim(); const title = newTitle.value.trim();
if (!title) return; if (!title) return;
@@ -63,8 +26,6 @@ async function submitNew() {
emit("select-topic", topic.id); emit("select-topic", topic.id);
} }
onMounted(loadProjects);
watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing map */});
</script> </script>
<template> <template>
@@ -92,21 +53,6 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
</div> </div>
</form> </form>
</div> </div>
<div class="subscriptions">
<h3>Subscribers</h3>
<ul class="sub-list">
<li v-for="p in projects" :key="p.id">
<label>
<input
type="checkbox"
:checked="isSubscribed(p.id)"
@change="toggleSubscription(p.id, ($event.target as HTMLInputElement).checked)"
/>
{{ p.title }}
</label>
</li>
</ul>
</div>
</section> </section>
</template> </template>
@@ -117,24 +63,13 @@ ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; } li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li.active { background: var(--fs-accent-soft); } li.active { background: var(--fs-accent-soft); }
li:hover { background: var(--fs-surface-hover); } li:hover { background: var(--fs-surface-hover); }
/* `.new-topic` and `.sub-list` are deliberately bare (#2444). The first wraps a /* `.new-topic` is deliberately bare (#2444): it wraps a button-or-form whose
button-or-form whose children style themselves; the second is a `<ul>`, and children style themselves, and namespaces the descendant rule below. */
the bare `ul` rule above already gives it list-style, padding and margin —
a base a class-name check cannot see, since it comes from an element
selector. Both namespace descendant rules and assume nothing about layout. */
.new-topic input { .new-topic input {
width: 100%; margin-bottom: 0.5rem; width: 100%; margin-bottom: 0.5rem;
background: var(--fs-surface-page); color: inherit; background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px; border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.5rem; padding: 0.5rem;
} }
.subscriptions {
margin-top: 2rem;
border-top: 1px solid var(--fs-border-color);
padding-top: 1rem;
}
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
.sub-list li { cursor: default; }
.sub-list label { display: flex; gap: 0.5rem; align-items: center; cursor: pointer; }
button { cursor: pointer; } button { cursor: pointer; }
</style> </style>
-1
View File
@@ -24,7 +24,6 @@ export interface StartPlanningResult {
topic_title: string; topic_title: string;
rulebook_title: string; rulebook_title: string;
}[]; }[];
subscribed_rulebooks: { id: number; title: string }[];
applicable_rules_truncated: boolean; applicable_rules_truncated: boolean;
project_goal: string; project_goal: string;
open_task_count: number; open_task_count: number;
-3
View File
@@ -716,9 +716,6 @@ async function confirmDelete() {
/> />
<p v-else-if="project.inception" class="inception-line"> <p v-else-if="project.inception" class="inception-line">
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }} Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
<template v-if="project.inception.choices.subscribe_rulebooks.length">
· subscribes {{ project.inception.choices.subscribe_rulebooks.length }}
</template>
· design system {{ project.inception.choices.design_system_id ? "#" + project.inception.choices.design_system_id : "none" }} · design system {{ project.inception.choices.design_system_id ? "#" + project.inception.choices.design_system_id : "none" }}
<template v-if="project.inception.choices.seed_systems"> · Systems seeded</template> <template v-if="project.inception.choices.seed_systems"> · Systems seeded</template>
</p> </p>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scribe", "name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).", "description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.09.15.0204", "version": "2026.09.15.1620",
"author": { "author": {
"name": "Bryan Van Deusen" "name": "Bryan Van Deusen"
}, },
+23 -21
View File
@@ -62,8 +62,10 @@ Two constraints on *how* that's achieved:
empty session is not evidence of an empty rulebook. Retrieval fires when empty session is not evidence of an empty rulebook. Retrieval fires when
something asks: before a consequential act, `search(content_type="rule")` on something asks: before a consequential act, `search(content_type="rule")` on
what you are about to do, and pull a record's full statement with what you are about to do, and pull a record's full statement with
`get_rule(id)` when it is about to bite. When a project is in scope, `get_rule(id)` when it is about to bite. When a project is in scope, pass
`enter_project(id)` also returns the rules bound to its areas. its `project_id`: the answer is then the global rules plus that project's
own, never another project's. `enter_project(id)` lists the project's own
rules by title.
**`kind` says how much force a record carries, and it is never something to **`kind` says how much force a record carries, and it is never something to
infer.** A **rule** must be followed: ignoring it breaks something or infer.** A **rule** must be followed: ignoring it breaks something or
@@ -243,40 +245,40 @@ bound — confine the session to it:
## Starting a project: decide what it inherits ## Starting a project: decide what it inherits
A project's inheritance is a **decision, not a default**. Before A project's inheritance is a **decision, not a default**. Before
`create_project`, ask the operator the three inception questions and pass the `create_project`, ask the operator the two inception questions and pass the
answers — never create a project bare by default: answers — never create a project bare by default:
- which rulebooks to **subscribe** (`list_rulebooks` shows them; default: none
— a rulebook binds a project only when it opts in) →
`subscribe_rulebooks=[...]`
- which **design system** its UI is built from (`list_design_systems`; or - which **design system** its UI is built from (`list_design_systems`; or
none) → `design_system_id=<id | -1>` none) → `design_system_id=<id | -1>`
- whether to **seed the standard starter Systems** so records can be tagged - whether to **seed the standard starter Systems** so records can be tagged
from day one → `seed_systems=true|false` from day one → `seed_systems=true|false`
Rules are not an inception question: a global rule already applies to every
project, and a project's own rules are written on it as they come up.
If `enter_project` returns an `inception` key, the project was never decided If `enter_project` returns an `inception` key, the project was never decided
(it inherits its defaults silently): raise that ask once, with the defaults it (it inherits its defaults silently): raise that ask once, with the defaults it
carries, then `decide_project_inception(project_id, …)`. Existing projects carries, then `decide_project_inception(project_id, …)`. Existing projects
were stamped "legacy" (inherit-all) and do not ask; any project can be were stamped "legacy" and do not ask; any project can be re-decided. The
re-decided. The rules/design-system/Systems tools still work one at a time — design-system and Systems tools still work one at a time — inception is the
inception is the moment they are decided together, and the record of why. moment they are decided together, and the record of why.
## Where a new rule goes ## Where a new rule goes
When codifying a rule, pick its home by **who it should bind** — and keep A rule has one of two homes, and the home IS its reach:
shared homes general:
- **Rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a reusable, - **Global** — in a rulebook (`create_rule` into a topic). It applies in every
*themed* module of general rules that binds the projects which opt in (e.g. a project, and reaches a session wherever the work matches it. A rulebook is a
review checklist → every service). Themed, but project-agnostic. *themed* grouping of general rules (e.g. a review checklist), not a list of
- **Project rule** (`create_project_rule`) — anything specific to one project projects it binds — there is no subscribing a project to one.
(its files, paths, quirks). - **Project** (`create_project_rule`) — anything specific to one project (its
files, paths, quirks). It reaches only that project's sessions.
There used to be a third home — an `always_on` rulebook that bound every Names one project's specifics → project rule; a standard that holds wherever
project automatically. It is gone: subscription is the only reach a rulebook the kind of work it describes happens → global. Never put project-specific
has. Names one project's specifics → project rule; anything a category of detail in a rulebook — it would reach every other project. A project that
projects shares → rulebook. Never put project-specific detail in a rulebook — departs from a global rule writes its own and links it with
it leaks to every other project that subscribes. `relate_rules(kind="overrides")`, which says why.
**Whichever home it gets, a rule needs `when_to_apply`.** It is the only thing **Whichever home it gets, a rule needs `when_to_apply`.** It is the only thing
that decides whether the rule is ever seen: nothing is preloaded, so a rule that decides whether the rule is ever seen: nothing is preloaded, so a rule
+1 -1
View File
@@ -43,7 +43,7 @@ async def get_milestone(milestone_id: int) -> dict:
rules surface again on recall. rules surface again on recall.
Returns: milestone (incl. body), progress, steps (its tasks ordered by Returns: milestone (incl. body), progress, steps (its tasks ordered by
status then update), and applicable_rules / subscribed_rulebooks. status then update), and applicable_rules / project_rules.
""" """
uid = current_user_id() uid = current_user_id()
milestone = await milestones_svc.get_milestone(uid, milestone_id) milestone = await milestones_svc.get_milestone(uid, milestone_id)
+2 -2
View File
@@ -384,8 +384,8 @@ async def notes_due_for_verification(
thing there is. 0 = no age filter. thing there is. 0 = no age filter.
project_id: narrow to one project. 0 = every project. Unlike the rules project_id: narrow to one project. 0 = every project. Unlike the rules
sweep, this filter is safe: a note belongs to at most one project sweep, this filter is safe: a note belongs to at most one project
outright, with none of the subscription paths that would make a outright, where a project is bound by every GLOBAL rule as well as
project filter UNDER-report a rule. its own — so a project filter would UNDER-report rules.
never_only: only notes nobody has ever verified. never_only: only notes nobody has ever verified.
""" """
uid = current_user_id() uid = current_user_id()
+26 -41
View File
@@ -66,7 +66,7 @@ async def enter_project(project_id: int) -> dict:
project_id: The project to enter. project_id: The project to enter.
Returns a dict with keys: project, milestone_summary, open_tasks, systems, Returns a dict with keys: project, milestone_summary, open_tasks, systems,
design_system, project_rules, subscribed_rulebooks, pattern_coverage — design_system, project_rules, pattern_coverage —
plus milestone_summary_omitted, inception and systems_bootstrap, each plus milestone_summary_omitted, inception and systems_bootstrap, each
present only when it applies (see below). present only when it applies (see below).
@@ -83,11 +83,12 @@ async def enter_project(project_id: int) -> dict:
with or without a milestone. A work-log counts as touching its task. Each with or without a milestone. A work-log counts as touching its task. Each
names its milestone. list_tasks has the rest. names its milestone. list_tasks has the rest.
`project_rules` lists the project's own rules by id and title, and `project_rules` lists the project's own rules by id and title. Global
`subscribed_rulebooks` the rulebooks it draws on. A rule reaches you in rules (the ones in rulebooks) apply here too and are not listed. Any rule
full when your work matches it; get_rule(id) reads one, and reaches you in full when your work matches it — a global one or one of
search(content_type="rule") asks whether one covers what you are about this project's, never another project's; get_rule(id) reads one, and
to do. search(content_type="rule", project_id=...) asks whether one covers what
you are about to do.
`pattern_coverage` (usually null) is the shape-accounting line — how many `pattern_coverage` (usually null) is the shape-accounting line — how many
of the bound repo's extracted shapes carry a classification against canon of the bound repo's extracted shapes carry a classification against canon
@@ -109,7 +110,7 @@ async def enter_project(project_id: int) -> dict:
`inception` (milestone 297) appears ONLY when the project is yours and `inception` (milestone 297) appears ONLY when the project is yours and
nobody has decided what it inherits: it carries the current defaults nobody has decided what it inherits: it carries the current defaults
(the rulebooks it could subscribe to, design system, Systems), what to ask the (design system, Systems), what to ask the
operator — once — and the decide_project_inception call that answers it; operator — once — and the decide_project_inception call that answers it;
it repeats on every enter until a decision is recorded. it repeats on every enter until a decision is recorded.
@@ -178,8 +179,7 @@ async def enter_project(project_id: int) -> dict:
) )
# The inception ask (milestone 297): a project nobody has decided on # The inception ask (milestone 297): a project nobody has decided on
# inherits nothing, silently — no rulebook subscriptions, no design system, # inherits nothing, silently — no design system, no Systems. Owner-only (deciding is the owner's), and only until a
# no Systems. Owner-only (deciding is the owner's), and only until a
# decision is recorded; the key is ABSENT otherwise (#2483). # decision is recorded; the key is ABSENT otherwise (#2483).
inception_ask = None inception_ask = None
if project.user_id == uid and not inception_svc.is_decided(project): if project.user_id == uid and not inception_svc.is_decided(project):
@@ -268,8 +268,9 @@ async def get_project(project_id: int) -> dict:
Returns full project fields, a milestone_summary list (every milestone, Returns full project fields, a milestone_summary list (every milestone,
with description and progress but no plan body; get_milestone reads a with description and progress but no plan body; get_milestone reads a
plan), and the rulebook-applicable_rules / subscribed_rulebooks pair the plan), the project's own rules (project_rules), and applicable_rules: the
assistant should consult when working on this project. global rules tagged to an area this project works in. Every other global
rule applies too and arrives by retrieval when the work matches it.
""" """
uid = current_user_id() uid = current_user_id()
project = await projects_svc.get_project(uid, project_id) project = await projects_svc.get_project(uid, project_id)
@@ -285,18 +286,14 @@ async def get_project(project_id: int) -> dict:
return data return data
def _inception_choices( def _inception_choices(design_system_id, seed_systems) -> dict | None:
subscribe_rulebooks, design_system_id, seed_systems,
) -> dict | None:
"""The tool args → an inception choices object, or None when no inception """The tool args → an inception choices object, or None when no inception
arg was given at all (a bare create stays undecided and enter_project arg was given at all (a bare create stays undecided and enter_project
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
system.""" system."""
if (subscribe_rulebooks is None if not design_system_id and seed_systems is None:
and not design_system_id and seed_systems is None):
return None return None
return { return {
"subscribe_rulebooks": list(subscribe_rulebooks or []),
"design_system_id": None if design_system_id in (0, -1) else design_system_id, "design_system_id": None if design_system_id in (0, -1) else design_system_id,
"seed_systems": bool(seed_systems), "seed_systems": bool(seed_systems),
} }
@@ -308,18 +305,18 @@ async def create_project(
goal: str = "", goal: str = "",
status: str = "active", status: str = "active",
color: str = "", color: str = "",
subscribe_rulebooks: list[int] | None = None,
design_system_id: int = 0, design_system_id: int = 0,
seed_systems: bool | None = None, seed_systems: bool | None = None,
) -> dict: ) -> dict:
"""Create a new project in Scribe — and decide what it inherits. """Create a new project in Scribe — and decide what it inherits.
A project's inheritance is a decision, not a default (milestone 297): A project's inheritance is a decision, not a default (milestone 297):
before calling, ask the operator the four inception questions and pass before calling, ask the operator the two inception questions and pass
the answers; a project created without any of them is UNDECIDED and the answers; a project created without either is UNDECIDED and
enter_project will ask until decide_project_inception records it. enter_project will ask until decide_project_inception records it.
Defaults if nobody decides: no rulebook subscriptions, no design system, Defaults if nobody decides: no design system, no Systems. Rules are not
no Systems. an inception question: global rules (in rulebooks) apply to every
project, and a project's own rules are written with create_project_rule.
Args: Args:
title: Project name (required). title: Project name (required).
@@ -327,10 +324,6 @@ async def create_project(
goal: The desired outcome or definition of done for the project. goal: The desired outcome or definition of done for the project.
status: one of active (default), paused, completed, archived. status: one of active (default), paused, completed, archived.
color: Optional hex colour for the project card (e.g. "#6366f1"). color: Optional hex colour for the project card (e.g. "#6366f1").
subscribe_rulebooks: rulebook ids this project opts into.
Subscription is the only way a rulebook binds a project, so a
rulebook left out simply does not apply. list_rulebooks shows
which exist.
design_system_id: the design system this project's UI is built from design_system_id: the design system this project's UI is built from
(list_design_systems); -1 = explicitly none; 0 = not stated. (list_design_systems); -1 = explicitly none; 0 = not stated.
seed_systems: true mints the standard starter Systems (CI & Release, seed_systems: true mints the standard starter Systems (CI & Release,
@@ -346,9 +339,7 @@ async def create_project(
color=color or None, color=color or None,
) )
data = project.to_dict() data = project.to_dict()
choices = _inception_choices( choices = _inception_choices(design_system_id, seed_systems)
subscribe_rulebooks, design_system_id, seed_systems,
)
if choices is not None: if choices is not None:
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp") decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
data["inception"] = decided["inception"] data["inception"] = decided["inception"]
@@ -364,7 +355,6 @@ async def create_project(
async def decide_project_inception( async def decide_project_inception(
project_id: int, project_id: int,
subscribe_rulebooks: list[int] | None = None,
design_system_id: int = 0, design_system_id: int = 0,
seed_systems: bool | None = None, seed_systems: bool | None = None,
) -> dict: ) -> dict:
@@ -372,22 +362,17 @@ async def decide_project_inception(
or re-decide later (milestone 297). or re-decide later (milestone 297).
Owner-only. Applies the effects through the ordinary tools' paths — Owner-only. Applies the effects through the ordinary tools' paths —
subscribe_project_to_rulebook, set_project_design_system and the standard Systems seed — and writes the
set_project_design_system, the standard Systems seed — and writes the
decision on the project last, so get_project/enter_project can say why decision on the project last, so get_project/enter_project can say why
the project has the rules, design and Systems it has. Re-deciding is the project has the design and Systems it has. Re-deciding replaces the
additive for subscriptions (use design system and never re-seeds Systems a project already has.
unsubscribe_project_from_rulebook to undo one), replaces the design
system, and never re-seeds Systems a project already has.
Args: as create_project's inception args. Passing nothing records a Args: as create_project's inception args. Passing nothing records a
decision to take nothing (no subscriptions, no design system, no seed) — decision to take nothing (no design system, no seed) — a valid answer,
a valid answer, stated. stated.
""" """
uid = current_user_id() uid = current_user_id()
choices = _inception_choices( choices = _inception_choices(design_system_id, seed_systems) or {}
subscribe_rulebooks, design_system_id, seed_systems,
) or {}
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp") decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
return {"project_id": project_id, **decided} return {"project_id": project_id, **decided}
+30 -121
View File
@@ -1,6 +1,6 @@
"""MCP tools for the Scribe Rulebook system. """MCP tools for the Scribe Rulebook system.
Rulebook / topic / rule CRUD, subscription management, and the rule-to-rule Rulebook / topic / rule CRUD and the rule-to-rule
edges. Thin wrappers over services/rulebooks.py — ownership is enforced in the edges. Thin wrappers over services/rulebooks.py — ownership is enforced in the
service, and the record shape comes from rule_brief / rule_detail there rather service, and the record shape comes from rule_brief / rule_detail there rather
than being rebuilt here. than being rebuilt here.
@@ -46,16 +46,14 @@ async def get_rulebook(rulebook_id: int) -> dict:
async def create_rulebook(title: str, description: str = "") -> dict: async def create_rulebook(title: str, description: str = "") -> dict:
"""Create a new rulebook (a shared, reusable module of general rules). """Create a new rulebook (a themed grouping of GLOBAL rules).
A rulebook reaches a project ONE way: the project subscribes to it A rule in a rulebook is global: it applies in every project its owner works
(subscribe_project_to_rulebook). There was a second until milestone 394 — on, and reaches a session by retrieval when the work makes it relevant
an `always_on` flag that bound every project automatically — and it is (milestone 414). There is no subscribing a project to a rulebook, and no
gone with the tier it belonged to. Opt-in is now the whole model, so a muting one per project — that machinery is gone. So a rulebook's rules must
rulebook binds what asked for it and nothing else. stay general, agnostic to any single project. Project-specific rules go in
create_project_rule.
A rulebook is SHARED, so its rules must stay general — agnostic
to any single project. Project-specific rules go in create_project_rule.
Args: Args:
title: Rulebook name. title: Rulebook name.
@@ -210,10 +208,12 @@ async def list_rules(
Args: Args:
rulebook_id: 0 = no filter; positive = restrict to that rulebook. rulebook_id: 0 = no filter; positive = restrict to that rulebook.
topic_id: 0 = no filter; positive = restrict to that topic. topic_id: 0 = no filter; positive = restrict to that topic.
project_id: 0 = no filter; positive = restrict to rules applicable project_id: 0 = no filter; positive = that project's OWN rules.
to that project (via its rulebook subscriptions). Global rules apply to every project, so they are listed by
rulebook or topic (or unfiltered), not under each project.
All filters are AND-combined; ownership-scoped. rulebook_id and topic_id AND-combine; project_id lists a project's rules
on its own. Ownership-scoped.
""" """
uid = current_user_id() uid = current_user_id()
rows = await rulebooks_svc.list_rules( rows = await rulebooks_svc.list_rules(
@@ -308,12 +308,12 @@ async def create_rule(
and let the answer stand; re-raising a declined proposal argues a rule and let the answer stand; re-raising a declined proposal argues a rule
into existence, which is the thing this whole loop exists to prevent. into existence, which is the thing this whole loop exists to prevent.
A rulebook rule is shared by every project subscribed to the rulebook, so A rulebook rule is GLOBAL — it applies in every project — so it must read as
it must read as a general standard a general standard: never pin it to one project's files, paths, or quirks.
never pin it to one project's files, paths, or quirks. For a rule that For a rule that applies to a single project only, use create_project_rule
applies to a single project only, use create_project_rule instead (no instead (no rulebook+topic ceremony). A standard only some projects share
rulebook+topic ceremony). If it's a standard a CATEGORY of projects shares, is still global in reach; write it so it names the kind of work it is
put it in a rulebook for that category and subscribe those projects to it. about, and it arrives where that work happens.
Write it general WITHOUT hedging for the exceptions. A project that needs Write it general WITHOUT hedging for the exceptions. A project that needs
to strengthen, narrow or replace this rule writes its own and links it to strengthen, narrow or replace this rule writes its own and links it
@@ -334,7 +334,7 @@ async def create_rule(
own, and fixing that breakage doesn't require the neighbouring clauses, it own, and fixing that breakage doesn't require the neighbouring clauses, it
is a separate rule. Rules that FAIL TOGETHER get linked with relate_rules is a separate rule. Rules that FAIL TOGETHER get linked with relate_rules
(kind="co_surfaces"), never merged into one row: a merged rule cannot be (kind="co_surfaces"), never merged into one row: a merged rule cannot be
cited, surfaced or suppressed a clause at a time, and it grows without cited or surfaced a clause at a time, and it grows without
limit because adding to it is always cheaper than adding a rule. limit because adding to it is always cheaper than adding a rule.
Args: Args:
@@ -420,11 +420,11 @@ async def create_project_rule(
Use this for anything SPECIFIC to one project — its files, paths, layout, Use this for anything SPECIFIC to one project — its files, paths, layout,
or quirks. This is the correct home for the project-specific detail that or quirks. This is the correct home for the project-specific detail that
must NOT go into a shared rulebook (where it would leak to every other must NOT go into a rulebook (where it would be global, and reach every
project that gets the rulebook). General standards belong in a rulebook other project). General standards belong in a rulebook instead
instead (create_rule). It bypasses the Rulebook -> Topic -> Rule ceremony; (create_rule). It bypasses the Rulebook -> Topic -> Rule ceremony; the
the rule is returned in get_project's applicable_rules (under rule surfaces by retrieval in this project's sessions only, and is listed
project_rules) and in list_rules(project_id=...). in get_project's project_rules and in list_rules(project_id=...).
PROPOSE, THEN WRITE ON A YES — create_rule's opening carries the whole PROPOSE, THEN WRITE ON A YES — create_rule's opening carries the whole
loop: the four things a proposal states (what it would require, its loop: the four things a proposal states (what it would require, its
@@ -882,94 +882,6 @@ async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
f"Restore with restore('{batch}')."} f"Restore with restore('{batch}')."}
# ── Subscriptions ──────────────────────────────────────────────────────
async def subscribe_project_to_rulebook(
project_id: int, rulebook_id: int,
) -> dict:
"""Subscribe a project to a rulebook — its rules then bind that project.
Subscription is the ONLY path for a rulebook (milestone 394): a reusable,
themed module of GENERAL rules shared across the projects that subscribe.
Subscribe a project because it fits the rulebook's theme (e.g. a visual app
-> the design-system rulebook), not to host rules about this one project —
those belong in create_project_rule.
"""
uid = current_user_id()
await rulebooks_svc.subscribe_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
)
return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": True}
async def unsubscribe_project_from_rulebook(
project_id: int, rulebook_id: int,
) -> dict:
"""Remove a project's subscription to a rulebook."""
uid = current_user_id()
await rulebooks_svc.unsubscribe_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
)
return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": False}
# ── Suppressions — project-level mute of rulebook rules / topics ────────
async def suppress_rule_for_project(
project_id: int, rule_id: int,
) -> dict:
"""Mute a single rulebook rule for one project.
The rule stays in its rulebook for other projects; only this project
skips it. Idempotent. Use unsuppress_rule_for_project to re-enable.
Project-scoped rules (create_project_rule) are NOT suppressible — delete
them with delete_rule instead.
"""
uid = current_user_id()
await rulebooks_svc.suppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=uid,
)
return {"project_id": project_id, "rule_id": rule_id, "suppressed": True}
async def unsuppress_rule_for_project(
project_id: int, rule_id: int,
) -> dict:
"""Re-enable a previously-suppressed rule for one project. Idempotent."""
uid = current_user_id()
await rulebooks_svc.unsuppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=uid,
)
return {"project_id": project_id, "rule_id": rule_id, "suppressed": False}
async def suppress_topic_for_project(
project_id: int, topic_id: int,
) -> dict:
"""Mute every rule under a topic for one project.
Equivalent to suppressing each rule in the topic individually, but
auto-includes new rules added to the topic later. Idempotent.
"""
uid = current_user_id()
await rulebooks_svc.suppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=uid,
)
return {"project_id": project_id, "topic_id": topic_id, "suppressed": True}
async def unsuppress_topic_for_project(
project_id: int, topic_id: int,
) -> dict:
"""Re-enable a previously-suppressed topic for one project. Idempotent."""
uid = current_user_id()
await rulebooks_svc.unsuppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=uid,
)
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
async def relate_rules( async def relate_rules(
from_rule_id: int, to_rule_id: int, kind: str, note: str = "", from_rule_id: int, to_rule_id: int, kind: str, note: str = "",
) -> dict: ) -> dict:
@@ -981,7 +893,7 @@ async def relate_rules(
together. Use it when you are tempted to fold one rule into another together. Use it when you are tempted to fold one rule into another
because "either could surface without the other": that instinct is because "either could surface without the other": that instinct is
right and merging is the wrong fix, because a merged rule cannot be right and merging is the wrong fix, because a merged rule cannot be
cited, suppressed or surfaced a clause at a time. Symmetric — draw it cited or surfaced a clause at a time. Symmetric — draw it
once, it reads from both ends. once, it reads from both ends.
- kind="overrides" — this rule supersedes that one for its scope. Use it - kind="overrides" — this rule supersedes that one for its scope. Use it
when a project rule is stricter than, or replaces, an inherited one, when a project rule is stricter than, or replaces, an inherited one,
@@ -1046,10 +958,10 @@ async def rules_due_for_verification(
Never-checked rules always qualify. 0 = no age filter. Never-checked rules always qualify. 0 = no age filter.
never_only: only rules nobody has ever verified. never_only: only rules nobody has ever verified.
NOT filterable by project, deliberately: a project reaches rules through NOT filterable by project, deliberately: a project is bound by its own
project scope and rulebook subscriptions, and a filter that missed one of rules AND every global rule, and a filter that dropped the global ones
those paths would UNDER-report — which is the would UNDER-report — which is the exact failure this whole surface exists
exact failure this whole surface exists to prevent. Read the whole list. to prevent. Read the whole list.
""" """
uid = current_user_id() uid = current_user_id()
rules = await rulebooks_svc.rules_due_for_verification( rules = await rulebooks_svc.rules_due_for_verification(
@@ -1110,9 +1022,6 @@ def register(mcp) -> None:
create_rule, create_project_rule, update_rule, delete_rule, create_rule, create_project_rule, update_rule, delete_rule,
create_preference, update_preference, create_preference, update_preference,
relate_rules, unrelate_rules, relate_rules, unrelate_rules,
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
suppress_rule_for_project, unsuppress_rule_for_project,
suppress_topic_for_project, unsuppress_topic_for_project,
rules_due_for_verification, mark_rule_verified, rules_due_for_verification, mark_rule_verified,
rule_history, rule_history,
): ):
+2 -3
View File
@@ -81,9 +81,8 @@ async def get_task(task_id: int) -> dict:
(the areas this task is filed under; read a subsystem's whole pile with (the areas this task is filed under; read a subsystem's whole pile with
list_system_records) or, for an untagged project task, the `systems_hint` list_system_records) or, for an untagged project task, the `systems_hint`
question. For legacy question. For legacy
kind=plan tasks, the response also includes applicable_rules + kind=plan tasks, the response also includes the project's applicable_rules
subscribed_rulebooks from the task's project's rulebook subscriptions (new and project_rules (new plans are milestones — use get_milestone for those).
plans are milestones — use get_milestone for those).
A task another user shared with you also carries `shared`, `owner` and A task another user shared with you also carries `shared`, `owner` and
`permission` — it's their work item, not one you took on. `permission` — it's their work item, not one you took on.
+1 -2
View File
@@ -44,8 +44,7 @@ from scribe.models.user_profile import UserProfile # noqa: E402, F401
# Imported before rulebook: rule_systems foreign-keys canonical_systems. # Imported before rulebook: rule_systems foreign-keys canonical_systems.
from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401 from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401
from scribe.models.rulebook import ( # noqa: E402, F401 from scribe.models.rulebook import ( # noqa: E402, F401
Rulebook, RulebookTopic, Rule, RuleRelation, project_rulebook_subscriptions, Rulebook, RulebookTopic, Rule, RuleRelation, rule_systems,
rule_systems,
) )
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401 from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401 from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
+4 -5
View File
@@ -39,11 +39,10 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
) )
# The inception record (milestone 297): what this project was decided to # The inception record (milestone 297): what this project was decided to
# inherit, when, and through which door — {decided_at, decided_by, via, # inherit, when, and through which door — {decided_at, decided_by, via,
# choices: {subscribe_rulebooks, # choices: {design_system_id, seed_systems}}. NULL means nobody has
# design_system_id, seed_systems}}. NULL means nobody has decided yet, # decided yet, and enter_project asks; the effects themselves live in
# and enter_project asks; the effects themselves live in the subscription # design_system_id and the project's Systems — this is the WHY, kept so
# / exclusion tables, design_system_id and the project's Systems — this is # later surfaces can say it. See services/inception.py.
# the WHY, kept so later surfaces can say it. See services/inception.py.
inception: Mapped[dict | None] = mapped_column(JSONB, nullable=True) inception: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
def to_dict(self) -> dict: def to_dict(self) -> dict:
+7 -33
View File
@@ -236,36 +236,10 @@ class RuleRelation(Base, CreatedAtMixin):
} }
# Pure many-to-many — no model class, just the join table. # `project_rulebook_subscriptions`, `project_rule_suppressions` and
project_rulebook_subscriptions = Table( # `project_topic_suppressions` lived here until milestone 414 (migration 0101).
"project_rulebook_subscriptions", # A rule's home is its scope now: a rule in a rulebook topic is global, a rule
Base.metadata, # on a project applies to that project, and retrieval reads that directly. A
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True), # subscription had stopped changing anything a session received, and a
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True), # suppression muted rules from a subscription. A project that departs from a
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)), # global rule writes a project rule with an `overrides` relation instead.
)
# Suppressions — let a project mute individual rules or whole topics from
# rulebooks it subscribes to, without unsubscribing the rulebook itself.
# FKs CASCADE so the row vanishes when its parent is removed.
project_rule_suppressions = Table(
"project_rule_suppressions",
Base.metadata,
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
# `project_rulebook_exclusions` lived here until milestone 394. It recorded a
# project's opt-out of a whole always-on rulebook — which only made sense
# while a rulebook could bind a project WITHOUT being asked. Subscription is
# now the only reach a rulebook has, so declining one is expressed by not
# subscribing, and there is nothing left to opt out of.
project_topic_suppressions = Table(
"project_topic_suppressions",
Base.metadata,
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
Column("topic_id", BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
+1 -2
View File
@@ -99,8 +99,7 @@ async def create_project_route():
@login_required @login_required
async def decide_inception_route(project_id: int): async def decide_inception_route(project_id: int):
"""Record (or re-record) what a project inherits — milestone 297. """Record (or re-record) what a project inherits — milestone 297.
Body: the choices object {subscribe_rulebooks, Body: the choices object {design_system_id, seed_systems}; owner-only."""
design_system_id, seed_systems}; owner-only."""
uid = get_current_user_id() uid = get_current_user_id()
data = await request.get_json() or {} data = await request.get_json() or {}
choices = data.get("choices", data) choices = data.get("choices", data)
+1 -79
View File
@@ -305,37 +305,7 @@ async def delete_rule(rule_id: int):
return "", 204 return "", 204
# ── Subscriptions ────────────────────────────────────────────────────── # ── A project's rule listing ───────────────────────────────────────────
@rulebooks_bp.post("/projects/<int:project_id>/rulebook-subscriptions")
@login_required
async def subscribe_project(project_id: int):
data = await request.get_json() or {}
rulebook_id = data.get("rulebook_id")
if not rulebook_id:
return jsonify({"error": "rulebook_id is required"}), 400
try:
await rulebooks_svc.subscribe_project(
project_id=project_id, rulebook_id=int(rulebook_id), user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.delete(
"/projects/<int:project_id>/rulebook-subscriptions/<int:rulebook_id>"
)
@login_required
async def unsubscribe_project(project_id: int, rulebook_id: int):
try:
await rulebooks_svc.unsubscribe_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.get("/projects/<int:project_id>/rules") @rulebooks_bp.get("/projects/<int:project_id>/rules")
@login_required @login_required
@@ -346,54 +316,6 @@ async def get_project_rules(project_id: int):
return jsonify(result) return jsonify(result)
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
@login_required
async def suppress_project_rule(project_id: int, rule_id: int):
try:
await rulebooks_svc.suppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
@login_required
async def unsuppress_project_rule(project_id: int, rule_id: int):
try:
await rulebooks_svc.unsuppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
@login_required
async def suppress_project_topic(project_id: int, topic_id: int):
try:
await rulebooks_svc.suppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
@login_required
async def unsuppress_project_topic(project_id: int, topic_id: int):
try:
await rulebooks_svc.unsuppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/rules") @rulebooks_bp.post("/projects/<int:project_id>/rules")
@login_required @login_required
async def create_project_rule(project_id: int): async def create_project_rule(project_id: int):
+11 -89
View File
@@ -22,9 +22,6 @@ from scribe.models.rulebook import (
Rule, Rule,
Rulebook, Rulebook,
RulebookTopic, RulebookTopic,
project_rule_suppressions,
project_rulebook_subscriptions,
project_topic_suppressions,
) )
from scribe.models.setting import Setting from scribe.models.setting import Setting
from scribe.models.system import RecordSystem, System from scribe.models.system import RecordSystem, System
@@ -66,8 +63,12 @@ logger = logging.getLogger(__name__)
# (milestone 333). Carrying it is the WHOLE REASON the table is separate: the # (milestone 333). Carrying it is the WHOLE REASON the table is separate: the
# note importer maps note_id through note_id_map, so a rule id parked there # note importer maps note_id through note_id_map, so a rule id parked there
# would restore attached to whatever note took that number. # would restore attached to whatever note took that number.
# v15 (2026-09) dropped rulebook_subscriptions / rule_suppressions /
# topic_suppressions with their tables (milestone 414): a rule's scope is its
# home now. Older archives carrying those sections still restore — the keys are
# simply not read — as do the subscribe_rulebooks inception choices they hold.
# Bump when the serialized schema changes. # Bump when the serialized schema changes.
BACKUP_VERSION = 14 BACKUP_VERSION = 15
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED # 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 # below, these two lists must together account for the entire schema — which is
@@ -80,8 +81,6 @@ BACKUP_VERSION = 14
_BACKED_UP = [ _BACKED_UP = [
"users", "projects", "milestones", "notes", "task_logs", "note_drafts", "users", "projects", "milestones", "notes", "task_logs", "note_drafts",
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules", "note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
"project_rulebook_subscriptions", "project_rule_suppressions",
"project_topic_suppressions",
# v5 (2026-08): the five-year gap this list was written to stop. # v5 (2026-08): the five-year gap this list was written to stop.
"systems", "record_systems", "design_systems", "design_tokens", "systems", "record_systems", "design_systems", "design_tokens",
"note_usage_events", "repo_bindings", "note_supersessions", "note_usage_events", "repo_bindings", "note_supersessions",
@@ -234,18 +233,6 @@ def _d(val: str | None) -> date | None:
return date.fromisoformat(val) if val else None return date.fromisoformat(val) if val else None
def _subscription_rows(rows) -> list[dict]:
return [{"project_id": r.project_id, "rulebook_id": r.rulebook_id} for r in rows]
def _rule_suppression_rows(rows) -> list[dict]:
return [{"project_id": r.project_id, "rule_id": r.rule_id} for r in rows]
def _topic_suppression_rows(rows) -> list[dict]:
return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows]
# The v5 sections. Pure row-builders like the join-table helpers above, for the # The v5 sections. Pure row-builders like the join-table helpers above, for the
@@ -640,15 +627,6 @@ async def export_full_backup() -> dict:
rulebooks = (await session.execute(select(Rulebook))).scalars().all() rulebooks = (await session.execute(select(Rulebook))).scalars().all()
topics = (await session.execute(select(RulebookTopic))).scalars().all() topics = (await session.execute(select(RulebookTopic))).scalars().all()
rules = (await session.execute(select(Rule))).scalars().all() rules = (await session.execute(select(Rule))).scalars().all()
subscriptions = (await session.execute(
select(project_rulebook_subscriptions)
)).all()
rule_suppressions = (await session.execute(
select(project_rule_suppressions)
)).all()
topic_suppressions = (await session.execute(
select(project_topic_suppressions)
)).all()
return { return {
"version": BACKUP_VERSION, "version": BACKUP_VERSION,
@@ -671,9 +649,6 @@ async def export_full_backup() -> dict:
"rulebooks": _rulebook_rows(rulebooks), "rulebooks": _rulebook_rows(rulebooks),
"rulebook_topics": _topic_rows(topics), "rulebook_topics": _topic_rows(topics),
"rules": _rule_rows(rules), "rules": _rule_rows(rules),
"rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
"canonical_systems": _canonical_system_rows(canonical_systems), "canonical_systems": _canonical_system_rows(canonical_systems),
"rule_systems": _rule_system_rows(rule_system_rows), "rule_systems": _rule_system_rows(rule_system_rows),
"rule_relations": _rule_relation_rows(rule_relations), "rule_relations": _rule_relation_rows(rule_relations),
@@ -825,24 +800,6 @@ async def export_user_backup(user_id: int) -> dict:
RuleRelation.to_rule_id.in_(_rule_ids), RuleRelation.to_rule_id.in_(_rule_ids),
) )
)).scalars().all() if _rule_ids else [] )).scalars().all() if _rule_ids else []
if project_ids:
subscriptions = (await session.execute(
select(project_rulebook_subscriptions).where(
project_rulebook_subscriptions.c.project_id.in_(project_ids)
)
)).all()
rule_suppressions = (await session.execute(
select(project_rule_suppressions).where(
project_rule_suppressions.c.project_id.in_(project_ids)
)
)).all()
topic_suppressions = (await session.execute(
select(project_topic_suppressions).where(
project_topic_suppressions.c.project_id.in_(project_ids)
)
)).all()
else:
subscriptions = rule_suppressions = topic_suppressions = []
return { return {
"version": BACKUP_VERSION, "version": BACKUP_VERSION,
@@ -867,9 +824,6 @@ async def export_user_backup(user_id: int) -> dict:
"rulebooks": _rulebook_rows(rulebooks), "rulebooks": _rulebook_rows(rulebooks),
"rulebook_topics": _topic_rows(topics), "rulebook_topics": _topic_rows(topics),
"rules": _rule_rows(rules), "rules": _rule_rows(rules),
"rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
"canonical_systems": _canonical_system_rows(canonical_systems), "canonical_systems": _canonical_system_rows(canonical_systems),
"rule_systems": _rule_system_rows(rule_system_rows), "rule_systems": _rule_system_rows(rule_system_rows),
"rule_relations": _rule_relation_rows(rule_relations), "rule_relations": _rule_relation_rows(rule_relations),
@@ -1014,8 +968,6 @@ async def _restore_v2(data: dict) -> dict:
"users": 0, "projects": 0, "milestones": 0, "notes": 0, "users": 0, "projects": 0, "milestones": 0, "notes": 0,
"task_logs": 0, "note_drafts": 0, "note_versions": 0, "task_logs": 0, "note_drafts": 0, "note_versions": 0,
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0, "settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
"rulebook_subscriptions": 0, "rule_suppressions": 0,
"topic_suppressions": 0,
"systems": 0, "record_systems": 0, "design_systems": 0, "systems": 0, "record_systems": 0, "design_systems": 0,
"design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0, "design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0,
"repo_bindings": 0, "repo_bindings": 0,
@@ -1300,38 +1252,10 @@ async def _restore_v2(data: dict) -> dict:
rule_id_map[r_data["id"]] = rule.id rule_id_map[r_data["id"]] = rule.id
stats["rules"] += 1 stats["rules"] += 1
# 12. Rulebook subscriptions (v3 join table) # 12-14. Rulebook subscriptions, rule and topic suppressions (v3-v14)
for sub in data.get("rulebook_subscriptions", []): # `rulebook_subscriptions`, `rule_suppressions` and `topic_suppressions`
mapped_pid = project_id_map.get(sub.get("project_id", 0)) # are READ BY NOBODY since milestone 414 dropped their tables. An
mapped_rbid = rulebook_id_map.get(sub.get("rulebook_id", 0)) # archive carrying them still imports, for the reason 14b gives.
if mapped_pid is None or mapped_rbid is None:
continue
await session.execute(project_rulebook_subscriptions.insert().values(
project_id=mapped_pid, rulebook_id=mapped_rbid,
))
stats["rulebook_subscriptions"] += 1
# 13. Rule suppressions (v3 join table)
for sup in data.get("rule_suppressions", []):
mapped_pid = project_id_map.get(sup.get("project_id", 0))
mapped_rid = rule_id_map.get(sup.get("rule_id", 0))
if mapped_pid is None or mapped_rid is None:
continue
await session.execute(project_rule_suppressions.insert().values(
project_id=mapped_pid, rule_id=mapped_rid,
))
stats["rule_suppressions"] += 1
# 14. Topic suppressions (v3 join table)
for sup in data.get("topic_suppressions", []):
mapped_pid = project_id_map.get(sup.get("project_id", 0))
mapped_tid = topic_id_map.get(sup.get("topic_id", 0))
if mapped_pid is None or mapped_tid is None:
continue
await session.execute(project_topic_suppressions.insert().values(
project_id=mapped_pid, topic_id=mapped_tid,
))
stats["topic_suppressions"] += 1
# 14b. Always-on rulebook exclusions (v10, milestone 297) # 14b. Always-on rulebook exclusions (v10, milestone 297)
# `rulebook_exclusions` was a v10 section and is READ BY NOBODY since # `rulebook_exclusions` was a v10 section and is READ BY NOBODY since
@@ -1669,10 +1593,8 @@ async def _restore_v2(data: dict) -> dict:
# so the next edit to that project would fail on data this # so the next edit to that project would fail on data this
# importer wrote. # importer wrote.
choices.pop("exclude_always_on_rulebooks", None) choices.pop("exclude_always_on_rulebooks", None)
choices["subscribe_rulebooks"] = [ # Same for subscribe_rulebooks since milestone 414.
rulebook_id_map[i] for i in choices.get("subscribe_rulebooks") or [] choices.pop("subscribe_rulebooks", None)
if i in rulebook_id_map
]
ds = choices.get("design_system_id") ds = choices.get("design_system_id")
choices["design_system_id"] = design_system_id_map.get(ds) if ds else None choices["design_system_id"] = design_system_id_map.get(ds) if ds else None
proj.inception = {**inception, "choices": choices} proj.inception = {**inception, "choices": choices}
+24 -81
View File
@@ -7,7 +7,6 @@ A project's inheritance is a decision, not a default. The record lives on
"decided_at": "<iso>", "decided_by": <user id> | null, "decided_at": "<iso>", "decided_by": <user id> | null,
"via": "mcp" | "ui" | "legacy", "via": "mcp" | "ui" | "legacy",
"choices": { "choices": {
"subscribe_rulebooks": [rulebook ids],
"design_system_id": <id> | null, "design_system_id": <id> | null,
"seed_systems": bool "seed_systems": bool
} }
@@ -17,14 +16,15 @@ 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.
``exclude_always_on_rulebooks`` was a fourth choice until milestone 394. It Rules are not a choice any more. ``exclude_always_on_rulebooks`` went with the
let a project decline to inherit an always-on rulebook, and with no always-on always-on tier (milestone 394), and ``subscribe_rulebooks`` went with
tier there is nothing to decline — a rulebook now reaches a project by subscriptions (milestone 414): a rule in a rulebook is global and applies to
subscription, which is opt-IN, so declining is expressed by not subscribing. 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 The shape and its validator are pure; ``decide`` composes the existing
services — subscriptions, set_project_design_system, the standard Systems services — set_project_design_system and the standard Systems seed — checks
seed — checks every target BEFORE touching anything, every target BEFORE touching anything,
applies the effects (each idempotent), and writes the record LAST, so a applies the effects (each idempotent), and writes the record LAST, so a
half-applied decision is re-runnable rather than recorded as done. half-applied decision is re-runnable rather than recorded as done.
``current_defaults`` is what the enter_project ask shows: what binds today ``current_defaults`` is what the enter_project ask shows: what binds today
@@ -34,20 +34,11 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session from scribe.models import async_session
from scribe.models.project import Project from scribe.models.project import Project
from scribe.models.rulebook import Rulebook
INCEPTION_VIAS = ("mcp", "ui", "legacy") INCEPTION_VIAS = ("mcp", "ui", "legacy")
CHOICE_KEYS = ("subscribe_rulebooks", "design_system_id", "seed_systems") CHOICE_KEYS = ("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: def validate_inception(choices) -> str | None:
@@ -55,18 +46,14 @@ def validate_inception(choices) -> str | None:
None. Pure and checked BEFORE any effect is applied: a decision either None. Pure and checked BEFORE any effect is applied: a decision either
applies whole or errors whole (the StrictArgs lesson, #2709). applies whole or errors whole (the StrictArgs lesson, #2709).
Accepts the four keys, each optional: two id lists (positive ints, no Accepts two keys, each optional: ``design_system_id`` an int or None,
duplicates between exclude and subscribe), ``design_system_id`` an int ``seed_systems`` a bool. Unknown keys are an error — a typo, or a choice
or None, ``seed_systems`` a bool. Unknown keys are an error — a typo the product no longer offers, must not become a silently ignored one."""
must not become a silently ignored choice."""
if not isinstance(choices, dict): if not isinstance(choices, dict):
return "choices must be an object" return "choices must be an object"
unknown = sorted(set(choices) - set(CHOICE_KEYS)) unknown = sorted(set(choices) - set(CHOICE_KEYS))
if unknown: if unknown:
return f"unknown inception choice(s): {', '.join(unknown)} (one of: {', '.join(CHOICE_KEYS)})" return f"unknown inception choice(s): {', '.join(unknown)} (one of: {', '.join(CHOICE_KEYS)})"
subs = choices.get("subscribe_rulebooks") or []
if not _is_id_list(subs):
return "subscribe_rulebooks must be a list of rulebook ids"
ds = choices.get("design_system_id") ds = choices.get("design_system_id")
if ds is not None and (isinstance(ds, bool) or not isinstance(ds, int) or ds <= 0): 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" return "design_system_id must be a positive id or null"
@@ -77,11 +64,10 @@ def validate_inception(choices) -> str | None:
def normalize_choices(choices: dict | None) -> dict: def normalize_choices(choices: dict | None) -> dict:
"""The three keys, always present, in canonical form — what gets stored """Both keys, always present, in canonical form — what gets stored
and what the UI/agent reads back. Call after validate_inception.""" and what the UI/agent reads back. Call after validate_inception."""
choices = choices or {} choices = choices or {}
return { return {
"subscribe_rulebooks": sorted(set(choices.get("subscribe_rulebooks") or [])),
"design_system_id": choices.get("design_system_id"), "design_system_id": choices.get("design_system_id"),
"seed_systems": bool(choices.get("seed_systems", False)), "seed_systems": bool(choices.get("seed_systems", False)),
} }
@@ -95,37 +81,20 @@ def is_decided(project) -> bool:
async def current_defaults(user_id: int, project_id: int) -> dict: async def current_defaults(user_id: int, project_id: int) -> dict:
"""What the project inherits if nobody decides — the ask's payload. """What the project inherits if nobody decides — the ask's payload.
{rulebooks: [{id,title}], subscribed_rulebooks: [...], {design_system_id, design_systems: [{id,title}], systems: <count>}.
design_system_id, design_systems: [{id,title}], systems: <count>}. Instance-agnostic: an install with no design systems shows an empty list,
Instance-agnostic: an install with no rulebooks / design systems shows and the ask says so rather than inventing a default.
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 design_systems as design_systems_svc
from scribe.services import projects as projects_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 from scribe.services import systems as systems_svc
project = await projects_svc.get_project(user_id, project_id) project = await projects_svc.get_project(user_id, project_id)
if project is None: if project is None:
raise ValueError(f"project {project_id} not found") raise ValueError(f"project {project_id} not found")
async with async_session() as session:
rows = (
await session.execute(
select(Rulebook.id, Rulebook.title)
.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) designs = await design_systems_svc.list_design_systems(user_id)
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True) systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
return { return {
# ONE list since milestone 394. This was split into always-on and
# "other" because the first bound the project whether it asked or not;
# with the tier gone every rulebook is opt-in, so the split named a
# difference that no longer exists.
"rulebooks": [{"id": i, "title": t} for i, t in rows],
"subscribed_rulebooks": applicable.get("subscribed_rulebooks", []),
"design_system_id": project.design_system_id, "design_system_id": project.design_system_id,
"design_systems": [{"id": d.id, "title": d.title} for d in designs], "design_systems": [{"id": d.id, "title": d.title} for d in designs],
"systems": len(systems), "systems": len(systems),
@@ -137,22 +106,6 @@ async def _check_targets(user_id: int, choices: dict) -> None:
effect lands — a decision applies whole or errors whole.""" effect lands — a decision applies whole or errors whole."""
from scribe.services import access from scribe.services import access
wanted = set(choices["subscribe_rulebooks"])
if wanted:
async with async_session() as session:
rows = (
await session.execute(
select(Rulebook.id).where(
Rulebook.id.in_(wanted),
Rulebook.owner_user_id == user_id,
Rulebook.deleted_at.is_(None),
)
)
).all()
found = {rid for (rid,) in rows}
missing = sorted(wanted - found)
if missing:
raise ValueError(f"rulebook(s) {missing} not found (or not yours)")
ds = choices["design_system_id"] ds = choices["design_system_id"]
if ds is not None and not await access.can_read_design_system(user_id, ds): 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)") raise ValueError(f"design system {ds} not found (or not readable)")
@@ -167,20 +120,17 @@ async def decide(
) -> dict: ) -> dict:
"""Record a project's inception decision and apply it (milestone 297). """Record a project's inception decision and apply it (milestone 297).
Owner-only. Validates the choices (pure) and every target (owned / Owner-only. Validates the choices (pure) and every target (readable)
readable) first; then, each idempotent: subscribe the named rulebooks, first; then, each idempotent: point the project at the design system
point the project at the design system (None = explicitly none), seed the (None = explicitly none), seed the standard Systems if asked and the
standard Systems if asked and the project has none; then write project has none; then write ``projects.inception`` LAST. Re-deciding
``projects.inception`` LAST. Re-deciding is additive for subscriptions replaces the design system and re-seeds nothing a project already has.
(nothing is silently dropped — unsubscribe is an explicit call), replaces
the design system, and re-seeds nothing a project already has.
Returns {"inception": <record>, "effects": {excluded, subscribed, Returns {"inception": <record>, "effects": {design_system_id,
design_system_id, systems_seeded}}. systems_seeded}}.
""" """
from scribe.services import design_systems as design_systems_svc from scribe.services import design_systems as design_systems_svc
from scribe.services import projects as projects_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 from scribe.services import systems as systems_svc
if via not in INCEPTION_VIAS or via == "legacy": if via not in INCEPTION_VIAS or via == "legacy":
@@ -194,8 +144,6 @@ async def decide(
raise ValueError(f"project {project_id} not found (or not yours)") raise ValueError(f"project {project_id} not found (or not yours)")
await _check_targets(user_id, choices) await _check_targets(user_id, choices)
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( if not await design_systems_svc.set_project_design_system(
user_id, project_id, choices["design_system_id"] user_id, project_id, choices["design_system_id"]
): ):
@@ -219,7 +167,6 @@ async def decide(
return { return {
"inception": record, "inception": record,
"effects": { "effects": {
"subscribed": choices["subscribe_rulebooks"],
"design_system_id": choices["design_system_id"], "design_system_id": choices["design_system_id"],
"systems_seeded": [sy.name for sy in seeded], "systems_seeded": [sy.name for sy in seeded],
}, },
@@ -235,24 +182,20 @@ async def inception_ask(user_id: int, project_id: int) -> dict:
defaults = await current_defaults(user_id, project_id) defaults = await current_defaults(user_id, project_id)
except Exception: except Exception:
return {} return {}
books = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["rulebooks"]) or "none"
designs = ", ".join(f"{d['title']} (#{d['id']})" for d in defaults["design_systems"]) or "none" designs = ", ".join(f"{d['title']} (#{d['id']})" for d in defaults["design_systems"]) or "none"
return { return {
"defaults": defaults, "defaults": defaults,
"ask": ( "ask": (
"This project has no inception decision: nobody has said what it " "This project has no inception decision: nobody has said what it "
f"inherits. Rulebooks it could subscribe to — {books}; design system — " "inherits. Design system — "
f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} " f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, " f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
"once: which rulebooks to subscribe (default: none — a rulebook binds " "once: which design system (or none), and whether to seed "
"a project only when it opts in), which design system (or none), and "
"whether to seed "
"the standard starter Systems — then record the answers. This ask repeats on " "the standard starter Systems — then record the answers. This ask repeats on "
"every enter_project until a decision is recorded." "every enter_project until a decision is recorded."
), ),
"call": ( "call": (
f"decide_project_inception(project_id={project_id}, " f"decide_project_inception(project_id={project_id}, "
"subscribe_rulebooks=[...], "
"design_system_id=<id | -1 for none>, seed_systems=<true|false>)" "design_system_id=<id | -1 for none>, seed_systems=<true|false>)"
), ),
} }
+1 -1
View File
@@ -48,8 +48,8 @@ async def start_planning(
{ {
"milestone": <milestone dict>, "milestone": <milestone dict>,
"applicable_rules": [...], "applicable_rules": [...],
"subscribed_rulebooks": [...],
"applicable_rules_truncated": bool, "applicable_rules_truncated": bool,
"project_rules": [...],
"project_goal": str, "project_goal": str,
"open_task_count": int, "open_task_count": int,
"steps": [<task dict>, ...], # only when steps were given "steps": [<task dict>, ...], # only when steps were given
+116 -411
View File
@@ -91,7 +91,7 @@ async def update_rulebook(
async def delete_rulebook(rulebook_id: int, user_id: int) -> None: async def delete_rulebook(rulebook_id: int, user_id: int) -> None:
"""Delete a rulebook. Cascade-deletes topics, rules, subscriptions.""" """Delete a rulebook. Cascade-deletes its topics and rules."""
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(Rulebook).where( select(Rulebook).where(
@@ -265,30 +265,6 @@ async def _assert_project_owned(session, project_id: int, user_id: int) -> None:
raise ValueError(f"project {project_id} not found") raise ValueError(f"project {project_id} not found")
async def _assert_rulebook_rule_owned(session, rule_id: int, user_id: int) -> None:
"""Raise ValueError if rule isn't a rulebook rule the user owns.
Project-scoped rules (Rule.project_id set, topic_id NULL) are NOT
suppressible — they belong to the project; delete them instead. This
helper deliberately excludes them.
"""
from scribe.models.rulebook import Rule
result = await session.execute(
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
Rule.id == rule_id,
Rulebook.owner_user_id == user_id,
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
)
if result.scalar_one_or_none() is None:
raise ValueError(f"rule {rule_id} not found or not a rulebook rule")
# The vocabularies migration 0088's CHECK constraints enforce. Named here so # The vocabularies migration 0088's CHECK constraints enforce. Named here so
# a caller can be corrected before the database refuses it (rule 36 keeps the # a caller can be corrected before the database refuses it (rule 36 keeps the
# two in step; this keeps the error readable). # two in step; this keeps the error readable).
@@ -414,9 +390,7 @@ def _refresh_rule_embedding(rule: Rule) -> None:
logger.exception("embedding refresh failed for rule %s", rule.id) logger.exception("embedding refresh failed for rule %s", rule.id)
async def co_surfaced_partners( async def co_surfaced_partners(user_id: int, rule_ids: list[int]) -> list[Rule]:
user_id: int, rule_ids: list[int], exclude_ids: set[int] | None = None,
) -> list[Rule]:
"""Rules that must arrive WITH the given ones, because they fail together. """Rules that must arrive WITH the given ones, because they fail together.
This is the whole reason `co_surfaces` exists. Rule 144 was split off rule This is the whole reason `co_surfaces` exists. Rule 144 was split off rule
@@ -425,18 +399,14 @@ async def co_surfaced_partners(
what the entire shape is intended to be." Merging was the only fix what the entire shape is intended to be." Merging was the only fix
available; this is the fix that should have been available. available; this is the fix that should have been available.
Two limits, both deliberate: Only rules the caller OWNS: an edge is not a back door into someone else's
rulebook. Whether a partner can reach a given PROJECT is the caller's
- Only rules the caller OWNS. An edge is not a back door into someone question (get_applicable_rules drops another project's rule), because this
else's rulebook. answers "what fails with these", which has no project in it.
- `exclude_ids` is honoured, and callers pass the project's SUPPRESSIONS.
A project that explicitly muted a rule should not have it dragged back in
by an edge — the suppression is a decision, and the edge does not
outrank it.
""" """
if not rule_ids: if not rule_ids:
return [] return []
known = set(rule_ids) | (exclude_ids or set()) known = set(rule_ids)
async with async_session() as session: async with async_session() as session:
edges = (await session.execute( edges = (await session.execute(
select(RuleRelation).where( select(RuleRelation).where(
@@ -523,9 +493,10 @@ async def create_project_rule(
) -> Rule: ) -> Rule:
"""Create a rule scoped to a single project (no rulebook ceremony). """Create a rule scoped to a single project (no rulebook ceremony).
Project-scoped rules apply only to the named project; they don't Project-scoped rules apply only to the named project: retrieval surfaces
propagate via rulebook subscriptions. Topic_id is left NULL — the them in that project's sessions and nowhere else (milestone 414), where a
CHECK constraint enforces exactly-one of (topic_id, project_id). rule in a rulebook topic is global. Topic_id is left NULL — the CHECK
constraint enforces exactly-one of (topic_id, project_id).
""" """
async with async_session() as session: async with async_session() as session:
await _assert_project_owned(session, project_id, user_id) await _assert_project_owned(session, project_id, user_id)
@@ -555,18 +526,40 @@ async def list_rules(
topic_id: int | None = None, topic_id: int | None = None,
project_id: int | None = None, project_id: int | None = None,
) -> list[Rule]: ) -> list[Rule]:
"""List rules filtered by any of the three IDs. All filters are ownership-scoped. """List rules by rulebook, topic or project. Ownership-scoped.
When project_id is set, the result includes both rulebook rules reached via A rule has one home (milestone 414): a rulebook topic, where it is global,
project_rulebook_subscriptions AND project-scoped rules (Rule.project_id). or a project. So the filters name homes rather than reach:
When rulebook_id or topic_id is set, project-scoped rules are excluded by
construction (they have neither). With no filter, only rulebook rules are - `project_id` lists that project's OWN rules. Global rules apply to every
returned — adding all of a user's project-scoped rules unprompted would project, so listing them under each one would say nothing; list them by
surprise existing callers. rulebook, or unfiltered. `rulebook_id` / `topic_id` don't combine with it
— a project rule has neither.
- `rulebook_id` / `topic_id` list global rules in that rulebook or topic.
- No filter lists every global rule. A user's project rules are left out:
they belong to their projects, and mixing them into the rulebook listing
would surprise its callers.
Before milestone 414, `project_id` returned the rules of every rulebook the
project SUBSCRIBED to plus its own. Subscriptions are gone.
""" """
from scribe.models.rulebook import project_rulebook_subscriptions from scribe.models.project import Project
async with async_session() as session: async with async_session() as session:
if project_id:
result = await session.execute(
select(Rule)
.join(Project, Rule.project_id == Project.id)
.where(
Project.user_id == user_id,
Rule.project_id == project_id,
Rule.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.order_by(Rule.order_index, Rule.title)
)
return list(result.scalars().all())
stmt = ( stmt = (
select(Rule) select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id) .join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
@@ -582,39 +575,11 @@ async def list_rules(
stmt = stmt.where(Rule.topic_id == topic_id) stmt = stmt.where(Rule.topic_id == topic_id)
if rulebook_id: if rulebook_id:
stmt = stmt.where(RulebookTopic.rulebook_id == rulebook_id) stmt = stmt.where(RulebookTopic.rulebook_id == rulebook_id)
if project_id:
stmt = (
stmt.join(
project_rulebook_subscriptions,
project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
)
.where(project_rulebook_subscriptions.c.project_id == project_id)
)
stmt = stmt.order_by( stmt = stmt.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title, Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
) )
result = await session.execute(stmt) result = await session.execute(stmt)
rulebook_rules = list(result.scalars().all()) return list(result.scalars().all())
if not project_id:
return rulebook_rules
# Project-scoped rules (topic_id IS NULL, project_id matches).
# Verifies ownership by joining Project on user_id.
from scribe.models.project import Project
proj_stmt = (
select(Rule)
.join(Project, Rule.project_id == Project.id)
.where(
Project.user_id == user_id,
Rule.project_id == project_id,
Rule.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.order_by(Rule.order_index, Rule.title)
)
proj_result = await session.execute(proj_stmt)
return rulebook_rules + list(proj_result.scalars().all())
async def _fetch_owned_rule(session, rule_id: int, user_id: int) -> Optional[Rule]: async def _fetch_owned_rule(session, rule_id: int, user_id: int) -> Optional[Rule]:
@@ -930,282 +895,42 @@ async def delete_rule(rule_id: int, user_id: int) -> None:
await session.commit() await session.commit()
# ── Subscriptions + get_applicable_rules ─────────────────────────────── # ── get_applicable_rules ────────────────────────────────────────────────
async def subscribe_project(
project_id: int, rulebook_id: int, user_id: int,
) -> None:
"""Add a subscription. Idempotent — duplicates raise; we swallow."""
from scribe.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_rulebook_owned(session, rulebook_id, user_id)
# ON CONFLICT DO NOTHING via try/except to keep dialect-agnostic.
try:
await session.execute(
insert(project_rulebook_subscriptions).values(
project_id=project_id, rulebook_id=rulebook_id,
)
)
await session.commit()
except Exception:
await session.rollback() # PK collision = already subscribed; fine.
async def unsubscribe_project(
project_id: int, rulebook_id: int, user_id: int,
) -> None:
from scribe.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_rulebook_owned(session, rulebook_id, user_id)
await session.execute(
sql_delete(project_rulebook_subscriptions).where(
project_rulebook_subscriptions.c.project_id == project_id,
project_rulebook_subscriptions.c.rulebook_id == rulebook_id,
)
)
await session.commit()
# ── Suppressions — project-level mute of rulebook rules / topics ────────
async def suppress_rule_for_project(
project_id: int, rule_id: int, user_id: int,
) -> None:
"""Mute one rulebook rule for one project. Idempotent."""
from scribe.models.rulebook import project_rule_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_rulebook_rule_owned(session, rule_id, user_id)
try:
await session.execute(
insert(project_rule_suppressions).values(
project_id=project_id, rule_id=rule_id,
)
)
await session.commit()
except Exception:
await session.rollback() # PK collision = already suppressed; fine.
async def unsuppress_rule_for_project(
project_id: int, rule_id: int, user_id: int,
) -> None:
"""Unmute one rulebook rule for one project. Idempotent."""
from scribe.models.rulebook import project_rule_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await session.execute(
sql_delete(project_rule_suppressions).where(
project_rule_suppressions.c.project_id == project_id,
project_rule_suppressions.c.rule_id == rule_id,
)
)
await session.commit()
async def suppress_topic_for_project(
project_id: int, topic_id: int, user_id: int,
) -> None:
"""Mute every rule under one topic for one project. Idempotent."""
from scribe.models.rulebook import project_topic_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_topic_owned(session, topic_id, user_id)
try:
await session.execute(
insert(project_topic_suppressions).values(
project_id=project_id, topic_id=topic_id,
)
)
await session.commit()
except Exception:
await session.rollback()
async def unsuppress_topic_for_project(
project_id: int, topic_id: int, user_id: int,
) -> None:
"""Unmute a topic for one project. Idempotent."""
from scribe.models.rulebook import project_topic_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await session.execute(
sql_delete(project_topic_suppressions).where(
project_topic_suppressions.c.project_id == project_id,
project_topic_suppressions.c.topic_id == topic_id,
)
)
await session.commit()
def _tagged_rule_ids():
"""Rules carrying at least one canonical area tag (milestone 394).
The complement is what matters: a rule NOT in this set was never narrowed
by its author, so it is general to its rulebook and applies wherever that
rulebook is subscribed. Expressed as a subquery rather than a fetched list
so the area test stays inside the one statement `limit` is counted on.
"""
return select(rule_systems.c.rule_id)
async def get_applicable_rules( async def get_applicable_rules(
project_id: int, user_id: int, limit: int = 50, project_id: int, user_id: int, limit: int = 50,
) -> dict: ) -> dict:
"""Return rules applicable to a project — both via rulebook subscriptions """The rules a project's LISTING shows: its own, and the global rules
and project-scoped rules (Rule.project_id matches), with suppressed rules deterministically bound to the areas it works in.
and suppressed topics filtered out.
Shape: Shape:
{ {
"rules": [{id, title, statement, "rules": [{id, title, statement, topic_id, topic_title,
topic_id, topic_title, rulebook_id, rulebook_title, ...}, ...],
rulebook_id, rulebook_title}, ...], "project_rules": [{id, title, statement, ...}, ...],
"project_rules": [{id, title, statement}, ...],
"suppressed_rules": [{id, title,
topic_id, topic_title,
rulebook_id, rulebook_title}, ...],
"suppressed_topics": [{id, title,
rulebook_id, rulebook_title}, ...],
"truncated": bool, "truncated": bool,
"subscribed_rulebooks": [{id, title}, ...]
} }
`rules` is the subscription-derived set with project-level suppressions NOT WHAT A SESSION RECEIVES. Rules reach a session by retrieval, which
applied. `project_rules` is the project-scoped set (never suppressed — reads a rule's home (milestone 414): global rules everywhere, a project's
delete instead). `suppressed_rules` / `suppressed_topics` carry the own rules in that project. This is the listing a planning read carries so
titles + rulebook context callers need to display what was filtered a reader can see which constraints are on the table, and it is narrower
without round-tripping for names. than retrieval on purpose — "every global rule" is not a list anyone reads.
`rules` is the global rules TAGGED to a canonical area this project works
in (milestone 307, D7): a deterministic tag match, never a similarity
score. Before milestone 414 this was every rule in a SUBSCRIBED rulebook,
narrowed by area only where an author had tagged one. With subscriptions
gone there is no opt-in left to scope the untagged ones, and an untagged
global rule is general by construction — it arrives by retrieval when the
work makes it relevant, like every other rule.
`project_rules` is the project's own, never filtered by area: a rule
written ON a project is scoped to it already.
""" """
from scribe.models.rulebook import ( from scribe.models.project import Project
project_rulebook_subscriptions,
project_rule_suppressions,
project_topic_suppressions,
)
async with async_session() as session: async with async_session() as session:
# Subscribed rulebooks for the project (ownership-scoped).
sub_q = (
select(Rulebook.id, Rulebook.title)
.join(
project_rulebook_subscriptions,
project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
)
.where(
project_rulebook_subscriptions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
Rulebook.deleted_at.is_(None),
)
.order_by(Rulebook.title)
)
sub_rows = (await session.execute(sub_q)).all()
subscribed_rulebooks = [
{"id": rb_id, "title": rb_title} for rb_id, rb_title in sub_rows
]
# Suppressed rules — joined to topic + rulebook so callers can render
# context without a follow-up lookup. Ownership-scoped via rulebook.
suppressed_rules_q = (
select(
Rule.id, Rule.title,
RulebookTopic.id.label("topic_id"),
RulebookTopic.title.label("topic_title"),
Rulebook.id.label("rulebook_id"),
Rulebook.title.label("rulebook_title"),
)
.join(project_rule_suppressions, project_rule_suppressions.c.rule_id == Rule.id)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
project_rule_suppressions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
)
.order_by(Rulebook.title, RulebookTopic.title, Rule.title)
)
suppressed_rule_rows = (await session.execute(suppressed_rules_q)).all()
suppressed_rules = [
{"id": rid, "title": rt, "topic_id": ti, "topic_title": tt,
"rulebook_id": rbi, "rulebook_title": rbt}
for rid, rt, ti, tt, rbi, rbt in suppressed_rule_rows
]
suppressed_rule_ids = [r["id"] for r in suppressed_rules]
# Suppressed topics — joined to rulebook for context.
suppressed_topics_q = (
select(
RulebookTopic.id, RulebookTopic.title,
Rulebook.id.label("rulebook_id"),
Rulebook.title.label("rulebook_title"),
)
.join(project_topic_suppressions, project_topic_suppressions.c.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
project_topic_suppressions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
)
.order_by(Rulebook.title, RulebookTopic.title)
)
suppressed_topic_rows = (await session.execute(suppressed_topics_q)).all()
suppressed_topics = [
{"id": tid, "title": tt, "rulebook_id": rbi, "rulebook_title": rbt}
for tid, tt, rbi, rbt in suppressed_topic_rows
]
suppressed_topic_ids = [t["id"] for t in suppressed_topics]
# Applicable rules (limit + 1 so we can detect truncation). Filter
# in SQL so truncation reflects the post-suppression count, not the
# raw subscription count.
# Selects the ENTITY, not a column list: rule_brief is the one place
# that decides which fields a surfaced rule carries, and a column list
# here would be a second such decision to keep in step. The row count
# is bounded by `limit`, so this is a listing, not a scan.
rules_q = (
select(
Rule,
RulebookTopic.title.label("topic_title"),
Rulebook.id.label("rulebook_id"),
Rulebook.title.label("rulebook_title"),
)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.join(
project_rulebook_subscriptions,
project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
)
.where(
project_rulebook_subscriptions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
# An inception exclusion is total (milestone 297): a rulebook the
# project opted out of contributes nothing, subscribed or not.
)
.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
.limit(limit + 1)
)
if suppressed_rule_ids:
rules_q = rules_q.where(Rule.id.notin_(suppressed_rule_ids))
if suppressed_topic_ids:
rules_q = rules_q.where(Rule.topic_id.notin_(suppressed_topic_ids))
# AREA BINDING (milestone 307, narrowed by 394). A rule reaches this
# project when it is tagged to an area the project actually works in —
# a deterministic tag match, never a similarity score, so bindingness
# never depends on a ranking (D7).
#
# Applied in SQL rather than by filtering afterwards, so `limit` counts
# the rules that will actually be surfaced instead of counting rules
# that are about to be dropped.
project_area_ids = (await session.execute( project_area_ids = (await session.execute(
select(System.canonical_id).where( select(System.canonical_id).where(
System.project_id == project_id, System.project_id == project_id,
@@ -1214,36 +939,37 @@ async def get_applicable_rules(
System.status == "active", System.status == "active",
).distinct() ).distinct()
)).scalars().all() )).scalars().all()
reachable = select(rule_systems.c.rule_id).where(
rule_systems.c.canonical_id.in_(project_area_ids) rule_rows = []
) if project_area_ids else None if project_area_ids:
# SUBSCRIPTION IS THE SCOPE; AREAS NARROW ONLY WHERE AN AUTHOR ASKED. # Selects the ENTITY, not a column list: rule_brief is the one
# # place that decides which fields a surfaced rule carries. Filtered
# This read `always_on OR reachable` (milestone 307). The tier arm is # in SQL so `limit` counts the rules that will actually be shown.
# gone, and the first attempt at 394 kept only the reachable arm — so rule_rows = (await session.execute(
# a subscribed rulebook's untagged rules stopped arriving at all. That select(
# was wrong twice over: the query above is ALREADY scoped to rulebooks Rule,
# this project subscribed to, so the project opted in and was then RulebookTopic.title.label("topic_title"),
# handed a subset of what it asked for; and the milestone is explicit Rulebook.id.label("rulebook_id"),
# that subscription-derived rules are not what it removes. The Rulebook.title.label("rulebook_title"),
# integration suite caught it through a co_surfaces partner that never )
# arrived because the rule it travels with had been filtered out. .join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
# .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
# So: every rule in a subscribed rulebook applies, EXCEPT that a rule .where(
# tagged to specific areas applies only to a project working in one of Rulebook.owner_user_id == user_id,
# them. An untagged rule is general to its rulebook by construction — Rule.deleted_at.is_(None),
# nobody narrowed it — while tagging is an author saying "this is RulebookTopic.deleted_at.is_(None),
# about CI" and meaning it. That keeps D7's deterministic narrowing Rulebook.deleted_at.is_(None),
# where it was asked for without inventing it where it was not. Rule.id.in_(
if reachable is not None: select(rule_systems.c.rule_id).where(
rules_q = rules_q.where( rule_systems.c.canonical_id.in_(project_area_ids)
or_(Rule.id.in_(reachable), Rule.id.notin_(_tagged_rule_ids())), )
) ),
else: )
# No canonical areas on this project: nothing can match by area, .order_by(
# so only the untagged (general) rules apply. Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
rules_q = rules_q.where(Rule.id.notin_(_tagged_rule_ids())) )
rule_rows = (await session.execute(rules_q)).all() .limit(limit + 1)
)).all()
truncated = len(rule_rows) > limit truncated = len(rule_rows) > limit
rules = [ rules = [
rule_brief(rule, topic_title=tt, rulebook_id=rbi, rulebook_title=rbt) rule_brief(rule, topic_title=tt, rulebook_id=rbi, rulebook_title=rbt)
@@ -1251,8 +977,7 @@ async def get_applicable_rules(
] ]
# Project-scoped rules — verifies ownership via Project.user_id. # Project-scoped rules — verifies ownership via Project.user_id.
from scribe.models.project import Project proj_rule_rows = (await session.execute(
proj_rules_q = (
select(Rule) select(Rule)
.join(Project, Rule.project_id == Project.id) .join(Project, Rule.project_id == Project.id)
.where( .where(
@@ -1262,32 +987,26 @@ async def get_applicable_rules(
Project.deleted_at.is_(None), Project.deleted_at.is_(None),
) )
.order_by(Rule.order_index, Rule.title) .order_by(Rule.order_index, Rule.title)
) )).all()
# A PROJECT'S OWN RULES ARE NOT FILTERED BY AREA, and the asymmetry
# with the family query above is the point. A family rule has to earn
# its way into this project; a rule written ON this project is scoped
# to it by construction, and filtering it again would drop rules whose
# only fault is that nobody tagged them to a System.
proj_rule_rows = (await session.execute(proj_rules_q)).all()
project_rules = [rule_brief(rule) for (rule,) in proj_rule_rows] project_rules = [rule_brief(rule) for (rule,) in proj_rule_rows]
# Edges travel with the rules they belong to (milestone 307). # Edges travel with the rules they belong to (milestone 307).
# #
# A co_surfaces partner that was not otherwise selected is ADDED, because a # A co_surfaces partner that was not otherwise selected is ADDED, because a
# rule that arrives without the half it fails with is the failure the edge # rule that arrives without the half it fails with is the failure the edge
# was created to prevent. Suppressions are passed as exclusions so an # was created to prevent — but only a partner that could reach this
# explicit mute still wins over an edge. # project at all. An edge to another project's rule is not a way in.
surfaced_ids = [r["id"] for r in rules] + [r["id"] for r in project_rules] surfaced_ids = [r["id"] for r in rules] + [r["id"] for r in project_rules]
partners = await co_surfaced_partners( partners = await co_surfaced_partners(user_id, surfaced_ids)
user_id, surfaced_ids, exclude_ids=set(suppressed_rule_ids),
)
for partner in partners: for partner in partners:
if partner.project_id not in (None, project_id):
continue
rules.append(rule_brief(partner, via="co_surfaces")) rules.append(rule_brief(partner, via="co_surfaces"))
surfaced_ids.append(partner.id) surfaced_ids.append(partner.id)
# Relations on every surfaced rule, so a reader can see that an override # Relations on every surfaced rule, so a reader can see that an override
# exists rather than discovering the contradiction by acting on the wrong # exists rather than discovering the contradiction by acting on the wrong
# one. Areas too — they are why a conditional rule is here at all. # one. Areas too — they are why a global rule is in this listing at all.
edges = await list_rule_relations(surfaced_ids) edges = await list_rule_relations(surfaced_ids)
areas = await list_rule_systems(surfaced_ids) areas = await list_rule_systems(surfaced_ids)
for brief in (*rules, *project_rules): for brief in (*rules, *project_rules):
@@ -1296,14 +1015,7 @@ async def get_applicable_rules(
if areas.get(brief["id"]): if areas.get(brief["id"]):
brief["systems"] = areas[brief["id"]] brief["systems"] = areas[brief["id"]]
return { return {"rules": rules, "project_rules": project_rules, "truncated": truncated}
"rules": rules,
"project_rules": project_rules,
"suppressed_rules": suppressed_rules,
"suppressed_topics": suppressed_topics,
"truncated": truncated,
"subscribed_rulebooks": subscribed_rulebooks,
}
def rules_payload( def rules_payload(
@@ -1313,11 +1025,11 @@ def rules_payload(
Every surface that hands rules to an agent (enter_project, get_project, Every surface that hands rules to an agent (enter_project, get_project,
get_milestone, get_task for legacy plans, start_planning) carries the get_milestone, get_task for legacy plans, start_planning) carries the
same seven keys under the same names — so a reader learns them once. One same keys under the same names — so a reader learns them once. One
place renames `rules` → `applicable_rules` and `truncated` → place renames `rules` → `applicable_rules` and `truncated` →
`applicable_rules_truncated`; the tools merge this into their payloads. `applicable_rules_truncated`; the tools merge this into their payloads.
IT ALSO RECORDS THE SURFACING, which is why it now takes a caller and a IT ALSO RECORDS THE SURFACING, which is why it takes a caller and a
source. Every one of those surfaces is a bulk delivery — the applicable set source. Every one of those surfaces is a bulk delivery — the applicable set
handed over whole, chosen by nobody — so this is the one place that has to handed over whole, chosen by nobody — so this is the one place that has to
emit for all of them. Doing it per-caller instead would be five sites to emit for all of them. Doing it per-caller instead would be five sites to
@@ -1330,17 +1042,16 @@ def rules_payload(
`RANKED_SOURCES` in `rule_usage` is what folds them back together. `RANKED_SOURCES` in `rule_usage` is what folds them back together.
Emitting from here is safe in a way emitting from `get_applicable_rules` Emitting from here is safe in a way emitting from `get_applicable_rules`
would not be: this function is only ever called to BUILD A REPLY. The two would not be: this function is only ever called to BUILD A REPLY. The
other callers of the rules machinery — the write-path etag arm other caller of the rules machinery — the write-path etag arm
(`plugin_context`) — computed a marker and showed nobody (`plugin_context`) — computes a marker and shows nobody anything, and
anything, and counting those would put rules in the denominator that no counting it would put rules in the denominator that no agent ever saw.
agent ever saw.
`brief` is the session handshake's form (#4045): the project's own rules `brief` is the session handshake's form (#4045): the project's own rules
as id and title, and the subscribed rulebooks, nothing else. Rules reach a as id and title, nothing else. Rules reach a session in full by
session in full by retrieval, which ignores subscriptions, so the handshake retrieval, so the handshake lists which of the project's constraints exist
lists which constraints exist rather than restating them; get_rule reads rather than restating them; get_rule reads one. Only what is shown is
one. Only what is shown is recorded as surfaced. recorded as surfaced.
""" """
if brief: if brief:
project_rules = [ project_rules = [
@@ -1350,10 +1061,7 @@ def rules_payload(
record_rule_surfaced( record_rule_surfaced(
user_id=user_id, rule_ids=[r["id"] for r in project_rules], source=source, user_id=user_id, rule_ids=[r["id"] for r in project_rules], source=source,
) )
return { return {"project_rules": project_rules}
"project_rules": project_rules,
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
}
record_rule_surfaced( record_rule_surfaced(
user_id=user_id, user_id=user_id,
rule_ids=( rule_ids=(
@@ -1365,10 +1073,7 @@ def rules_payload(
return { return {
"applicable_rules": applicable["rules"], "applicable_rules": applicable["rules"],
"applicable_rules_truncated": applicable["truncated"], "applicable_rules_truncated": applicable["truncated"],
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"project_rules": applicable.get("project_rules", []), "project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
} }
-16
View File
@@ -83,22 +83,6 @@ async def _cascade(session, user_id: int, etype: str, eid: int, batch: str, now)
await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.project_id == eid], batch, now) await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.project_id == eid], batch, now)
# Project-scoped rules cascade with the project they're attached to. # Project-scoped rules cascade with the project they're attached to.
await _set(session, Rule, [Rule.project_id == eid], batch, now) await _set(session, Rule, [Rule.project_id == eid], batch, now)
# Suppressions are pure associations (no deleted_at) — hard-delete
# them here so restoring the project doesn't bring stale mutes back.
# FK CASCADE would handle a full DELETE on the project row, but the
# soft-delete path keeps the project row alive; this guarantees the
# rows are gone whether or not the project ever gets purged.
from scribe.models.rulebook import (
project_rule_suppressions, project_topic_suppressions,
)
await session.execute(
sql_delete(project_rule_suppressions)
.where(project_rule_suppressions.c.project_id == eid)
)
await session.execute(
sql_delete(project_topic_suppressions)
.where(project_topic_suppressions.c.project_id == eid)
)
await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now) await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now)
elif etype == "milestone": elif etype == "milestone":
await _set(session, Note, [Note.user_id == user_id, Note.milestone_id == eid], batch, now) await _set(session, Note, [Note.user_id == user_id, Note.milestone_id == eid], batch, now)
+10 -14
View File
@@ -3,10 +3,9 @@
The WHY a project inherits what it does lives on projects.inception. Pure The WHY a project inherits what it does lives on projects.inception. Pure
validation is pinned here; the effects are step 3's integration tests. validation is pinned here; the effects are step 3's integration tests.
The opt-out of an always-on rulebook had its own association table until Rules left inception in two steps: the always-on opt-out with the tier
milestone 394. With no always-on tier there is nothing to opt out OF — a (milestone 394), and the rulebook subscription with subscriptions (milestone
rulebook binds a project only by subscription — so the table and the test 414). A rule in a rulebook is global, so there is nothing to choose.
that pinned its shape both went with it.
""" """
from scribe.models.project import Project from scribe.models.project import Project
from scribe.services.inception import ( from scribe.services.inception import (
@@ -28,10 +27,9 @@ def test_project_carries_an_inception_record_and_to_dict_shows_it():
def test_validate_inception_pins_the_choice_vocabulary(): def test_validate_inception_pins_the_choice_vocabulary():
assert CHOICE_KEYS == ("subscribe_rulebooks", "design_system_id", "seed_systems") assert CHOICE_KEYS == ("design_system_id", "seed_systems")
assert validate_inception({}) is None assert validate_inception({}) is None
assert validate_inception({"subscribe_rulebooks": [2], assert validate_inception({"design_system_id": 3, "seed_systems": True}) is None
"design_system_id": 3, "seed_systems": True}) is None
assert validate_inception({"design_system_id": None}) is None assert validate_inception({"design_system_id": None}) is None
assert "must be an object" in validate_inception([]) assert "must be an object" in validate_inception([])
assert "unknown inception choice" in validate_inception({"repo": "x"}) assert "unknown inception choice" in validate_inception({"repo": "x"})
@@ -41,19 +39,17 @@ def test_validate_inception_pins_the_choice_vocabulary():
# believe a rulebook had been declined. # believe a rulebook had been declined.
assert "unknown inception choice" in validate_inception( assert "unknown inception choice" in validate_inception(
{"exclude_always_on_rulebooks": [1]}) {"exclude_always_on_rulebooks": [1]})
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [0]}) # Same for the subscription choice since milestone 414.
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [True]}) assert "unknown inception choice" in validate_inception({"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": 0})
assert "positive id or null" in validate_inception({"design_system_id": True}) assert "positive id or null" in validate_inception({"design_system_id": True})
assert "true or false" in validate_inception({"seed_systems": "yes"}) assert "true or false" in validate_inception({"seed_systems": "yes"})
def test_normalize_choices_is_canonical_and_complete(): def test_normalize_choices_is_canonical_and_complete():
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3]}) out = normalize_choices({"design_system_id": 4})
assert out == {"subscribe_rulebooks": [1, 3], assert out == {"design_system_id": 4, "seed_systems": False}
"design_system_id": None, "seed_systems": False} assert normalize_choices(None) == {"design_system_id": None, "seed_systems": False}
assert normalize_choices(None) == {"subscribe_rulebooks": [],
"design_system_id": None, "seed_systems": False}
def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant(): def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant():
+19 -37
View File
@@ -1,9 +1,11 @@
"""Real-Postgres integration tests for project inception (milestone 297). """Real-Postgres integration tests for project inception (milestone 297).
What mocks can't prove: a decision's effects land through the real services 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 (the design system points, the standard Systems seed once), the record is
points, the standard Systems seed once), the record is written last, a bad written last, a bad target applies nothing.
target applies nothing.
Rules are not part of inception since milestone 414: a rule in a rulebook is
global and applies to every project, so there is nothing to subscribe to.
""" """
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@@ -11,7 +13,6 @@ import pytest_asyncio
from scribe.models import async_session from scribe.models import async_session
from scribe.models.project import Project from scribe.models.project import Project
from scribe.services import inception as inception_svc from scribe.services import inception as inception_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import canonical_systems as canonical_svc from scribe.services import canonical_systems as canonical_svc
from scribe.services import systems as systems_svc from scribe.services import systems as systems_svc
from tests.helpers import ensure_user from tests.helpers import ensure_user
@@ -21,8 +22,7 @@ pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine"
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def seeded(): async def seeded():
"""Owner, a fresh project, one always-on rulebook (with a rule) and one """Owner and a fresh, undecided project."""
ordinary rulebook (with a rule)."""
async with async_session() as s: async with async_session() as s:
owner = await ensure_user(s, "inception_owner") owner = await ensure_user(s, "inception_owner")
project = Project(user_id=owner.id, title="Inception target") project = Project(user_id=owner.id, title="Inception target")
@@ -30,56 +30,33 @@ async def seeded():
await s.flush() await s.flush()
ids = {"owner": owner.id, "pid": project.id} ids = {"owner": owner.id, "pid": project.id}
await s.commit() await s.commit()
# Two ordinary rulebooks. One was flagged always-on until milestone 394
# removed the tier; a rulebook now reaches a project only by subscription,
# so what used to be "binds automatically" and "binds if you opt in" are
# the same kind of thing.
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
other = await rulebooks_svc.create_rulebook(ids["owner"], "Optional practices")
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 return ids
@pytest.mark.integration @pytest.mark.integration
async def test_decide_applies_every_effect_and_records_last(seeded): async def test_decide_applies_every_effect_and_records_last(seeded):
owner, pid = seeded["owner"], seeded["pid"] owner, pid = seeded["owner"], seeded["pid"]
# Undecided: nothing binds, because nothing has been subscribed. Before
# milestone 394 the always-on rulebook bound here without being asked for,
# and this assertion read the other way round.
defaults = await inception_svc.current_defaults(owner, pid) defaults = await inception_svc.current_defaults(owner, pid)
assert sorted(r["id"] for r in defaults["rulebooks"]) == sorted( assert set(defaults) == {"design_system_id", "design_systems", "systems"}
[seeded["always"], seeded["other"]])
assert defaults["systems"] == 0 and defaults["design_system_id"] is None assert defaults["systems"] == 0 and defaults["design_system_id"] is None
assert (await rulebooks_svc.get_applicable_rules(pid, owner))["rules"] == []
out = await inception_svc.decide(owner, pid, via="mcp", choices={ out = await inception_svc.decide(owner, pid, via="mcp", choices={
"subscribe_rulebooks": [seeded["other"]],
"design_system_id": None, "design_system_id": None,
"seed_systems": True, "seed_systems": True,
}) })
assert out["effects"]["subscribed"] == [seeded["other"]] assert set(out["effects"]) == {"design_system_id", "systems_seeded"}
catalog = await canonical_svc.list_canonical_systems() catalog = await canonical_svc.list_canonical_systems()
assert len(out["effects"]["systems_seeded"]) == len(catalog) assert len(out["effects"]["systems_seeded"]) == len(catalog)
# Seeded Systems come out mapped, not needing a later reconciliation. # Seeded Systems come out mapped, not needing a later reconciliation.
seeded_systems = await systems_svc.list_systems(owner, pid) seeded_systems = await systems_svc.list_systems(owner, pid)
assert all(s.canonical_id is not None for s in seeded_systems) assert all(s.canonical_id is not None for s in seeded_systems)
# The subscription is what binds, and it is the ONLY thing that does —
# the unsubscribed rulebook contributes nothing even though it used to
# bind every project by default.
applicable = await rulebooks_svc.get_applicable_rules(pid, owner)
assert [s["id"] for s in applicable["subscribed_rulebooks"]] == [seeded["other"]]
assert "dev is home" not in [r["title"] for r in applicable["rules"]]
# The record, written last, says why. # The record, written last, says why.
async with async_session() as s: async with async_session() as s:
project = await s.get(Project, pid) project = await s.get(Project, pid)
assert inception_svc.is_decided(project) assert inception_svc.is_decided(project)
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
assert project.inception["choices"]["subscribe_rulebooks"] == [seeded["other"]] assert project.inception["choices"] == {"design_system_id": None, "seed_systems": True}
# Re-deciding with seed again mints nothing twice. # Re-deciding with seed again mints nothing twice.
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True}) again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
assert again["effects"]["systems_seeded"] == [] assert again["effects"]["systems_seeded"] == []
@@ -89,15 +66,20 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
@pytest.mark.integration @pytest.mark.integration
async def test_a_bad_decision_applies_nothing(seeded): async def test_a_bad_decision_applies_nothing(seeded):
owner, pid = seeded["owner"], seeded["pid"] owner, pid = seeded["owner"], seeded["pid"]
# A subscription to a rulebook that is not yours is refused BEFORE any # A design system that is not readable is refused BEFORE any effect
# effect lands — the seed must not happen on a decision that fails. # lands — the seed must not happen on a decision that fails.
with pytest.raises(ValueError, match="not found"): with pytest.raises(ValueError, match="not found"):
await inception_svc.decide(owner, pid, via="mcp", choices={ await inception_svc.decide(owner, pid, via="mcp", choices={
"subscribe_rulebooks": [999999], "seed_systems": True, "design_system_id": 999999, "seed_systems": True,
})
assert await systems_svc.list_systems(owner, pid) == []
# The retired rulebook choice is an unknown key, refused whole: a caller
# still passing it must not believe a rulebook was subscribed.
with pytest.raises(ValueError, match="unknown inception choice"):
await inception_svc.decide(owner, pid, via="mcp", choices={
"subscribe_rulebooks": [1], "seed_systems": True,
}) })
assert await systems_svc.list_systems(owner, pid) == [] 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"): with pytest.raises(ValueError, match="legacy"):
await inception_svc.decide(owner, pid, via="legacy", choices={}) await inception_svc.decide(owner, pid, via="legacy", choices={})
async with async_session() as s: async with async_session() as s:
+49 -64
View File
@@ -1,21 +1,21 @@
"""Real-Postgres tests for WHICH rules reach a session (milestone 307 step 5, """Real-Postgres tests for WHICH rules a project's listing names (milestone
narrowed by 394). 307 step 5, narrowed by 394 and 414).
What mocks can't prove, and what this design must not get wrong: A session receives rules by retrieval, scoped by a rule's home (see
test_integration_rule_scope). This is the other surface — the listing a
planning read carries — and what mocks can't prove about it:
1. A rule is invisible to a project that doesn't work in its area, and 1. A global rule tagged to an area arrives in the listing of a project that
arrives — binding, not suggested — to one that does. Area matching is works in that area, and not before. Area matching is DETERMINISTIC: a tag
DETERMINISTIC: a tag comparison, never a similarity score. comparison, never a similarity score.
2. A `co_surfaces` partner arrives with its other half, which is the failure 2. An UNTAGGED global rule is not listed: it applies everywhere and arrives by
that made merging rule 144 into rule 46 look like the only fix. retrieval, and listing every one under every project would say nothing.
3. An explicit suppression outranks an edge. 3. A `co_surfaces` partner arrives with its other half — the failure that made
merging rule 144 into rule 46 look like the only fix — unless the partner
lives on a different project, because an edge is not a way in.
TWO CLAIMS WERE DROPPED HERE BY MILESTONE 394, and it is worth saying which Milestone 414 dropped the suppression claim ("an explicit suppression outranks
rather than leaving a shorter list. "A rule with no tier binds exactly as an edge") with suppressions themselves.
before" and "a conditional rule is reachable, not resident" were both about
the always-on tier. There is no tier and no resident payload, so neither
states anything that can now be true or false — they were not failing, they
had stopped being claims.
""" """
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@@ -32,40 +32,27 @@ pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine"
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def world(): async def world():
"""A project with two rulebooks — one subscribed, one not. """A project with one rule of its own, and a rulebook of global rules."""
Both are ordinary rulebooks since milestone 394; the fixture used to flag
one always-on because that was a second, separate way to reach a project.
Keeping two is still worth it: a rulebook nobody subscribed to must
contribute nothing, and a fixture with only the subscribed one could not
tell "correctly scoped" from "returns everything".
"""
async with async_session() as s: async with async_session() as s:
owner = await ensure_user(s, "surfacing_owner") owner = await ensure_user(s, "surfacing_owner")
project = Project(user_id=owner.id, title="Surfacing target") project = Project(user_id=owner.id, title="Surfacing target")
s.add(project) elsewhere = Project(user_id=owner.id, title="Another project")
s.add_all([project, elsewhere])
await s.flush() await s.flush()
ids = {"owner": owner.id, "pid": project.id} ids = {"owner": owner.id, "pid": project.id, "elsewhere": elsewhere.id}
await s.commit() await s.commit()
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards") book = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
always_topic = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
await rulebooks_svc.create_rule(
always_topic.id, ids["owner"], "dev is home", "Work on dev.",
)
book = await rulebooks_svc.create_rulebook(ids["owner"], "Subscribed practices")
topic = await rulebooks_svc.create_topic(book.id, ids["owner"], "release") topic = await rulebooks_svc.create_topic(book.id, ids["owner"], "release")
plain = await rulebooks_svc.create_rule( await rulebooks_svc.create_rule(
topic.id, ids["owner"], "Between batches, keep stacking", "Keep going.", topic.id, ids["owner"], "dev is home", "Work on dev.",
when_to_apply="before pushing a branch",
) )
await rulebooks_svc.subscribe_project( own = await rulebooks_svc.create_project_rule(
project_id=ids["pid"], rulebook_id=book.id, user_id=ids["owner"], ids["pid"], ids["owner"], "Between batches, keep stacking", "Keep going.",
when_to_apply="when a batch goes green",
) )
ids.update({ ids.update({"book": book.id, "topic": topic.id, "own": own.id})
"always": always.id, "always_topic": always_topic.id,
"book": book.id, "topic": topic.id, "plain": plain.id,
})
return ids return ids
@@ -74,12 +61,18 @@ async def _titles(ids) -> set[str]:
return {r["title"] for r in applicable["rules"]} return {r["title"] for r in applicable["rules"]}
@pytest.mark.integration
async def test_an_untagged_global_rule_is_not_listed(world):
"""It applies here — and everywhere — so naming it under this project says
nothing a reader can act on. The project's own rule is always listed."""
applicable = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
assert "dev is home" not in await _titles(world)
assert [r["title"] for r in applicable["project_rules"]] == [
"Between batches, keep stacking"]
@pytest.mark.integration @pytest.mark.integration
async def test_a_conditional_rule_binds_a_project_that_works_in_its_area(world): async def test_a_tagged_global_rule_is_listed_for_a_project_that_works_in_its_area(world):
"""The payoff: the tag match carries it in deterministically. The project """The payoff: the tag match carries it in deterministically. The project
reaches the area through its own System's canonical_id — its local NAME is reaches the area through its own System's canonical_id — its local NAME is
irrelevant, which is the whole reason the catalog exists.""" irrelevant, which is the whole reason the catalog exists."""
@@ -103,7 +96,7 @@ async def test_a_conditional_rule_binds_a_project_that_works_in_its_area(world):
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"]) surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
hit = [r for r in surfaced["rules"] if r["title"] == "Release tagging"] hit = [r for r in surfaced["rules"] if r["title"] == "Release tagging"]
assert hit, "a tagged conditional rule must bind a project working in that area" assert hit, "a tagged global rule must be listed for a project working in that area"
assert [s["name"] for s in hit[0]["systems"]] == ["CI & Release"] assert [s["name"] for s in hit[0]["systems"]] == ["CI & Release"]
@@ -118,12 +111,8 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
"A name decides nothing.", "A name decides nothing.",
when_to_apply="when naming a build", when_to_apply="when naming a build",
) )
# TAGGED TO AN AREA THIS PROJECT DOES NOT WORK IN, which is what makes the # Tagged to an area this project does NOT work in, so nothing but the edge
# test able to fail at all. Since milestone 394 an UNTAGGED rule in a # can bring it in — otherwise this test could pass without the edge.
# subscribed rulebook applies on its own, so an untagged partner arrives
# through the ordinary query and the edge is never exercised — the
# assertion below passed while proving nothing, which is how this was
# noticed. Tagging it puts it out of reach of everything except the edge.
area = await canonical_svc.find_by_name("CI & Release") area = await canonical_svc.find_by_name("CI & Release")
assert area is not None, "migration 0087 seeds the standard vocabulary" assert area is not None, "migration 0087 seeds the standard vocabulary"
await rulebooks_svc.set_rule_systems(partner.id, world["owner"], [area.id]) await rulebooks_svc.set_rule_systems(partner.id, world["owner"], [area.id])
@@ -132,7 +121,7 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
) )
await rulebooks_svc.add_rule_relation( await rulebooks_svc.add_rule_relation(
world["owner"], world["plain"], partner.id, "co_surfaces", world["owner"], world["own"], partner.id, "co_surfaces",
note="they fail together", note="they fail together",
) )
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"]) surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
@@ -142,20 +131,16 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
@pytest.mark.integration @pytest.mark.integration
async def test_a_suppression_outranks_an_edge(world): async def test_an_edge_is_not_a_way_into_another_project(world):
"""The edge says these belong together; the suppression says this project """A partner that lives on a different project belongs to that project.
does not want that one. An explicit decision beats an inferred one.""" The edge says the two fail together; it does not make one project's rule
# Untagged on purpose, unlike the partner above: this test is about the apply to another."""
# SUPPRESSION winning, so the partner should be one that would otherwise foreign = await rulebooks_svc.create_project_rule(
# arrive by every available route — the ordinary query AND the edge. world["elsewhere"], world["owner"], "Another project's rule", "Not here.",
partner = await rulebooks_svc.create_rule( when_to_apply="working on the other project",
world["topic"], world["owner"], "Muted partner", "Should not arrive.",
) )
await rulebooks_svc.add_rule_relation( await rulebooks_svc.add_rule_relation(
world["owner"], world["plain"], partner.id, "co_surfaces", world["owner"], world["own"], foreign.id, "co_surfaces",
)
await rulebooks_svc.suppress_rule_for_project(
world["pid"], partner.id, world["owner"],
) )
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"]) surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
assert "Muted partner" not in {r["title"] for r in surfaced["rules"]} assert "Another project's rule" not in {r["title"] for r in surfaced["rules"]}
+1 -1
View File
@@ -78,7 +78,7 @@ async def test_get_milestone_returns_body_steps_and_rules():
step = MagicMock() step = MagicMock()
step.to_dict.return_value = {"id": 9, "title": "step 1", "status": "todo"} step.to_dict.return_value = {"id": 9, "title": "step 1", "status": "todo"}
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False, applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}]} "project_rules": [{"id": 3, "title": "own"}]}
with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone", with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone",
AsyncMock(return_value=m)), \ AsyncMock(return_value=m)), \
patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone_progress", patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone_progress",
+4 -3
View File
@@ -9,7 +9,7 @@ pytestmark = pytest.mark.usefixtures("_bind_user")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_planning_tool_delegates_to_service(): async def test_start_planning_tool_delegates_to_service():
payload = {"milestone": {"id": 5}, "applicable_rules": [], "subscribed_rulebooks": [], payload = {"milestone": {"id": 5}, "applicable_rules": [], "project_rules": [],
"applicable_rules_truncated": False, "project_goal": "", "open_task_count": 0} "applicable_rules_truncated": False, "project_goal": "", "open_task_count": 0}
with patch("scribe.mcp.tools.tasks.planning_svc.start_planning", with patch("scribe.mcp.tools.tasks.planning_svc.start_planning",
AsyncMock(return_value=payload)) as mock: AsyncMock(return_value=payload)) as mock:
@@ -26,7 +26,7 @@ async def test_start_planning_tool_delegates_to_service():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_task_augments_plan_with_rules(): async def test_get_task_augments_plan_with_rules():
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False, applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}]} "project_rules": [{"id": 3, "title": "own"}]}
with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user", with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user",
AsyncMock(return_value=(fake_task(task_kind="plan", id=9, project_id=3), "owner"))), \ AsyncMock(return_value=(fake_task(task_kind="plan", id=9, project_id=3), "owner"))), \
patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules", patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
@@ -34,7 +34,8 @@ async def test_get_task_augments_plan_with_rules():
from scribe.mcp.tools.tasks import get_task from scribe.mcp.tools.tasks import get_task
out = await get_task(task_id=9) out = await get_task(task_id=9)
assert out["applicable_rules"] == [{"id": 1, "title": "r"}] assert out["applicable_rules"] == [{"id": 1, "title": "r"}]
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}] assert out["project_rules"] == [{"id": 3, "title": "own"}]
assert "subscribed_rulebooks" not in out
assert out["applicable_rules_truncated"] is False assert out["applicable_rules_truncated"] is False
+18 -21
View File
@@ -73,7 +73,7 @@ async def test_get_project_enriches_with_milestone_summary():
p = fake_project(id=5, title="found") p = fake_project(id=5, title="found")
milestone_summary = [{"id": 10, "title": "MS", "status": "active", "total": 3}] milestone_summary = [{"id": 10, "title": "MS", "status": "active", "total": 3}]
applicable_payload = { applicable_payload = {
"rules": [], "truncated": False, "subscribed_rulebooks": [], "rules": [], "truncated": False,
} }
with patch( with patch(
"scribe.mcp.tools.projects.projects_svc.get_project", "scribe.mcp.tools.projects.projects_svc.get_project",
@@ -91,9 +91,10 @@ async def test_get_project_enriches_with_milestone_summary():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks(): async def test_get_project_includes_applicable_rules_and_project_rules():
"""The augmented get_project response includes applicable_rules and """The augmented get_project response includes applicable_rules and
subscribed_rulebooks pulled from services/rulebooks.get_applicable_rules. project_rules pulled from services/rulebooks.get_applicable_rules, and
nothing about subscriptions (milestone 414).
""" """
p = fake_project(id=3, title="Fabled Assistant") p = fake_project(id=3, title="Fabled Assistant")
milestone_summary = [] milestone_summary = []
@@ -105,7 +106,6 @@ async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks():
"rulebook_title": "FabledSword family"}, "rulebook_title": "FabledSword family"},
], ],
"truncated": False, "truncated": False,
"subscribed_rulebooks": [{"id": 1, "title": "FabledSword family"}],
} }
with patch( with patch(
"scribe.mcp.tools.projects.projects_svc.get_project", "scribe.mcp.tools.projects.projects_svc.get_project",
@@ -119,8 +119,10 @@ async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks():
): ):
out = await get_project(project_id=3) out = await get_project(project_id=3)
assert out["applicable_rules"][0]["title"] == "dev is home" assert out["applicable_rules"][0]["title"] == "dev is home"
assert out["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}] assert out["project_rules"] == []
assert out["applicable_rules_truncated"] is False assert out["applicable_rules_truncated"] is False
for gone in ("subscribed_rulebooks", "suppressed_rules", "suppressed_topics"):
assert gone not in out, gone
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -173,7 +175,6 @@ async def test_enter_project_composes_full_context():
"topic_title": "t", "rulebook_title": "rb"}], "topic_title": "t", "rulebook_title": "rb"}],
"project_rules": [{"id": 99, "title": "pr1", "statement": "ps"}], "project_rules": [{"id": 99, "title": "pr1", "statement": "ps"}],
"truncated": False, "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}],
} }
milestone_summary = [{"id": 10, "title": "MS", "status": "active", "total": 3}] milestone_summary = [{"id": 10, "title": "MS", "status": "active", "total": 3}]
@@ -202,10 +203,9 @@ async def test_enter_project_composes_full_context():
assert out["project"] == {"id": 5, "title": "P", "status": "active", "goal": ""} assert out["project"] == {"id": 5, "title": "P", "status": "active", "goal": ""}
assert out["milestone_summary"] == milestone_summary assert out["milestone_summary"] == milestone_summary
# Rules arrive in full by retrieval; the handshake lists the project's own # Rules arrive in full by retrieval; the handshake lists the project's own
# by id and title and drops the subscription bookkeeping (#4045). # by id and title (#4045), and nothing about subscriptions (milestone 414).
assert out["project_rules"] == [{"id": 99, "title": "pr1"}] assert out["project_rules"] == [{"id": 99, "title": "pr1"}]
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}] for gone in ("applicable_rules", "applicable_rules_truncated", "subscribed_rulebooks",
for gone in ("applicable_rules", "applicable_rules_truncated",
"suppressed_rules", "suppressed_topics", "recent_notes"): "suppressed_rules", "suppressed_topics", "recent_notes"):
assert gone not in out, gone assert gone not in out, gone
assert out["open_tasks"] == [{ assert out["open_tasks"] == [{
@@ -243,8 +243,7 @@ async def test_enter_project_surfaces_the_systems_vocabulary():
AsyncMock(return_value=p), AsyncMock(return_value=p),
), patch( ), patch(
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", "scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False, AsyncMock(return_value={"rules": [], "truncated": False}),
"subscribed_rulebooks": []}),
), patch( ), patch(
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", "scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=[]), AsyncMock(return_value=[]),
@@ -266,8 +265,7 @@ def _enter_project_stubs(p):
patch("scribe.mcp.tools.projects.projects_svc.get_project", patch("scribe.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=p)), AsyncMock(return_value=p)),
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False, AsyncMock(return_value={"rules": [], "truncated": False})),
"subscribed_rulebooks": []})),
patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=[])), AsyncMock(return_value=[])),
patch("scribe.mcp.tools.projects.notes_svc.list_notes", patch("scribe.mcp.tools.projects.notes_svc.list_notes",
@@ -357,8 +355,7 @@ async def test_enter_project_hands_back_the_design_system_when_the_project_has_o
AsyncMock(return_value=p), AsyncMock(return_value=p),
), patch( ), patch(
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", "scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False, AsyncMock(return_value={"rules": [], "truncated": False}),
"subscribed_rulebooks": []}),
), patch( ), patch(
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", "scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=[]), AsyncMock(return_value=[]),
@@ -417,11 +414,10 @@ async def test_create_project_with_inception_args_decides_via_mcp():
decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}} decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}}
with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \ with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide: patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
out = await create_project(title="P", subscribe_rulebooks=[1], design_system_id=-1, seed_systems=True) out = await create_project(title="P", design_system_id=-1, seed_systems=True)
kw = decide.await_args.kwargs kw = decide.await_args.kwargs
assert decide.await_args.args[1] == 5 and kw["via"] == "mcp" assert decide.await_args.args[1] == 5 and kw["via"] == "mcp"
assert kw["choices"] == {"subscribe_rulebooks": [1], assert kw["choices"] == {"design_system_id": None, "seed_systems": True}
"design_system_id": None, "seed_systems": True}
assert out["inception"]["via"] == "mcp" and "inception_effects" in out assert out["inception"]["via"] == "mcp" and "inception_effects" in out
@@ -437,8 +433,7 @@ async def test_decide_project_inception_tool_records_an_inherit_all_decision_whe
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project(): async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project():
applicable = {"rules": [], "project_rules": [], "truncated": False, applicable = {"rules": [], "project_rules": [], "truncated": False}
"subscribed_rulebooks": []}
ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"} ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"}
async def run(project): async def run(project):
@@ -471,6 +466,8 @@ def test_inception_routes_and_tool_are_registered():
mcp = build_mcp_server() mcp = build_mcp_server()
assert mcp._tool_manager.get_tool("decide_project_inception") is not None assert mcp._tool_manager.get_tool("decide_project_inception") is not None
tool = mcp._tool_manager.get_tool("create_project") tool = mcp._tool_manager.get_tool("create_project")
for name in ("subscribe_rulebooks", "design_system_id", "seed_systems"): for name in ("design_system_id", "seed_systems"):
assert name in tool.parameters.get("properties", {}), name assert name in tool.parameters.get("properties", {}), name
# Rules left inception with subscriptions (milestone 414).
assert "subscribe_rulebooks" not in tool.parameters.get("properties", {})
+8 -74
View File
@@ -186,30 +186,6 @@ async def test_delete_rule_with_confirmed_soft_deletes():
assert mock_delete.called assert mock_delete.called
@pytest.mark.asyncio
async def test_subscribe_project_to_rulebook_calls_service():
mock = AsyncMock()
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.subscribe_project", mock,
):
from scribe.mcp.tools.rulebooks import subscribe_project_to_rulebook
out = await subscribe_project_to_rulebook(project_id=3, rulebook_id=1)
assert out["subscribed"] is True
assert mock.called
@pytest.mark.asyncio
async def test_unsubscribe_project_from_rulebook_calls_service():
mock = AsyncMock()
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.unsubscribe_project", mock,
):
from scribe.mcp.tools.rulebooks import unsubscribe_project_from_rulebook
out = await unsubscribe_project_from_rulebook(project_id=3, rulebook_id=1)
assert out["subscribed"] is False
assert mock.called
def test_register_attaches_every_tool(): def test_register_attaches_every_tool():
"""Every tool in the module reaches the server. """Every tool in the module reaches the server.
@@ -227,7 +203,9 @@ def test_register_attaches_every_tool():
# (milestone 399). # (milestone 399).
# 28 since milestone 394 took list_always_on_rules and the two # 28 since milestone 394 took list_always_on_rules and the two
# always-on exclusion tools with the tier they served. # always-on exclusion tools with the tier they served.
assert len(mcp.names) == 28 # 22 since milestone 414 retired subscriptions and suppressions: the two
# subscribe tools and the four suppress/unsuppress tools.
assert len(mcp.names) == 22
# spot-check a few names # spot-check a few names
assert "list_rulebooks" in mcp.names assert "list_rulebooks" in mcp.names
assert "create_rule" in mcp.names assert "create_rule" in mcp.names
@@ -237,17 +215,17 @@ def test_register_attaches_every_tool():
# get_preference to look for here. # get_preference to look for here.
assert "create_preference" in mcp.names assert "create_preference" in mcp.names
assert "update_preference" in mcp.names assert "update_preference" in mcp.names
assert "subscribe_project_to_rulebook" in mcp.names
assert "create_project_rule" in mcp.names assert "create_project_rule" in mcp.names
assert "suppress_rule_for_project" in mcp.names
# milestone 312: the sweep, and the stamp that answers it # milestone 312: the sweep, and the stamp that answers it
assert "rules_due_for_verification" in mcp.names assert "rules_due_for_verification" in mcp.names
assert "mark_rule_verified" in mcp.names assert "mark_rule_verified" in mcp.names
# milestone 323: what a rule used to say # milestone 323: what a rule used to say
assert "rule_history" in mcp.names assert "rule_history" in mcp.names
assert "unsuppress_rule_for_project" in mcp.names # milestone 414: a rule's home is its scope; nothing subscribes or mutes.
assert "suppress_topic_for_project" in mcp.names for gone in ("subscribe_project_to_rulebook", "unsubscribe_project_from_rulebook",
assert "unsuppress_topic_for_project" in mcp.names "suppress_rule_for_project", "unsuppress_rule_for_project",
"suppress_topic_for_project", "unsuppress_topic_for_project"):
assert gone not in mcp.names
@@ -306,50 +284,6 @@ async def test_create_project_rule_uses_explicit_title_when_given():
assert kwargs["title"] == "no auto-docstrings" assert kwargs["title"] == "no auto-docstrings"
@pytest.mark.asyncio
async def test_suppress_rule_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.suppress_rule_for_project", mock):
from scribe.mcp.tools.rulebooks import suppress_rule_for_project
out = await suppress_rule_for_project(project_id=3, rule_id=17)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
assert out == {"project_id": 3, "rule_id": 17, "suppressed": True}
@pytest.mark.asyncio
async def test_unsuppress_rule_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.unsuppress_rule_for_project", mock):
from scribe.mcp.tools.rulebooks import unsuppress_rule_for_project
out = await unsuppress_rule_for_project(project_id=3, rule_id=17)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
assert out == {"project_id": 3, "rule_id": 17, "suppressed": False}
@pytest.mark.asyncio
async def test_suppress_topic_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.suppress_topic_for_project", mock):
from scribe.mcp.tools.rulebooks import suppress_topic_for_project
out = await suppress_topic_for_project(project_id=3, topic_id=22)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
assert out == {"project_id": 3, "topic_id": 22, "suppressed": True}
@pytest.mark.asyncio
async def test_unsuppress_topic_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.unsuppress_topic_for_project", mock):
from scribe.mcp.tools.rulebooks import unsuppress_topic_for_project
out = await unsuppress_topic_for_project(project_id=3, topic_id=22)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
assert out == {"project_id": 3, "topic_id": 22, "suppressed": False}
# ── Typed edges between rules (milestone 307) ─────────────────────────── # ── Typed edges between rules (milestone 307) ───────────────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
+3 -6
View File
@@ -80,8 +80,7 @@ def _task(tid: int, milestone_id: int | None) -> MagicMock:
def _enter_stubs(project, milestones: list[dict], tasks: list, *, rules=None, systems=None, def _enter_stubs(project, milestones: list[dict], tasks: list, *, rules=None, systems=None,
design=None): design=None):
applicable = rules or {"rules": [], "project_rules": [], "truncated": False, applicable = rules or {"rules": [], "project_rules": [], "truncated": False}
"subscribed_rulebooks": []}
return [ return [
patch("scribe.mcp.tools.projects.projects_svc.get_project", patch("scribe.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=project)), AsyncMock(return_value=project)),
@@ -121,8 +120,7 @@ async def test_enter_project_stays_small_however_large_the_project():
"rules": [{"id": i, "title": f"r{i}", "statement": PLAN} for i in range(50)], "rules": [{"id": i, "title": f"r{i}", "statement": PLAN} for i in range(50)],
"project_rules": [{"id": 100 + i, "title": f"pr{i}", "statement": PLAN, "project_rules": [{"id": 100 + i, "title": f"pr{i}", "statement": PLAN,
"when_to_apply": PLAN} for i in range(60)], "when_to_apply": PLAN} for i in range(60)],
"truncated": True, "subscribed_rulebooks": [{"id": 1, "title": "Family"}], "truncated": True,
"suppressed_rules": [], "suppressed_topics": [],
} }
systems = [] systems = []
for i in range(40): for i in range(40):
@@ -181,8 +179,7 @@ async def test_get_project_lists_every_milestone_without_plans():
patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=_history(10))), \ AsyncMock(return_value=_history(10))), \
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False, AsyncMock(return_value={"rules": [], "truncated": False})):
"subscribed_rulebooks": []})):
out = await get_project(project_id=5) out = await get_project(project_id=5)
assert len(out["milestone_summary"]) == 10 assert len(out["milestone_summary"]) == 10
assert all("body" not in m for m in out["milestone_summary"]) assert all("body" not in m for m in out["milestone_summary"])
+21 -29
View File
@@ -48,9 +48,7 @@ def test_service_signatures_require_user_id():
"set_rule_systems", "add_rule_relation", "remove_rule_relation", "set_rule_systems", "add_rule_relation", "remove_rule_relation",
"list_rules", "list_rules",
"get_rule", "update_rule", "delete_rule", "get_rule", "update_rule", "delete_rule",
"subscribe_project", "unsubscribe_project", "get_applicable_rules", "get_applicable_rules",
"suppress_rule_for_project", "unsuppress_rule_for_project",
"suppress_topic_for_project", "unsuppress_topic_for_project",
): ):
sig = inspect.signature(getattr(svc, fn_name)) sig = inspect.signature(getattr(svc, fn_name))
assert "user_id" in sig.parameters, f"{fn_name} missing user_id param" assert "user_id" in sig.parameters, f"{fn_name} missing user_id param"
@@ -70,38 +68,32 @@ def test_create_project_rule_route_exists():
assert callable(getattr(rb_routes, "create_project_rule")) assert callable(getattr(rb_routes, "create_project_rule"))
def test_suppression_route_handlers_exist(): def test_subscriptions_and_suppressions_are_gone():
"""The 4 suppression endpoint handlers are registered as Python callables.""" """Milestone 414: a rule's home is its scope. Nothing subscribes a project
from scribe.routes import rulebooks as rb_routes to a rulebook or mutes a rule for one — not a route, a service function or
for name in ( a table. A partial removal would leave a door that 500s on a missing table.
"suppress_project_rule", "unsuppress_project_rule", """
"suppress_project_topic", "unsuppress_project_topic",
):
assert callable(getattr(rb_routes, name)), f"missing route handler: {name}"
def test_suppression_association_tables_declared():
"""Migration 0060 created two new association tables; the models module
must declare matching Table() objects so the rest of the service layer
can reference them via .c.<column>."""
from scribe.models import rulebook as rb_models from scribe.models import rulebook as rb_models
for tbl_name in ("project_rule_suppressions", "project_topic_suppressions"): from scribe.routes import rulebooks as rb_routes
tbl = getattr(rb_models, tbl_name, None) from scribe.services import rulebooks as svc
assert tbl is not None, f"models.rulebook missing {tbl_name}" for name in ("subscribe_project", "unsubscribe_project",
cols = {c.name for c in tbl.columns} "suppress_project_rule", "unsuppress_project_rule",
assert "project_id" in cols "suppress_project_topic", "unsuppress_project_topic"):
assert "rule_id" in cols or "topic_id" in cols assert not hasattr(rb_routes, name), f"route handler still present: {name}"
for name in ("subscribe_project", "unsubscribe_project",
"suppress_rule_for_project", "unsuppress_rule_for_project",
"suppress_topic_for_project", "unsuppress_topic_for_project"):
assert not hasattr(svc, name), f"service function still present: {name}"
for name in ("project_rulebook_subscriptions", "project_rule_suppressions",
"project_topic_suppressions"):
assert not hasattr(rb_models, name), f"table still declared: {name}"
def test_rule_handlers_callable():
def test_rule_and_subscription_handlers_callable():
from scribe.routes import rulebooks as rb_routes from scribe.routes import rulebooks as rb_routes
for name in ( for name in (
"list_rules", "create_rule", "get_rule", "update_rule", "delete_rule", "list_rules", "create_rule", "get_rule", "update_rule", "delete_rule",
"subscribe_project", "unsubscribe_project", "get_project_rules", "get_project_rules",
# The typed edges — both doors carry them (rule 33). # The typed edges — both doors carry them (rule 33).
"relate_rules", "unrelate_rules", "relate_rules", "unrelate_rules",
): ):
+4 -9
View File
@@ -330,7 +330,6 @@ def test_rules_payload_records_both_the_family_and_project_halves():
"rules": [{"id": 10}, {"id": 11}], "rules": [{"id": 10}, {"id": 11}],
"project_rules": [{"id": 12}], "project_rules": [{"id": 12}],
"truncated": False, "truncated": False,
"subscribed_rulebooks": [],
}, },
user_id=1, user_id=1,
source="enter_project", source="enter_project",
@@ -342,9 +341,9 @@ def test_rules_payload_records_both_the_family_and_project_halves():
def test_brief_rules_payload_lists_titles_and_records_only_what_it_shows(): def test_brief_rules_payload_lists_titles_and_records_only_what_it_shows():
"""The handshake's form (#4045): project rules by id and title, the """The handshake's form (#4045): project rules by id and title, nothing
subscribed rulebooks, nothing else. A subscription-derived rule it doesn't else (subscriptions went in milestone 414). A global rule it doesn't show
show must not count as surfaced.""" must not count as surfaced."""
from scribe.services import rulebooks as svc from scribe.services import rulebooks as svc
rec = MagicMock() rec = MagicMock()
@@ -354,17 +353,13 @@ def test_brief_rules_payload_lists_titles_and_records_only_what_it_shows():
"rules": [{"id": 10, "title": "family", "statement": "s"}], "rules": [{"id": 10, "title": "family", "statement": "s"}],
"project_rules": [{"id": 12, "title": "own", "statement": "s"}], "project_rules": [{"id": 12, "title": "own", "statement": "s"}],
"truncated": False, "truncated": False,
"subscribed_rulebooks": [{"id": 1, "title": "Family"}],
}, },
user_id=1, user_id=1,
source="enter_project", source="enter_project",
brief=True, brief=True,
) )
assert out == { assert out == {"project_rules": [{"id": 12, "title": "own"}]}
"project_rules": [{"id": 12, "title": "own"}],
"subscribed_rulebooks": [{"id": 1, "title": "Family"}],
}
assert rec.call_args.kwargs["rule_ids"] == [12] assert rec.call_args.kwargs["rule_ids"] == [12]
+1 -16
View File
@@ -249,11 +249,7 @@ def test_the_column_guard_covers_every_table_with_a_row_helper():
# REAL table names, as _BACKED_UP holds them — not the shorter keys the # REAL table names, as _BACKED_UP holds them — not the shorter keys the
# payload uses for the same sections. Getting this wrong is what the guard # payload uses for the same sections. Getting this wrong is what the guard
# caught on its own first run. # caught on its own first run.
join_tables = { join_tables = {"rule_systems"}
"project_rulebook_subscriptions", "project_rule_suppressions",
"project_topic_suppressions",
"rule_systems",
}
covered = set(_column_guard_targets()) | join_tables covered = set(_column_guard_targets()) | join_tables
assert set(backup._BACKED_UP) - covered == set() assert set(backup._BACKED_UP) - covered == set()
# And no stale entries: every declaration must name a real target. # And no stale entries: every declaration must name a real target.
@@ -306,15 +302,6 @@ def test_every_table_is_either_backed_up_or_explicitly_excluded():
) )
def test_join_table_row_helpers_are_pure():
subs = [SimpleNamespace(project_id=1, rulebook_id=2)]
rsup = [SimpleNamespace(project_id=1, rule_id=9)]
tsup = [SimpleNamespace(project_id=1, topic_id=7)]
assert backup._subscription_rows(subs) == [{"project_id": 1, "rulebook_id": 2}]
assert backup._rule_suppression_rows(rsup) == [{"project_id": 1, "rule_id": 9}]
assert backup._topic_suppression_rows(tsup) == [{"project_id": 1, "topic_id": 7}]
class _Result: class _Result:
def scalars(self): def scalars(self):
return self return self
@@ -350,8 +337,6 @@ async def test_export_full_backup_contains_every_declared_section():
# The sections v2 silently dropped, the six v5 added, v6's # The sections v2 silently dropped, the six v5 added, v6's
# note_supersessions, and v7's code_shapes (all empty here). # note_supersessions, and v7's code_shapes (all empty here).
for key in ("rulebooks", "rulebook_topics", "rules", for key in ("rulebooks", "rulebook_topics", "rules",
"rulebook_subscriptions", "rule_suppressions",
"topic_suppressions",
"systems", "record_systems", "design_systems", "systems", "record_systems", "design_systems",
"design_tokens", "note_usage_events", "repo_bindings", "design_tokens", "note_usage_events", "repo_bindings",
"note_supersessions", "code_shapes", "code_shape_events", "note_supersessions", "code_shapes", "code_shape_events",
+3 -2
View File
@@ -13,7 +13,7 @@ async def test_start_planning_creates_milestone_and_returns_rules():
"rules": [{"id": 1, "title": "dev is home", "statement": "...", "rules": [{"id": 1, "title": "dev is home", "statement": "...",
"topic_title": "git-workflow", "rulebook_title": "FabledSword family"}], "topic_title": "git-workflow", "rulebook_title": "FabledSword family"}],
"truncated": False, "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "FabledSword family"}], "project_rules": [{"id": 3, "title": "own", "statement": "..."}],
} }
with patch("scribe.services.planning.milestones_svc.create_milestone", with patch("scribe.services.planning.milestones_svc.create_milestone",
AsyncMock(return_value=fake_milestone)) as mock_create, \ AsyncMock(return_value=fake_milestone)) as mock_create, \
@@ -34,7 +34,8 @@ async def test_start_planning_creates_milestone_and_returns_rules():
# Returned shape # Returned shape
assert out["milestone"]["id"] == 5 assert out["milestone"]["id"] == 5
assert out["applicable_rules"][0]["title"] == "dev is home" assert out["applicable_rules"][0]["title"] == "dev is home"
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "FabledSword family"}] assert out["project_rules"][0]["id"] == 3
assert "subscribed_rulebooks" not in out
assert out["open_task_count"] == 3 assert out["open_task_count"] == 3
+48 -64
View File
@@ -172,23 +172,7 @@ async def test_get_rule_returns_none_when_not_owner():
assert result is None assert result is None
# ── Subscriptions + applicable_rules ──────────────────────────────────── # ── applicable_rules ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_subscribe_project_requires_owned_rulebook():
"""subscribe_project raises if user doesn't own the rulebook."""
mock_session = make_mock_session()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("scribe.services.rulebooks.async_session") as mock_cls:
mock_cls.return_value = mock_session
from scribe.services.rulebooks import subscribe_project
with pytest.raises(ValueError, match="not found"):
await subscribe_project(
project_id=1, rulebook_id=999, user_id=7,
)
def _empty(): def _empty():
"""A MagicMock result whose .all() returns [] (or .scalars().all() returns []).""" """A MagicMock result whose .all() returns [] (or .scalars().all() returns [])."""
@@ -217,13 +201,18 @@ def _no_edges():
) )
def _areas(*canonical_ids):
"""The project-areas query's result: `.scalars().all()` of canonical ids."""
r = MagicMock()
r.scalars.return_value.all.return_value = list(canonical_ids)
return r
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_applicable_rules_returns_shape(): async def test_get_applicable_rules_returns_shape():
"""get_applicable_rules returns the full projection — including the """The listing is a project's own rules plus the global rules tagged to its
new suppression fields and rulebook/topic IDs on each rule.""" areas (milestone 414) — no subscriptions or suppressions in the shape."""
mock_session = make_mock_session() mock_session = make_mock_session()
sub_result = MagicMock()
sub_result.all.return_value = [(1, "FabledSword family")]
rules_result = MagicMock() rules_result = MagicMock()
rules_result.all.return_value = [ rules_result.all.return_value = [
# The query selects the ENTITY plus three labels, so rule_brief stays # The query selects the ENTITY plus three labels, so rule_brief stays
@@ -232,10 +221,9 @@ async def test_get_applicable_rules_returns_shape():
"git-workflow", 1, "FabledSword family") "git-workflow", 1, "FabledSword family")
for i in range(50) for i in range(50)
] ]
# Execute order: sub_q, suppressed_rules_q, suppressed_topics_q, # Execute order: project-areas_q, rules_q (only with areas), proj_rules_q
# project-areas_q (milestone 307), rules_q, proj_rules_q
mock_session.execute = AsyncMock(side_effect=[ mock_session.execute = AsyncMock(side_effect=[
sub_result, _empty(), _empty(), _empty(), rules_result, _empty(), _areas(4), rules_result, _empty(),
]) ])
_p1, _p2, _p3 = _no_edges() _p1, _p2, _p3 = _no_edges()
@@ -244,19 +232,11 @@ async def test_get_applicable_rules_returns_shape():
from scribe.services.rulebooks import get_applicable_rules from scribe.services.rulebooks import get_applicable_rules
result = await get_applicable_rules(project_id=3, user_id=7, limit=50) result = await get_applicable_rules(project_id=3, user_id=7, limit=50)
assert "rules" in result assert set(result) == {"rules", "project_rules", "truncated"}
assert "project_rules" in result
assert "suppressed_rules" in result
assert "suppressed_topics" in result
assert "truncated" in result
assert "subscribed_rulebooks" in result
assert result["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}]
assert len(result["rules"]) == 50 assert len(result["rules"]) == 50
assert result["rules"][0]["topic_id"] == 2 assert result["rules"][0]["topic_id"] == 2
assert result["rules"][0]["rulebook_id"] == 1 assert result["rules"][0]["rulebook_id"] == 1
assert result["project_rules"] == [] assert result["project_rules"] == []
assert result["suppressed_rules"] == []
assert result["suppressed_topics"] == []
assert result["truncated"] is False assert result["truncated"] is False
@@ -264,14 +244,12 @@ async def test_get_applicable_rules_returns_shape():
async def test_get_applicable_rules_truncates_when_over_limit(): async def test_get_applicable_rules_truncates_when_over_limit():
"""When limit+1 rows are returned, truncated=True and only `limit` returned.""" """When limit+1 rows are returned, truncated=True and only `limit` returned."""
mock_session = make_mock_session() mock_session = make_mock_session()
sub_result = MagicMock()
sub_result.all.return_value = []
rules_result = MagicMock() rules_result = MagicMock()
rules_result.all.return_value = [ rules_result.all.return_value = [
(fake_rule(id=i, title=f"r{i}"), "topic", 1, "rb") for i in range(51) (fake_rule(id=i, title=f"r{i}"), "topic", 1, "rb") for i in range(51)
] ]
mock_session.execute = AsyncMock(side_effect=[ mock_session.execute = AsyncMock(side_effect=[
sub_result, _empty(), _empty(), _empty(), rules_result, _empty(), _areas(4), rules_result, _empty(),
]) ])
_p1, _p2, _p3 = _no_edges() _p1, _p2, _p3 = _no_edges()
@@ -284,6 +262,24 @@ async def test_get_applicable_rules_truncates_when_over_limit():
assert len(result["rules"]) == 50 assert len(result["rules"]) == 50
@pytest.mark.asyncio
async def test_a_project_with_no_areas_lists_no_global_rules():
"""Untagged global rules apply everywhere and arrive by retrieval; the
listing only names the ones bound by area, so no areas means none — and
the global-rules query is not run at all."""
mock_session = make_mock_session()
mock_session.execute = AsyncMock(side_effect=[_areas(), _empty()])
_p1, _p2, _p3 = _no_edges()
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3:
mock_cls.return_value = mock_session
from scribe.services.rulebooks import get_applicable_rules
result = await get_applicable_rules(project_id=3, user_id=7)
assert result["rules"] == []
assert mock_session.execute.await_count == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_applicable_rules_includes_project_scoped_rules(): async def test_get_applicable_rules_includes_project_scoped_rules():
"""Project-scoped rules surface in the project_rules field.""" """Project-scoped rules surface in the project_rules field."""
@@ -295,9 +291,7 @@ async def test_get_applicable_rules_includes_project_scoped_rules():
(fake_rule(id=101, topic_id=None, project_id=3, title="PR-bound", (fake_rule(id=101, topic_id=None, project_id=3, title="PR-bound",
statement="Land schema changes in their own PR."),), statement="Land schema changes in their own PR."),),
] ]
mock_session.execute = AsyncMock(side_effect=[ mock_session.execute = AsyncMock(side_effect=[_areas(), proj_rules_result])
_empty(), _empty(), _empty(), _empty(), _empty(), proj_rules_result,
])
_p1, _p2, _p3 = _no_edges() _p1, _p2, _p3 = _no_edges()
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3: with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3:
@@ -311,38 +305,28 @@ async def test_get_applicable_rules_includes_project_scoped_rules():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_applicable_rules_surfaces_suppressed_with_context(): async def test_a_co_surfaces_partner_on_another_project_is_not_dragged_in():
"""Suppressed rules and topics come back with full title + rulebook context """An edge is not a way into a project: a partner that is global or on this
so the UI can render them without an extra round-trip.""" project arrives; one on a different project does not."""
mock_session = make_mock_session() mock_session = make_mock_session()
suppressed_rules_result = MagicMock() proj_rules_result = MagicMock()
suppressed_rules_result.all.return_value = [ proj_rules_result.all.return_value = [(fake_rule(id=100, topic_id=None, project_id=3),)]
# (rule_id, title, topic_id, topic_title, rulebook_id, rulebook_title) mock_session.execute = AsyncMock(side_effect=[_areas(), proj_rules_result])
(17, "Old rule", 5, "old-topic", 1, "FabledSword family"), partners = [
fake_rule(id=200, topic_id=9, project_id=None, title="global partner"),
fake_rule(id=201, topic_id=None, project_id=3, title="same-project partner"),
fake_rule(id=202, topic_id=None, project_id=8, title="other-project partner"),
] ]
suppressed_topics_result = MagicMock()
suppressed_topics_result.all.return_value = [
# (topic_id, topic_title, rulebook_id, rulebook_title)
(22, "design-system", 1, "FabledSword family"),
]
mock_session.execute = AsyncMock(side_effect=[
# sub_q, suppressed_rules_q, suppressed_topics_q, project-areas_q,
# rules_q, proj_rules_q
_empty(), suppressed_rules_result, suppressed_topics_result,
_empty(), _empty(), _empty(),
])
_p1, _p2, _p3 = _no_edges() with patch("scribe.services.rulebooks.async_session") as mock_cls, \
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3: patch("scribe.services.rulebooks.co_surfaced_partners", AsyncMock(return_value=partners)), \
patch("scribe.services.rulebooks.list_rule_relations", AsyncMock(return_value={})), \
patch("scribe.services.rulebooks.list_rule_systems", AsyncMock(return_value={})):
mock_cls.return_value = mock_session mock_cls.return_value = mock_session
from scribe.services.rulebooks import get_applicable_rules from scribe.services.rulebooks import get_applicable_rules
result = await get_applicable_rules(project_id=3, user_id=7) result = await get_applicable_rules(project_id=3, user_id=7)
assert len(result["suppressed_rules"]) == 1 assert [r["id"] for r in result["rules"]] == [200, 201]
assert result["suppressed_rules"][0]["id"] == 17
assert result["suppressed_rules"][0]["rulebook_title"] == "FabledSword family"
assert len(result["suppressed_topics"]) == 1
assert result["suppressed_topics"][0]["title"] == "design-system"
# ── rule_brief (milestone 307) ────────────────────────────────────────── # ── rule_brief (milestone 307) ──────────────────────────────────────────
+4 -5
View File
@@ -44,16 +44,15 @@ async def test_delete_returns_none_when_not_found():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_project_cascades_to_notes_milestones_project_rules_and_suppressions(): async def test_delete_project_cascades_to_notes_milestones_and_project_rules():
session = make_mock_session() session = make_mock_session()
# exists-check + 6 cascade ops: # exists-check + 4 cascade ops:
# notes (soft) → milestones (soft) → project-scoped rules (soft) → # notes (soft) → milestones (soft) → project-scoped rules (soft) →
# project_rule_suppressions (hard DELETE) → project_topic_suppressions (hard DELETE) →
# project (soft) # project (soft)
# The two suppression hard-DELETEs went with their tables (milestone 414).
session.execute = AsyncMock(side_effect=[ session.execute = AsyncMock(side_effect=[
_exists_result(True), _exists_result(True),
MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock(),
MagicMock(), MagicMock(),
MagicMock(), MagicMock(),
]) ])
with patch("scribe.services.trash.async_session") as cls: with patch("scribe.services.trash.async_session") as cls:
@@ -61,7 +60,7 @@ async def test_delete_project_cascades_to_notes_milestones_project_rules_and_sup
from scribe.services.trash import delete from scribe.services.trash import delete
batch = await delete(user_id=1, entity_type="project", entity_id=3) batch = await delete(user_id=1, entity_type="project", entity_id=3)
assert isinstance(batch, str) assert isinstance(batch, str)
assert session.execute.await_count == 7 assert session.execute.await_count == 5
@pytest.mark.asyncio @pytest.mark.asyncio