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 |
| Search / Recall | `search`, `get_recent`, `list_tags`, `retrieval_telemetry` | Semantic + structured recall, and the readout its thresholds are tuned from |
| Systems | `create_system`, `list_systems`, `list_system_records` | Reusable per-project subsystems/areas |
| Rulebooks | `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules |
| Rulebooks | `list_rules`, `create_rule`, `create_project_rule`, `relate_rules`, … | Engineering/workflow rules |
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
| Admin | `get_app_logs` (write/admin key) | Diagnostics |
+2 -5
View File
@@ -77,7 +77,7 @@ endpoint at `/mcp`, not these REST routes.
|--------|------|-------------|
| GET / POST | `/api/projects` | List (owned + shared) / create |
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete |
| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {subscribe_rulebooks, design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) |
| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) |
| GET | `/api/projects/:id/inception/defaults` | What binds if nobody decides — the inception card's payload |
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
@@ -115,11 +115,8 @@ endpoint at `/mcp`, not these REST routes.
| GET | `/api/rules` | List rules |
| POST | `/api/rulebook-topics/:tid/rules` | Add a rule to a topic |
| GET / PATCH / DELETE | `/api/rules/:id` | Read / update / delete a rule |
| POST | `/api/projects/:id/rulebook-subscriptions` | Subscribe a project to a rulebook |
| GET | `/api/projects/:id/rules` | Applicable rules for a project |
| GET | `/api/projects/:id/rules` | A project's own rules, and the global rules tagged to its areas |
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
| POST / DELETE | `/api/projects/:id/suppressions/rules/:rid` | Suppress / unsuppress a rule |
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
## Sharing
+5 -2
View File
@@ -64,8 +64,11 @@ across sessions.
session when what the agent is about to do matches its trigger: a command,
a file being written, or the operator's own message. `when_to_apply` is
therefore the field that decides whether a rule is ever seen.
- **Per-project scope** — A project subscribes to rulebooks, and can add
project-scoped rules or suppress individual inherited rules/topics.
- **Global or project scope** — A rule in a rulebook is global: it applies in
every project. A project rule applies to that project only. Retrieval honours
the difference, so a session sees global rules plus its own project's, never
another project's. A project that departs from a global rule writes its own
and links it with an `overrides` relation.
## Stored Processes
+2 -5
View File
@@ -2,7 +2,6 @@
import { apiGet, apiPost } from "@/api/client";
export interface InceptionChoices {
subscribe_rulebooks: number[];
design_system_id: number | null;
seed_systems: boolean;
}
@@ -15,8 +14,6 @@ export interface InceptionRecord {
}
export interface InceptionDefaults {
rulebooks: { id: number; title: string }[];
subscribed_rulebooks: { id: number; title: string }[];
design_system_id: number | null;
design_systems: { id: number; title: string }[];
systems: number;
@@ -25,11 +22,11 @@ export interface InceptionDefaults {
export interface InceptionDecision {
project_id: number;
inception: InceptionRecord;
effects: { excluded: number[]; subscribed: number[]; design_system_id: number | null; systems_seeded: string[] };
effects: { design_system_id: number | null; systems_seeded: string[] };
}
export const emptyChoices = (): InceptionChoices => ({
subscribe_rulebooks: [], design_system_id: null, seed_systems: false,
design_system_id: null, seed_systems: false,
});
export const fetchInceptionDefaults = (projectId: number) =>
+4 -46
View File
@@ -108,23 +108,7 @@ export interface ApplicableRules {
rulebook_title: string;
})[];
project_rules: RuleHeader[];
suppressed_rules: {
id: number;
title: string;
topic_id: number;
topic_title: string;
rulebook_id: number;
rulebook_title: string;
}[];
suppressed_topics: {
id: number;
title: string;
rulebook_id: number;
rulebook_title: string;
}[];
truncated: boolean;
subscribed_rulebooks: { id: number; title: string }[];
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
}
// ── Rulebooks ───────────────────────────────────────────────────────
@@ -272,15 +256,7 @@ export async function deleteRule(id: number): Promise<void> {
return apiDelete(`/api/rules/${id}`);
}
// ── Subscriptions ──────────────────────────────────────────────────
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}`);
}
// ── A project's rules ──────────────────────────────────────────────
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
return apiGet(`/api/projects/${projectId}/rules`);
@@ -293,24 +269,6 @@ export async function createProjectRule(
return apiPost(`/api/projects/${projectId}/rules`, data);
}
// ── Suppressions ───────────────────────────────────────────────────
export async function suppressRuleForProject(projectId: number, ruleId: number): Promise<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
@@ -337,9 +295,9 @@ export interface RuleVerificationRow {
* first, never-checked at the top. Rules without a check never appear:
* they are decisions, and there is nothing to go and check.
*
* Not filterable by project — a project reaches rules through project
* scope, subscriptions, always-on rulebooks and exclusions, and a filter
* missing one of those paths would under-report.
* Not filterable by project — a project is bound by its own rules and by
* every global rule, and a filter that dropped the global ones would
* under-report.
*/
export async function listRulesDueForVerification(opts: {
olderThanDays?: number;
+7 -35
View File
@@ -14,7 +14,6 @@ import {
decideInception, emptyChoices, fetchInceptionDefaults,
type InceptionChoices, type InceptionDecision, type InceptionDefaults,
} from "@/api/inception";
import { listRulebooks } from "@/api/rulebooks";
const props = withDefaults(defineProps<{
mode: "create" | "decide";
@@ -28,7 +27,6 @@ const emit = defineEmits<{
}>();
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
const others = ref<{ id: number; title: string }[]>([]);
const designSystems = ref<{ id: number; title: string }[]>([]);
const systemsCount = ref(0);
const loading = ref(true);
@@ -46,18 +44,12 @@ async function load() {
try {
if (props.mode === "decide" && props.projectId) {
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
others.value = d.rulebooks;
designSystems.value = d.design_systems;
systemsCount.value = d.systems;
// Start from what stands today so "record" without changes is a true inherit-all.
local.value = {
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
design_system_id: d.design_system_id,
seed_systems: false,
};
// Start from what stands today, so "record" without changes keeps it.
local.value = { design_system_id: d.design_system_id, seed_systems: false };
} else {
const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]);
others.value = rulebooks.map((r) => ({ id: r.id, title: r.title }));
const ds = await fetchDesignSystems();
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
}
} catch (e: unknown) {
@@ -67,17 +59,7 @@ async function load() {
}
}
function subscribed(id: number): boolean {
return local.value.subscribe_rulebooks.includes(id);
}
function toggleSubscribe(id: number) {
const list = local.value.subscribe_rulebooks;
local.value.subscribe_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
}
const nothingToDecide = computed(
() => !others.value.length && !designSystems.value.length,
);
const nothingToDecide = computed(() => !designSystems.value.length);
async function record() {
if (!props.projectId) return;
@@ -101,22 +83,12 @@ onMounted(load);
<h3 id="inception-title" class="inception-title">What does this project inherit?</h3>
<p class="inception-lede">
A project's inheritance is a decision, not a default. Until it is recorded,
every always-on rulebook binds, nothing is subscribed, and there is no design
system or Systems.
there is no design system and no Systems. Rules aren't part of this: global
rules apply to every project, and a project's own rules are added on it.
</p>
<p v-if="loading" class="inception-muted">Loading…</p>
<p v-else-if="error" class="error-msg">{{ error }}</p>
<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">
<h4>Design system</h4>
<select v-model="local.design_system_id" class="inception-select" aria-label="Design system">
@@ -134,7 +106,7 @@ onMounted(load);
</label>
</div>
<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>
<div v-if="mode === 'decide'" class="inception-actions">
<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 {
getProjectApplicableRules,
subscribeProject,
unsubscribeProject,
listRulebooks,
getRule,
createProjectRule,
deleteRule,
suppressRuleForProject,
unsuppressRuleForProject,
suppressTopicForProject,
unsuppressTopicForProject,
} 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 router = useRouter();
const applicable = ref<ApplicableRules | null>(null);
const allRulebooks = ref<Rulebook[]>([]);
const showPicker = ref(false);
const expandedRuleIds = ref<Set<number>>(new Set());
const ruleDetails = ref<Record<number, {
@@ -38,22 +40,6 @@ async function load() {
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) {
if (expandedRuleIds.value.has(ruleId)) {
expandedRuleIds.value.delete(ruleId);
@@ -143,66 +129,13 @@ async function removeProjectRule(ruleId: number) {
await load();
}
const showSuppressed = ref(false);
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();
});
onMounted(load);
watch(() => props.projectId, load);
</script>
<template>
<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">
<div class="section-head">
<h3>Project rules</h3>
@@ -286,9 +219,13 @@ watch(() => props.projectId, load);
</section>
<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">
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>.
</p>
<div
@@ -298,26 +235,12 @@ watch(() => props.projectId, load);
>
<h4>{{ rb.rulebook_title }}</h4>
<div v-for="topic in rb.topics" :key="topic.topic_id" class="topic-group">
<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>
<h5>{{ topic.topic_title }}</h5>
<ul>
<li v-for="r in topic.rules" :key="r.id" class="rule">
<div class="rule-head">
<div class="rule-head-text" @click="toggleRuleExpand(r.id)">
<span class="rule-title">{{ r.title }}</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 class="rule-head" @click="toggleRuleExpand(r.id)">
<span class="rule-title">{{ r.title }}</span>
<span class="rule-statement">{{ r.statement }}</span>
</div>
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
<div v-if="ruleDetails[r.id].why">
@@ -349,64 +272,25 @@ watch(() => props.projectId, load);
</p>
</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>
</template>
<style scoped>
.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; }
h3 {
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
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 {
background: none;
border: 1px dashed var(--fs-border-color);
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
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-note { margin: 0 0 0.75rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
.rb-group { margin-bottom: 1.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
@@ -441,7 +325,7 @@ ul { list-style: none; padding: 0; margin: 0; }
}
.empty, .truncated { opacity: 0.7; font-style: italic; }
.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; }
.new-rule-form {
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;
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>
@@ -221,7 +221,7 @@ watch(() => props.ruleId, load);
</ul>
<p class="field-note">
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>
</section>
@@ -1,12 +1,11 @@
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { ref } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import { apiGet } from "@/api/client";
import {
subscribeProject, unsubscribeProject, getProjectApplicableRules,
} 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<{
rulebookId: number;
topics: RulebookTopic[];
@@ -18,42 +17,6 @@ const store = useRulebooksStore();
const isCreating = ref(false);
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() {
const title = newTitle.value.trim();
if (!title) return;
@@ -63,8 +26,6 @@ async function submitNew() {
emit("select-topic", topic.id);
}
onMounted(loadProjects);
watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing map */});
</script>
<template>
@@ -92,21 +53,6 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
</div>
</form>
</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>
</template>
@@ -117,24 +63,13 @@ ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li.active { background: var(--fs-accent-soft); }
li:hover { background: var(--fs-surface-hover); }
/* `.new-topic` and `.sub-list` are deliberately bare (#2444). The first wraps a
button-or-form whose children style themselves; the second is a `<ul>`, and
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` is deliberately bare (#2444): it wraps a button-or-form whose
children style themselves, and namespaces the descendant rule below. */
.new-topic input {
width: 100%; margin-bottom: 0.5rem;
background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
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; }
</style>
-1
View File
@@ -24,7 +24,6 @@ export interface StartPlanningResult {
topic_title: string;
rulebook_title: string;
}[];
subscribed_rulebooks: { id: number; title: string }[];
applicable_rules_truncated: boolean;
project_goal: string;
open_task_count: number;
-3
View File
@@ -716,9 +716,6 @@ async function confirmDelete() {
/>
<p v-else-if="project.inception" class="inception-line">
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" }}
<template v-if="project.inception.choices.seed_systems"> · Systems seeded</template>
</p>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"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).",
"version": "2026.09.15.0204",
"version": "2026.09.15.1620",
"author": {
"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
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
`get_rule(id)` when it is about to bite. When a project is in scope,
`enter_project(id)` also returns the rules bound to its areas.
`get_rule(id)` when it is about to bite. When a project is in scope, pass
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
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
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:
- 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
none) → `design_system_id=<id | -1>`
- whether to **seed the standard starter Systems** so records can be tagged
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
(it inherits its defaults silently): raise that ask once, with the defaults it
carries, then `decide_project_inception(project_id, …)`. Existing projects
were stamped "legacy" (inherit-all) and do not ask; any project can be
re-decided. The rules/design-system/Systems tools still work one at a time —
inception is the moment they are decided together, and the record of why.
were stamped "legacy" and do not ask; any project can be re-decided. The
design-system and Systems tools still work one at a time — inception is the
moment they are decided together, and the record of why.
## Where a new rule goes
When codifying a rule, pick its home by **who it should bind** — and keep
shared homes general:
A rule has one of two homes, and the home IS its reach:
- **Rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a reusable,
*themed* module of general rules that binds the projects which opt in (e.g. a
review checklist → every service). Themed, but project-agnostic.
- **Project rule** (`create_project_rule`) — anything specific to one project
(its files, paths, quirks).
- **Global** — in a rulebook (`create_rule` into a topic). It applies in every
project, and reaches a session wherever the work matches it. A rulebook is a
*themed* grouping of general rules (e.g. a review checklist), not a list of
projects it binds — there is no subscribing a project to one.
- **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
project automatically. It is gone: subscription is the only reach a rulebook
has. Names one project's specifics → project rule; anything a category of
projects shares → rulebook. Never put project-specific detail in a rulebook —
it leaks to every other project that subscribes.
Names one project's specifics → project rule; a standard that holds wherever
the kind of work it describes happens → global. Never put project-specific
detail in a rulebook — it would reach every other project. A project that
departs from a global rule writes its own and links it with
`relate_rules(kind="overrides")`, which says why.
**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
+1 -1
View File
@@ -43,7 +43,7 @@ async def get_milestone(milestone_id: int) -> dict:
rules surface again on recall.
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()
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.
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
outright, with none of the subscription paths that would make a
project filter UNDER-report a rule.
outright, where a project is bound by every GLOBAL rule as well as
its own — so a project filter would UNDER-report rules.
never_only: only notes nobody has ever verified.
"""
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.
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
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
names its milestone. list_tasks has the rest.
`project_rules` lists the project's own rules by id and title, and
`subscribed_rulebooks` the rulebooks it draws on. A rule reaches you in
full when your work matches it; get_rule(id) reads one, and
search(content_type="rule") asks whether one covers what you are about
to do.
`project_rules` lists the project's own rules by id and title. Global
rules (the ones in rulebooks) apply here too and are not listed. Any rule
reaches you in full when your work matches it — a global one or one of
this project's, never another project's; get_rule(id) reads one, and
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
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
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;
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
# inherits nothing, silently — no rulebook subscriptions, no design system,
# no Systems. Owner-only (deciding is the owner's), and only until a
# inherits nothing, silently — no design system, no Systems. Owner-only (deciding is the owner's), and only until a
# decision is recorded; the key is ABSENT otherwise (#2483).
inception_ask = None
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,
with description and progress but no plan body; get_milestone reads a
plan), and the rulebook-applicable_rules / subscribed_rulebooks pair the
assistant should consult when working on this project.
plan), the project's own rules (project_rules), and applicable_rules: the
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()
project = await projects_svc.get_project(uid, project_id)
@@ -285,18 +286,14 @@ async def get_project(project_id: int) -> dict:
return data
def _inception_choices(
subscribe_rulebooks, design_system_id, seed_systems,
) -> dict | None:
def _inception_choices(design_system_id, seed_systems) -> dict | None:
"""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
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
system."""
if (subscribe_rulebooks is None
and not design_system_id and seed_systems is None):
if not design_system_id and seed_systems is None:
return None
return {
"subscribe_rulebooks": list(subscribe_rulebooks or []),
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
"seed_systems": bool(seed_systems),
}
@@ -308,18 +305,18 @@ async def create_project(
goal: str = "",
status: str = "active",
color: str = "",
subscribe_rulebooks: list[int] | None = None,
design_system_id: int = 0,
seed_systems: bool | None = None,
) -> dict:
"""Create a new project in Scribe — and decide what it inherits.
A project's inheritance is a decision, not a default (milestone 297):
before calling, ask the operator the four inception questions and pass
the answers; a project created without any of them is UNDECIDED and
before calling, ask the operator the two inception questions and pass
the answers; a project created without either is UNDECIDED and
enter_project will ask until decide_project_inception records it.
Defaults if nobody decides: no rulebook subscriptions, no design system,
no Systems.
Defaults if nobody decides: no design system, no Systems. Rules are not
an inception question: global rules (in rulebooks) apply to every
project, and a project's own rules are written with create_project_rule.
Args:
title: Project name (required).
@@ -327,10 +324,6 @@ async def create_project(
goal: The desired outcome or definition of done for the project.
status: one of active (default), paused, completed, archived.
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
(list_design_systems); -1 = explicitly none; 0 = not stated.
seed_systems: true mints the standard starter Systems (CI & Release,
@@ -346,9 +339,7 @@ async def create_project(
color=color or None,
)
data = project.to_dict()
choices = _inception_choices(
subscribe_rulebooks, design_system_id, seed_systems,
)
choices = _inception_choices(design_system_id, seed_systems)
if choices is not None:
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
data["inception"] = decided["inception"]
@@ -364,7 +355,6 @@ async def create_project(
async def decide_project_inception(
project_id: int,
subscribe_rulebooks: list[int] | None = None,
design_system_id: int = 0,
seed_systems: bool | None = None,
) -> dict:
@@ -372,22 +362,17 @@ async def decide_project_inception(
or re-decide later (milestone 297).
Owner-only. Applies the effects through the ordinary tools' paths —
subscribe_project_to_rulebook,
set_project_design_system, the standard Systems seed — and writes the
set_project_design_system and the standard Systems seed — and writes the
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
additive for subscriptions (use
unsubscribe_project_from_rulebook to undo one), replaces the design
system, and never re-seeds Systems a project already has.
the project has the design and Systems it has. Re-deciding replaces the
design system and never re-seeds Systems a project already has.
Args: as create_project's inception args. Passing nothing records a
decision to take nothing (no subscriptions, no design system, no seed) —
a valid answer, stated.
decision to take nothing (no design system, no seed) — a valid answer,
stated.
"""
uid = current_user_id()
choices = _inception_choices(
subscribe_rulebooks, design_system_id, seed_systems,
) or {}
choices = _inception_choices(design_system_id, seed_systems) or {}
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
return {"project_id": project_id, **decided}
+30 -121
View File
@@ -1,6 +1,6 @@
"""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
service, and the record shape comes from rule_brief / rule_detail there rather
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:
"""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
(subscribe_project_to_rulebook). There was a second until milestone 394 —
an `always_on` flag that bound every project automatically — and it is
gone with the tier it belonged to. Opt-in is now the whole model, so a
rulebook binds what asked for it and nothing else.
A rulebook is SHARED, so its rules must stay general — agnostic
to any single project. Project-specific rules go in create_project_rule.
A rule in a rulebook is global: it applies in every project its owner works
on, and reaches a session by retrieval when the work makes it relevant
(milestone 414). There is no subscribing a project to a rulebook, and no
muting one per project — that machinery is gone. So a rulebook's rules must
stay general, agnostic to any single project. Project-specific rules go in
create_project_rule.
Args:
title: Rulebook name.
@@ -210,10 +208,12 @@ async def list_rules(
Args:
rulebook_id: 0 = no filter; positive = restrict to that rulebook.
topic_id: 0 = no filter; positive = restrict to that topic.
project_id: 0 = no filter; positive = restrict to rules applicable
to that project (via its rulebook subscriptions).
project_id: 0 = no filter; positive = that project's OWN rules.
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()
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
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
it must read as a general standard
never pin it to one project's files, paths, or quirks. For a rule that
applies to a single project only, use create_project_rule instead (no
rulebook+topic ceremony). If it's a standard a CATEGORY of projects shares,
put it in a rulebook for that category and subscribe those projects to it.
A rulebook rule is GLOBAL — it applies in every project — so it must read as
a general standard: never pin it to one project's files, paths, or quirks.
For a rule that applies to a single project only, use create_project_rule
instead (no rulebook+topic ceremony). A standard only some projects share
is still global in reach; write it so it names the kind of work it is
about, and it arrives where that work happens.
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
@@ -334,7 +334,7 @@ async def create_rule(
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
(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.
Args:
@@ -420,11 +420,11 @@ async def create_project_rule(
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
must NOT go into a shared rulebook (where it would leak to every other
project that gets the rulebook). General standards belong in a rulebook
instead (create_rule). It bypasses the Rulebook -> Topic -> Rule ceremony;
the rule is returned in get_project's applicable_rules (under
project_rules) and in list_rules(project_id=...).
must NOT go into a rulebook (where it would be global, and reach every
other project). General standards belong in a rulebook instead
(create_rule). It bypasses the Rulebook -> Topic -> Rule ceremony; the
rule surfaces by retrieval in this project's sessions only, and is listed
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
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}')."}
# ── 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(
from_rule_id: int, to_rule_id: int, kind: str, note: str = "",
) -> dict:
@@ -981,7 +893,7 @@ async def relate_rules(
together. Use it when you are tempted to fold one rule into another
because "either could surface without the other": that instinct is
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.
- kind="overrides" — this rule supersedes that one for its scope. Use it
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_only: only rules nobody has ever verified.
NOT filterable by project, deliberately: a project reaches rules through
project scope and rulebook subscriptions, and a filter that missed one of
those paths would UNDER-report — which is the
exact failure this whole surface exists to prevent. Read the whole list.
NOT filterable by project, deliberately: a project is bound by its own
rules AND every global rule, and a filter that dropped the global ones
would UNDER-report — which is the exact failure this whole surface exists
to prevent. Read the whole list.
"""
uid = current_user_id()
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_preference, update_preference,
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,
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
list_system_records) or, for an untagged project task, the `systems_hint`
question. For legacy
kind=plan tasks, the response also includes applicable_rules +
subscribed_rulebooks from the task's project's rulebook subscriptions (new
plans are milestones — use get_milestone for those).
kind=plan tasks, the response also includes the project's applicable_rules
and project_rules (new plans are milestones — use get_milestone for those).
A task another user shared with you also carries `shared`, `owner` and
`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.
from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401
from scribe.models.rulebook import ( # noqa: E402, F401
Rulebook, RulebookTopic, Rule, RuleRelation, project_rulebook_subscriptions,
rule_systems,
Rulebook, RulebookTopic, Rule, RuleRelation, rule_systems,
)
from scribe.models.repo_binding import RepoBinding # 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
# inherit, when, and through which door — {decided_at, decided_by, via,
# choices: {subscribe_rulebooks,
# design_system_id, seed_systems}}. NULL means nobody has decided yet,
# and enter_project asks; the effects themselves live in the subscription
# / exclusion tables, design_system_id and the project's Systems — this is
# the WHY, kept so later surfaces can say it. See services/inception.py.
# choices: {design_system_id, seed_systems}}. NULL means nobody has
# decided yet, and enter_project asks; the effects themselves live in
# design_system_id and the project's Systems — this is the WHY, kept so
# later surfaces can say it. See services/inception.py.
inception: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
def to_dict(self) -> dict:
+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 = Table(
"project_rulebook_subscriptions",
Base.metadata,
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
# 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)),
)
# `project_rulebook_subscriptions`, `project_rule_suppressions` and
# `project_topic_suppressions` lived here until milestone 414 (migration 0101).
# 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. A
# subscription had stopped changing anything a session received, and a
# suppression muted rules from a subscription. A project that departs from a
# global rule writes a project rule with an `overrides` relation instead.
+1 -2
View File
@@ -99,8 +99,7 @@ async def create_project_route():
@login_required
async def decide_inception_route(project_id: int):
"""Record (or re-record) what a project inherits — milestone 297.
Body: the choices object {subscribe_rulebooks,
design_system_id, seed_systems}; owner-only."""
Body: the choices object {design_system_id, seed_systems}; owner-only."""
uid = get_current_user_id()
data = await request.get_json() or {}
choices = data.get("choices", data)
+1 -79
View File
@@ -305,37 +305,7 @@ async def delete_rule(rule_id: int):
return "", 204
# ── Subscriptions ──────────────────────────────────────────────────────
@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
# ── A project's rule listing ───────────────────────────────────────────
@rulebooks_bp.get("/projects/<int:project_id>/rules")
@login_required
@@ -346,54 +316,6 @@ async def get_project_rules(project_id: int):
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")
@login_required
async def create_project_rule(project_id: int):
+11 -89
View File
@@ -22,9 +22,6 @@ from scribe.models.rulebook import (
Rule,
Rulebook,
RulebookTopic,
project_rule_suppressions,
project_rulebook_subscriptions,
project_topic_suppressions,
)
from scribe.models.setting import Setting
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
# note importer maps note_id through note_id_map, so a rule id parked there
# 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.
BACKUP_VERSION = 14
BACKUP_VERSION = 15
# 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
@@ -80,8 +81,6 @@ BACKUP_VERSION = 14
_BACKED_UP = [
"users", "projects", "milestones", "notes", "task_logs", "note_drafts",
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
"project_rulebook_subscriptions", "project_rule_suppressions",
"project_topic_suppressions",
# v5 (2026-08): the five-year gap this list was written to stop.
"systems", "record_systems", "design_systems", "design_tokens",
"note_usage_events", "repo_bindings", "note_supersessions",
@@ -234,18 +233,6 @@ def _d(val: str | None) -> date | 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
@@ -640,15 +627,6 @@ async def export_full_backup() -> dict:
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
topics = (await session.execute(select(RulebookTopic))).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 {
"version": BACKUP_VERSION,
@@ -671,9 +649,6 @@ async def export_full_backup() -> dict:
"rulebooks": _rulebook_rows(rulebooks),
"rulebook_topics": _topic_rows(topics),
"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),
"rule_systems": _rule_system_rows(rule_system_rows),
"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),
)
)).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 {
"version": BACKUP_VERSION,
@@ -867,9 +824,6 @@ async def export_user_backup(user_id: int) -> dict:
"rulebooks": _rulebook_rows(rulebooks),
"rulebook_topics": _topic_rows(topics),
"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),
"rule_systems": _rule_system_rows(rule_system_rows),
"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,
"task_logs": 0, "note_drafts": 0, "note_versions": 0,
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
"rulebook_subscriptions": 0, "rule_suppressions": 0,
"topic_suppressions": 0,
"systems": 0, "record_systems": 0, "design_systems": 0,
"design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0,
"repo_bindings": 0,
@@ -1300,38 +1252,10 @@ async def _restore_v2(data: dict) -> dict:
rule_id_map[r_data["id"]] = rule.id
stats["rules"] += 1
# 12. Rulebook subscriptions (v3 join table)
for sub in data.get("rulebook_subscriptions", []):
mapped_pid = project_id_map.get(sub.get("project_id", 0))
mapped_rbid = rulebook_id_map.get(sub.get("rulebook_id", 0))
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
# 12-14. Rulebook subscriptions, rule and topic suppressions (v3-v14)
# `rulebook_subscriptions`, `rule_suppressions` and `topic_suppressions`
# are READ BY NOBODY since milestone 414 dropped their tables. An
# archive carrying them still imports, for the reason 14b gives.
# 14b. Always-on rulebook exclusions (v10, milestone 297)
# `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
# importer wrote.
choices.pop("exclude_always_on_rulebooks", None)
choices["subscribe_rulebooks"] = [
rulebook_id_map[i] for i in choices.get("subscribe_rulebooks") or []
if i in rulebook_id_map
]
# Same for subscribe_rulebooks since milestone 414.
choices.pop("subscribe_rulebooks", None)
ds = choices.get("design_system_id")
choices["design_system_id"] = design_system_id_map.get(ds) if ds else None
proj.inception = {**inception, "choices": choices}
+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,
"via": "mcp" | "ui" | "legacy",
"choices": {
"subscribe_rulebooks": [rulebook ids],
"design_system_id": <id> | null,
"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 /
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
let a project decline to inherit an always-on rulebook, and with no always-on
tier there is nothing to decline — a rulebook now reaches a project by
subscription, which is opt-IN, so declining is expressed by not subscribing.
Rules are not a choice any more. ``exclude_always_on_rulebooks`` went with the
always-on tier (milestone 394), and ``subscribe_rulebooks`` went with
subscriptions (milestone 414): a rule in a rulebook is global and applies to
every project, and a project's own rules are written on it directly. Migration
0101 strips both keys from stored records.
The shape and its validator are pure; ``decide`` composes the existing
services — subscriptions, set_project_design_system, the standard Systems
seed — checks every target BEFORE touching anything,
services — set_project_design_system and the standard Systems seed — checks
every target BEFORE touching anything,
applies the effects (each idempotent), and writes the record LAST, so a
half-applied decision is re-runnable rather than recorded as done.
``current_defaults`` is what the enter_project ask shows: what binds today
@@ -34,20 +34,11 @@ from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.project import Project
from scribe.models.rulebook import Rulebook
INCEPTION_VIAS = ("mcp", "ui", "legacy")
CHOICE_KEYS = ("subscribe_rulebooks", "design_system_id", "seed_systems")
def _is_id_list(value) -> bool:
return isinstance(value, list) and all(
isinstance(v, int) and not isinstance(v, bool) and v > 0 for v in value
)
CHOICE_KEYS = ("design_system_id", "seed_systems")
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
applies whole or errors whole (the StrictArgs lesson, #2709).
Accepts the four keys, each optional: two id lists (positive ints, no
duplicates between exclude and subscribe), ``design_system_id`` an int
or None, ``seed_systems`` a bool. Unknown keys are an error — a typo
must not become a silently ignored choice."""
Accepts two keys, each optional: ``design_system_id`` an int or None,
``seed_systems`` a bool. Unknown keys are an error — a typo, or a choice
the product no longer offers, must not become a silently ignored one."""
if not isinstance(choices, dict):
return "choices must be an object"
unknown = sorted(set(choices) - set(CHOICE_KEYS))
if unknown:
return f"unknown inception choice(s): {', '.join(unknown)} (one of: {', '.join(CHOICE_KEYS)})"
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")
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"
@@ -77,11 +64,10 @@ def validate_inception(choices) -> str | None:
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."""
choices = choices or {}
return {
"subscribe_rulebooks": sorted(set(choices.get("subscribe_rulebooks") or [])),
"design_system_id": choices.get("design_system_id"),
"seed_systems": bool(choices.get("seed_systems", False)),
}
@@ -95,37 +81,20 @@ def is_decided(project) -> bool:
async def current_defaults(user_id: int, project_id: int) -> dict:
"""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>}.
Instance-agnostic: an install with no rulebooks / design systems shows
empty lists, and the ask says so rather than inventing a default.
{design_system_id, design_systems: [{id,title}], systems: <count>}.
Instance-agnostic: an install with no design systems shows an empty list,
and the ask says so rather than inventing a default.
"""
from scribe.services import design_systems as design_systems_svc
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
project = await projects_svc.get_project(user_id, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
async with async_session() as session:
rows = (
await session.execute(
select(Rulebook.id, Rulebook.title)
.where(Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None))
.order_by(Rulebook.title)
)
).all()
applicable = await rulebooks_svc.get_applicable_rules(project_id, user_id, limit=1)
designs = await design_systems_svc.list_design_systems(user_id)
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
return {
# 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_systems": [{"id": d.id, "title": d.title} for d in designs],
"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."""
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"]
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)")
@@ -167,20 +120,17 @@ async def decide(
) -> dict:
"""Record a project's inception decision and apply it (milestone 297).
Owner-only. Validates the choices (pure) and every target (owned /
readable) first; then, each idempotent: subscribe the named rulebooks,
point the project at the design system (None = explicitly none), seed the
standard Systems if asked and the project has none; then write
``projects.inception`` LAST. Re-deciding is additive for subscriptions
(nothing is silently dropped — unsubscribe is an explicit call), replaces
the design system, and re-seeds nothing a project already has.
Owner-only. Validates the choices (pure) and every target (readable)
first; then, each idempotent: point the project at the design system
(None = explicitly none), seed the standard Systems if asked and the
project has none; then write ``projects.inception`` LAST. Re-deciding
replaces the design system and re-seeds nothing a project already has.
Returns {"inception": <record>, "effects": {excluded, subscribed,
design_system_id, systems_seeded}}.
Returns {"inception": <record>, "effects": {design_system_id,
systems_seeded}}.
"""
from scribe.services import design_systems as design_systems_svc
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
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)")
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(
user_id, project_id, choices["design_system_id"]
):
@@ -219,7 +167,6 @@ async def decide(
return {
"inception": record,
"effects": {
"subscribed": choices["subscribe_rulebooks"],
"design_system_id": choices["design_system_id"],
"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)
except Exception:
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"
return {
"defaults": defaults,
"ask": (
"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"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
"once: which rulebooks to subscribe (default: none — a rulebook binds "
"a project only when it opts in), which design system (or none), and "
"whether to seed "
"once: which design system (or none), and whether to seed "
"the standard starter Systems — then record the answers. This ask repeats on "
"every enter_project until a decision is recorded."
),
"call": (
f"decide_project_inception(project_id={project_id}, "
"subscribe_rulebooks=[...], "
"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>,
"applicable_rules": [...],
"subscribed_rulebooks": [...],
"applicable_rules_truncated": bool,
"project_rules": [...],
"project_goal": str,
"open_task_count": int,
"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:
"""Delete a rulebook. Cascade-deletes topics, rules, subscriptions."""
"""Delete a rulebook. Cascade-deletes its topics and rules."""
async with async_session() as session:
result = await session.execute(
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")
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
# a caller can be corrected before the database refuses it (rule 36 keeps the
# 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)
async def co_surfaced_partners(
user_id: int, rule_ids: list[int], exclude_ids: set[int] | None = None,
) -> list[Rule]:
async def co_surfaced_partners(user_id: int, rule_ids: list[int]) -> list[Rule]:
"""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
@@ -425,18 +399,14 @@ async def co_surfaced_partners(
what the entire shape is intended to be." Merging was the only fix
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.
- `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.
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
question (get_applicable_rules drops another project's rule), because this
answers "what fails with these", which has no project in it.
"""
if not rule_ids:
return []
known = set(rule_ids) | (exclude_ids or set())
known = set(rule_ids)
async with async_session() as session:
edges = (await session.execute(
select(RuleRelation).where(
@@ -523,9 +493,10 @@ async def create_project_rule(
) -> Rule:
"""Create a rule scoped to a single project (no rulebook ceremony).
Project-scoped rules apply only to the named project; they don't
propagate via rulebook subscriptions. Topic_id is left NULL — the
CHECK constraint enforces exactly-one of (topic_id, project_id).
Project-scoped rules apply only to the named project: retrieval surfaces
them in that project's sessions and nowhere else (milestone 414), where a
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:
await _assert_project_owned(session, project_id, user_id)
@@ -555,18 +526,40 @@ async def list_rules(
topic_id: int | None = None,
project_id: int | None = None,
) -> 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
project_rulebook_subscriptions AND project-scoped rules (Rule.project_id).
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
returned — adding all of a user's project-scoped rules unprompted would
surprise existing callers.
A rule has one home (milestone 414): a rulebook topic, where it is global,
or a project. So the filters name homes rather than reach:
- `project_id` lists that project's OWN rules. Global rules apply to every
project, so listing them under each one would say nothing; list them by
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:
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 = (
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
@@ -582,39 +575,11 @@ async def list_rules(
stmt = stmt.where(Rule.topic_id == topic_id)
if 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(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
result = await session.execute(stmt)
rulebook_rules = 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())
return list(result.scalars().all())
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()
# ── Subscriptions + 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)
# ── get_applicable_rules ────────────────────────────────────────────────
async def get_applicable_rules(
project_id: int, user_id: int, limit: int = 50,
) -> dict:
"""Return rules applicable to a project — both via rulebook subscriptions
and project-scoped rules (Rule.project_id matches), with suppressed rules
and suppressed topics filtered out.
"""The rules a project's LISTING shows: its own, and the global rules
deterministically bound to the areas it works in.
Shape:
{
"rules": [{id, title, statement,
topic_id, topic_title,
rulebook_id, rulebook_title}, ...],
"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}, ...],
"rules": [{id, title, statement, topic_id, topic_title,
rulebook_id, rulebook_title, ...}, ...],
"project_rules": [{id, title, statement, ...}, ...],
"truncated": bool,
"subscribed_rulebooks": [{id, title}, ...]
}
`rules` is the subscription-derived set with project-level suppressions
applied. `project_rules` is the project-scoped set (never suppressed —
delete instead). `suppressed_rules` / `suppressed_topics` carry the
titles + rulebook context callers need to display what was filtered
without round-tripping for names.
NOT WHAT A SESSION RECEIVES. Rules reach a session by retrieval, which
reads a rule's home (milestone 414): global rules everywhere, a project's
own rules in that project. This is the listing a planning read carries so
a reader can see which constraints are on the table, and it is narrower
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 (
project_rulebook_subscriptions,
project_rule_suppressions,
project_topic_suppressions,
)
from scribe.models.project import Project
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(
select(System.canonical_id).where(
System.project_id == project_id,
@@ -1214,36 +939,37 @@ async def get_applicable_rules(
System.status == "active",
).distinct()
)).scalars().all()
reachable = select(rule_systems.c.rule_id).where(
rule_systems.c.canonical_id.in_(project_area_ids)
) if project_area_ids else None
# SUBSCRIPTION IS THE SCOPE; AREAS NARROW ONLY WHERE AN AUTHOR ASKED.
#
# This read `always_on OR reachable` (milestone 307). The tier arm is
# gone, and the first attempt at 394 kept only the reachable arm — so
# a subscribed rulebook's untagged rules stopped arriving at all. That
# was wrong twice over: the query above is ALREADY scoped to rulebooks
# this project subscribed to, so the project opted in and was then
# handed a subset of what it asked for; and the milestone is explicit
# that subscription-derived rules are not what it removes. The
# integration suite caught it through a co_surfaces partner that never
# arrived because the rule it travels with had been filtered out.
#
# So: every rule in a subscribed rulebook applies, EXCEPT that a rule
# tagged to specific areas applies only to a project working in one of
# them. An untagged rule is general to its rulebook by construction —
# nobody narrowed it — while tagging is an author saying "this is
# about CI" and meaning it. That keeps D7's deterministic narrowing
# where it was asked for without inventing it where it was not.
if reachable is not None:
rules_q = rules_q.where(
or_(Rule.id.in_(reachable), Rule.id.notin_(_tagged_rule_ids())),
)
else:
# No canonical areas on this project: nothing can match by area,
# so only the untagged (general) rules apply.
rules_q = rules_q.where(Rule.id.notin_(_tagged_rule_ids()))
rule_rows = (await session.execute(rules_q)).all()
rule_rows = []
if project_area_ids:
# Selects the ENTITY, not a column list: rule_brief is the one
# place that decides which fields a surfaced rule carries. Filtered
# in SQL so `limit` counts the rules that will actually be shown.
rule_rows = (await session.execute(
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)
.where(
Rulebook.owner_user_id == user_id,
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
Rule.id.in_(
select(rule_systems.c.rule_id).where(
rule_systems.c.canonical_id.in_(project_area_ids)
)
),
)
.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
.limit(limit + 1)
)).all()
truncated = len(rule_rows) > limit
rules = [
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.
from scribe.models.project import Project
proj_rules_q = (
proj_rule_rows = (await session.execute(
select(Rule)
.join(Project, Rule.project_id == Project.id)
.where(
@@ -1262,32 +987,26 @@ async def get_applicable_rules(
Project.deleted_at.is_(None),
)
.order_by(Rule.order_index, Rule.title)
)
# 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()
)).all()
project_rules = [rule_brief(rule) for (rule,) in proj_rule_rows]
# Edges travel with the rules they belong to (milestone 307).
#
# 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
# was created to prevent. Suppressions are passed as exclusions so an
# explicit mute still wins over an edge.
# was created to prevent — but only a partner that could reach this
# 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]
partners = await co_surfaced_partners(
user_id, surfaced_ids, exclude_ids=set(suppressed_rule_ids),
)
partners = await co_surfaced_partners(user_id, surfaced_ids)
for partner in partners:
if partner.project_id not in (None, project_id):
continue
rules.append(rule_brief(partner, via="co_surfaces"))
surfaced_ids.append(partner.id)
# Relations on every surfaced rule, so a reader can see that an override
# 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)
areas = await list_rule_systems(surfaced_ids)
for brief in (*rules, *project_rules):
@@ -1296,14 +1015,7 @@ async def get_applicable_rules(
if areas.get(brief["id"]):
brief["systems"] = areas[brief["id"]]
return {
"rules": rules,
"project_rules": project_rules,
"suppressed_rules": suppressed_rules,
"suppressed_topics": suppressed_topics,
"truncated": truncated,
"subscribed_rulebooks": subscribed_rulebooks,
}
return {"rules": rules, "project_rules": project_rules, "truncated": truncated}
def rules_payload(
@@ -1313,11 +1025,11 @@ def rules_payload(
Every surface that hands rules to an agent (enter_project, get_project,
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` →
`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
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
@@ -1330,17 +1042,16 @@ def rules_payload(
`RANKED_SOURCES` in `rule_usage` is what folds them back together.
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
other callers of the rules machinery — the write-path etag arm
(`plugin_context`) — computed a marker and showed nobody
anything, and counting those would put rules in the denominator that no
agent ever saw.
would not be: this function is only ever called to BUILD A REPLY. The
other caller of the rules machinery — the write-path etag arm
(`plugin_context`) — computes a marker and shows nobody anything, and
counting it would put rules in the denominator that no agent ever saw.
`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
session in full by retrieval, which ignores subscriptions, so the handshake
lists which constraints exist rather than restating them; get_rule reads
one. Only what is shown is recorded as surfaced.
as id and title, nothing else. Rules reach a session in full by
retrieval, so the handshake lists which of the project's constraints exist
rather than restating them; get_rule reads one. Only what is shown is
recorded as surfaced.
"""
if brief:
project_rules = [
@@ -1350,10 +1061,7 @@ def rules_payload(
record_rule_surfaced(
user_id=user_id, rule_ids=[r["id"] for r in project_rules], source=source,
)
return {
"project_rules": project_rules,
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
}
return {"project_rules": project_rules}
record_rule_surfaced(
user_id=user_id,
rule_ids=(
@@ -1365,10 +1073,7 @@ def rules_payload(
return {
"applicable_rules": applicable["rules"],
"applicable_rules_truncated": applicable["truncated"],
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"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)
# Project-scoped rules cascade with the project they're attached to.
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)
elif etype == "milestone":
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
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
milestone 394. With no always-on tier there is nothing to opt out OF — a
rulebook binds a project only by subscription — so the table and the test
that pinned its shape both went with it.
Rules left inception in two steps: the always-on opt-out with the tier
(milestone 394), and the rulebook subscription with subscriptions (milestone
414). A rule in a rulebook is global, so there is nothing to choose.
"""
from scribe.models.project import Project
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():
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({"subscribe_rulebooks": [2],
"design_system_id": 3, "seed_systems": True}) is None
assert validate_inception({"design_system_id": 3, "seed_systems": True}) is None
assert validate_inception({"design_system_id": None}) is None
assert "must be an object" in validate_inception([])
assert "unknown inception choice" in validate_inception({"repo": "x"})
@@ -41,19 +39,17 @@ def test_validate_inception_pins_the_choice_vocabulary():
# believe a rulebook had been declined.
assert "unknown inception choice" in validate_inception(
{"exclude_always_on_rulebooks": [1]})
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [0]})
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [True]})
# Same for the subscription choice since milestone 414.
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": True})
assert "true or false" in validate_inception({"seed_systems": "yes"})
def test_normalize_choices_is_canonical_and_complete():
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3]})
assert out == {"subscribe_rulebooks": [1, 3],
"design_system_id": None, "seed_systems": False}
assert normalize_choices(None) == {"subscribe_rulebooks": [],
"design_system_id": None, "seed_systems": False}
out = normalize_choices({"design_system_id": 4})
assert out == {"design_system_id": 4, "seed_systems": False}
assert normalize_choices(None) == {"design_system_id": None, "seed_systems": False}
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).
What mocks can't prove: a decision's effects land through the real services
(exclusions filter the always-on set, subscriptions bind, the design system
points, the standard Systems seed once), the record is written last, a bad
target applies nothing.
(the design system points, the standard Systems seed once), the record is
written last, a bad 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_asyncio
@@ -11,7 +13,6 @@ import pytest_asyncio
from scribe.models import async_session
from scribe.models.project import Project
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 systems as systems_svc
from tests.helpers import ensure_user
@@ -21,8 +22,7 @@ pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine"
@pytest_asyncio.fixture
async def seeded():
"""Owner, a fresh project, one always-on rulebook (with a rule) and one
ordinary rulebook (with a rule)."""
"""Owner and a fresh, undecided project."""
async with async_session() as s:
owner = await ensure_user(s, "inception_owner")
project = Project(user_id=owner.id, title="Inception target")
@@ -30,56 +30,33 @@ async def seeded():
await s.flush()
ids = {"owner": owner.id, "pid": project.id}
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
@pytest.mark.integration
async def test_decide_applies_every_effect_and_records_last(seeded):
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)
assert sorted(r["id"] for r in defaults["rulebooks"]) == sorted(
[seeded["always"], seeded["other"]])
assert set(defaults) == {"design_system_id", "design_systems", "systems"}
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={
"subscribe_rulebooks": [seeded["other"]],
"design_system_id": None,
"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()
assert len(out["effects"]["systems_seeded"]) == len(catalog)
# Seeded Systems come out mapped, not needing a later reconciliation.
seeded_systems = await systems_svc.list_systems(owner, pid)
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.
async with async_session() as s:
project = await s.get(Project, pid)
assert inception_svc.is_decided(project)
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
assert project.inception["choices"]["subscribe_rulebooks"] == [seeded["other"]]
assert project.inception["choices"] == {"design_system_id": None, "seed_systems": True}
# Re-deciding with seed again mints nothing twice.
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
assert again["effects"]["systems_seeded"] == []
@@ -89,15 +66,20 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
@pytest.mark.integration
async def test_a_bad_decision_applies_nothing(seeded):
owner, pid = seeded["owner"], seeded["pid"]
# A subscription to a rulebook that is not yours is refused BEFORE any
# effect lands — the seed must not happen on a decision that fails.
# A design system that is not readable is refused BEFORE any effect
# lands — the seed must not happen on a decision that fails.
with pytest.raises(ValueError, match="not found"):
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) == []
with pytest.raises(ValueError, match="not found"):
await inception_svc.decide(owner, pid, via="mcp", choices={"subscribe_rulebooks": [999999]})
with pytest.raises(ValueError, match="legacy"):
await inception_svc.decide(owner, pid, via="legacy", choices={})
async with async_session() as s:
+49 -64
View File
@@ -1,21 +1,21 @@
"""Real-Postgres tests for WHICH rules reach a session (milestone 307 step 5,
narrowed by 394).
"""Real-Postgres tests for WHICH rules a project's listing names (milestone
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
arrives — binding, not suggested — to one that does. Area matching is
DETERMINISTIC: a tag comparison, never a similarity score.
2. A `co_surfaces` partner arrives with its other half, which is the failure
that made merging rule 144 into rule 46 look like the only fix.
3. An explicit suppression outranks an edge.
1. A global rule tagged to an area arrives in the listing of a project that
works in that area, and not before. Area matching is DETERMINISTIC: a tag
comparison, never a similarity score.
2. An UNTAGGED global rule is not listed: it applies everywhere and arrives by
retrieval, and listing every one under every project would say nothing.
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
rather than leaving a shorter list. "A rule with no tier binds exactly as
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.
Milestone 414 dropped the suppression claim ("an explicit suppression outranks
an edge") with suppressions themselves.
"""
import pytest
import pytest_asyncio
@@ -32,40 +32,27 @@ pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine"
@pytest_asyncio.fixture
async def world():
"""A project with two rulebooks — one subscribed, one not.
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".
"""
"""A project with one rule of its own, and a rulebook of global rules."""
async with async_session() as s:
owner = await ensure_user(s, "surfacing_owner")
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()
ids = {"owner": owner.id, "pid": project.id}
ids = {"owner": owner.id, "pid": project.id, "elsewhere": elsewhere.id}
await s.commit()
always = 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")
book = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
topic = await rulebooks_svc.create_topic(book.id, ids["owner"], "release")
plain = await rulebooks_svc.create_rule(
topic.id, ids["owner"], "Between batches, keep stacking", "Keep going.",
await rulebooks_svc.create_rule(
topic.id, ids["owner"], "dev is home", "Work on dev.",
when_to_apply="before pushing a branch",
)
await rulebooks_svc.subscribe_project(
project_id=ids["pid"], rulebook_id=book.id, user_id=ids["owner"],
own = await rulebooks_svc.create_project_rule(
ids["pid"], ids["owner"], "Between batches, keep stacking", "Keep going.",
when_to_apply="when a batch goes green",
)
ids.update({
"always": always.id, "always_topic": always_topic.id,
"book": book.id, "topic": topic.id, "plain": plain.id,
})
ids.update({"book": book.id, "topic": topic.id, "own": own.id})
return ids
@@ -74,12 +61,18 @@ async def _titles(ids) -> set[str]:
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
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
reaches the area through its own System's canonical_id — its local NAME is
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"])
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"]
@@ -118,12 +111,8 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
"A name decides nothing.",
when_to_apply="when naming a build",
)
# TAGGED TO AN AREA THIS PROJECT DOES NOT WORK IN, which is what makes the
# test able to fail at all. Since milestone 394 an UNTAGGED rule in a
# 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.
# Tagged to an area this project does NOT work in, so nothing but the edge
# can bring it in — otherwise this test could pass without the edge.
area = await canonical_svc.find_by_name("CI & Release")
assert area is not None, "migration 0087 seeds the standard vocabulary"
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(
world["owner"], world["plain"], partner.id, "co_surfaces",
world["owner"], world["own"], partner.id, "co_surfaces",
note="they fail together",
)
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
async def test_a_suppression_outranks_an_edge(world):
"""The edge says these belong together; the suppression says this project
does not want that one. An explicit decision beats an inferred one."""
# Untagged on purpose, unlike the partner above: this test is about the
# SUPPRESSION winning, so the partner should be one that would otherwise
# arrive by every available route — the ordinary query AND the edge.
partner = await rulebooks_svc.create_rule(
world["topic"], world["owner"], "Muted partner", "Should not arrive.",
async def test_an_edge_is_not_a_way_into_another_project(world):
"""A partner that lives on a different project belongs to that project.
The edge says the two fail together; it does not make one project's rule
apply to another."""
foreign = await rulebooks_svc.create_project_rule(
world["elsewhere"], world["owner"], "Another project's rule", "Not here.",
when_to_apply="working on the other project",
)
await rulebooks_svc.add_rule_relation(
world["owner"], world["plain"], partner.id, "co_surfaces",
)
await rulebooks_svc.suppress_rule_for_project(
world["pid"], partner.id, world["owner"],
world["owner"], world["own"], foreign.id, "co_surfaces",
)
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.to_dict.return_value = {"id": 9, "title": "step 1", "status": "todo"}
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",
AsyncMock(return_value=m)), \
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
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}
with patch("scribe.mcp.tools.tasks.planning_svc.start_planning",
AsyncMock(return_value=payload)) as mock:
@@ -26,7 +26,7 @@ async def test_start_planning_tool_delegates_to_service():
@pytest.mark.asyncio
async def test_get_task_augments_plan_with_rules():
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",
AsyncMock(return_value=(fake_task(task_kind="plan", id=9, project_id=3), "owner"))), \
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
out = await get_task(task_id=9)
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
+18 -21
View File
@@ -73,7 +73,7 @@ async def test_get_project_enriches_with_milestone_summary():
p = fake_project(id=5, title="found")
milestone_summary = [{"id": 10, "title": "MS", "status": "active", "total": 3}]
applicable_payload = {
"rules": [], "truncated": False, "subscribed_rulebooks": [],
"rules": [], "truncated": False,
}
with patch(
"scribe.mcp.tools.projects.projects_svc.get_project",
@@ -91,9 +91,10 @@ async def test_get_project_enriches_with_milestone_summary():
@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
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")
milestone_summary = []
@@ -105,7 +106,6 @@ async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks():
"rulebook_title": "FabledSword family"},
],
"truncated": False,
"subscribed_rulebooks": [{"id": 1, "title": "FabledSword family"}],
}
with patch(
"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)
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
for gone in ("subscribed_rulebooks", "suppressed_rules", "suppressed_topics"):
assert gone not in out, gone
@pytest.mark.asyncio
@@ -173,7 +175,6 @@ async def test_enter_project_composes_full_context():
"topic_title": "t", "rulebook_title": "rb"}],
"project_rules": [{"id": 99, "title": "pr1", "statement": "ps"}],
"truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}],
}
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["milestone_summary"] == milestone_summary
# 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["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}]
for gone in ("applicable_rules", "applicable_rules_truncated",
for gone in ("applicable_rules", "applicable_rules_truncated", "subscribed_rulebooks",
"suppressed_rules", "suppressed_topics", "recent_notes"):
assert gone not in out, gone
assert out["open_tasks"] == [{
@@ -243,8 +243,7 @@ async def test_enter_project_surfaces_the_systems_vocabulary():
AsyncMock(return_value=p),
), patch(
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False,
"subscribed_rulebooks": []}),
AsyncMock(return_value={"rules": [], "truncated": False}),
), patch(
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=[]),
@@ -266,8 +265,7 @@ def _enter_project_stubs(p):
patch("scribe.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=p)),
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False,
"subscribed_rulebooks": []})),
AsyncMock(return_value={"rules": [], "truncated": False})),
patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=[])),
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),
), patch(
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False,
"subscribed_rulebooks": []}),
AsyncMock(return_value={"rules": [], "truncated": False}),
), patch(
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
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": []}}
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:
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
assert decide.await_args.args[1] == 5 and kw["via"] == "mcp"
assert kw["choices"] == {"subscribe_rulebooks": [1],
"design_system_id": None, "seed_systems": True}
assert kw["choices"] == {"design_system_id": None, "seed_systems": True}
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
async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project():
applicable = {"rules": [], "project_rules": [], "truncated": False,
"subscribed_rulebooks": []}
applicable = {"rules": [], "project_rules": [], "truncated": False}
ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"}
async def run(project):
@@ -471,6 +466,8 @@ def test_inception_routes_and_tool_are_registered():
mcp = build_mcp_server()
assert mcp._tool_manager.get_tool("decide_project_inception") is not None
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
# 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
@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():
"""Every tool in the module reaches the server.
@@ -227,7 +203,9 @@ def test_register_attaches_every_tool():
# (milestone 399).
# 28 since milestone 394 took list_always_on_rules and the two
# 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
assert "list_rulebooks" 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.
assert "create_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 "suppress_rule_for_project" in mcp.names
# milestone 312: the sweep, and the stamp that answers it
assert "rules_due_for_verification" in mcp.names
assert "mark_rule_verified" in mcp.names
# milestone 323: what a rule used to say
assert "rule_history" in mcp.names
assert "unsuppress_rule_for_project" in mcp.names
assert "suppress_topic_for_project" in mcp.names
assert "unsuppress_topic_for_project" in mcp.names
# milestone 414: a rule's home is its scope; nothing subscribes or mutes.
for gone in ("subscribe_project_to_rulebook", "unsubscribe_project_from_rulebook",
"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"
@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) ───────────────────────────
@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,
design=None):
applicable = rules or {"rules": [], "project_rules": [], "truncated": False,
"subscribed_rulebooks": []}
applicable = rules or {"rules": [], "project_rules": [], "truncated": False}
return [
patch("scribe.mcp.tools.projects.projects_svc.get_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)],
"project_rules": [{"id": 100 + i, "title": f"pr{i}", "statement": PLAN,
"when_to_apply": PLAN} for i in range(60)],
"truncated": True, "subscribed_rulebooks": [{"id": 1, "title": "Family"}],
"suppressed_rules": [], "suppressed_topics": [],
"truncated": True,
}
systems = []
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",
AsyncMock(return_value=_history(10))), \
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False,
"subscribed_rulebooks": []})):
AsyncMock(return_value={"rules": [], "truncated": False})):
out = await get_project(project_id=5)
assert len(out["milestone_summary"]) == 10
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",
"list_rules",
"get_rule", "update_rule", "delete_rule",
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
"suppress_rule_for_project", "unsuppress_rule_for_project",
"suppress_topic_for_project", "unsuppress_topic_for_project",
"get_applicable_rules",
):
sig = inspect.signature(getattr(svc, fn_name))
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"))
def test_suppression_route_handlers_exist():
"""The 4 suppression endpoint handlers are registered as Python callables."""
from scribe.routes import rulebooks as rb_routes
for name in (
"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>."""
def test_subscriptions_and_suppressions_are_gone():
"""Milestone 414: a rule's home is its scope. Nothing subscribes a project
to a rulebook or mutes a rule for one — not a route, a service function or
a table. A partial removal would leave a door that 500s on a missing table.
"""
from scribe.models import rulebook as rb_models
for tbl_name in ("project_rule_suppressions", "project_topic_suppressions"):
tbl = getattr(rb_models, tbl_name, None)
assert tbl is not None, f"models.rulebook missing {tbl_name}"
cols = {c.name for c in tbl.columns}
assert "project_id" in cols
assert "rule_id" in cols or "topic_id" in cols
from scribe.routes import rulebooks as rb_routes
from scribe.services import rulebooks as svc
for name in ("subscribe_project", "unsubscribe_project",
"suppress_project_rule", "unsuppress_project_rule",
"suppress_project_topic", "unsuppress_project_topic"):
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_and_subscription_handlers_callable():
def test_rule_handlers_callable():
from scribe.routes import rulebooks as rb_routes
for name in (
"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).
"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}],
"project_rules": [{"id": 12}],
"truncated": False,
"subscribed_rulebooks": [],
},
user_id=1,
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():
"""The handshake's form (#4045): project rules by id and title, the
subscribed rulebooks, nothing else. A subscription-derived rule it doesn't
show must not count as surfaced."""
"""The handshake's form (#4045): project rules by id and title, nothing
else (subscriptions went in milestone 414). A global rule it doesn't show
must not count as surfaced."""
from scribe.services import rulebooks as svc
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"}],
"project_rules": [{"id": 12, "title": "own", "statement": "s"}],
"truncated": False,
"subscribed_rulebooks": [{"id": 1, "title": "Family"}],
},
user_id=1,
source="enter_project",
brief=True,
)
assert out == {
"project_rules": [{"id": 12, "title": "own"}],
"subscribed_rulebooks": [{"id": 1, "title": "Family"}],
}
assert out == {"project_rules": [{"id": 12, "title": "own"}]}
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
# payload uses for the same sections. Getting this wrong is what the guard
# caught on its own first run.
join_tables = {
"project_rulebook_subscriptions", "project_rule_suppressions",
"project_topic_suppressions",
"rule_systems",
}
join_tables = {"rule_systems"}
covered = set(_column_guard_targets()) | join_tables
assert set(backup._BACKED_UP) - covered == set()
# 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:
def scalars(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
# note_supersessions, and v7's code_shapes (all empty here).
for key in ("rulebooks", "rulebook_topics", "rules",
"rulebook_subscriptions", "rule_suppressions",
"topic_suppressions",
"systems", "record_systems", "design_systems",
"design_tokens", "note_usage_events", "repo_bindings",
"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": "...",
"topic_title": "git-workflow", "rulebook_title": "FabledSword family"}],
"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",
AsyncMock(return_value=fake_milestone)) as mock_create, \
@@ -34,7 +34,8 @@ async def test_start_planning_creates_milestone_and_returns_rules():
# Returned shape
assert out["milestone"]["id"] == 5
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
+48 -64
View File
@@ -172,23 +172,7 @@ async def test_get_rule_returns_none_when_not_owner():
assert result is None
# ── Subscriptions + 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,
)
# ── applicable_rules ────────────────────────────────────────────────────
def _empty():
"""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
async def test_get_applicable_rules_returns_shape():
"""get_applicable_rules returns the full projection — including the
new suppression fields and rulebook/topic IDs on each rule."""
"""The listing is a project's own rules plus the global rules tagged to its
areas (milestone 414) — no subscriptions or suppressions in the shape."""
mock_session = make_mock_session()
sub_result = MagicMock()
sub_result.all.return_value = [(1, "FabledSword family")]
rules_result = MagicMock()
rules_result.all.return_value = [
# 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")
for i in range(50)
]
# Execute order: sub_q, suppressed_rules_q, suppressed_topics_q,
# project-areas_q (milestone 307), rules_q, proj_rules_q
# Execute order: project-areas_q, rules_q (only with areas), proj_rules_q
mock_session.execute = AsyncMock(side_effect=[
sub_result, _empty(), _empty(), _empty(), rules_result, _empty(),
_areas(4), rules_result, _empty(),
])
_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
result = await get_applicable_rules(project_id=3, user_id=7, limit=50)
assert "rules" in result
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 set(result) == {"rules", "project_rules", "truncated"}
assert len(result["rules"]) == 50
assert result["rules"][0]["topic_id"] == 2
assert result["rules"][0]["rulebook_id"] == 1
assert result["project_rules"] == []
assert result["suppressed_rules"] == []
assert result["suppressed_topics"] == []
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():
"""When limit+1 rows are returned, truncated=True and only `limit` returned."""
mock_session = make_mock_session()
sub_result = MagicMock()
sub_result.all.return_value = []
rules_result = MagicMock()
rules_result.all.return_value = [
(fake_rule(id=i, title=f"r{i}"), "topic", 1, "rb") for i in range(51)
]
mock_session.execute = AsyncMock(side_effect=[
sub_result, _empty(), _empty(), _empty(), rules_result, _empty(),
_areas(4), rules_result, _empty(),
])
_p1, _p2, _p3 = _no_edges()
@@ -284,6 +262,24 @@ async def test_get_applicable_rules_truncates_when_over_limit():
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
async def test_get_applicable_rules_includes_project_scoped_rules():
"""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",
statement="Land schema changes in their own PR."),),
]
mock_session.execute = AsyncMock(side_effect=[
_empty(), _empty(), _empty(), _empty(), _empty(), proj_rules_result,
])
mock_session.execute = AsyncMock(side_effect=[_areas(), proj_rules_result])
_p1, _p2, _p3 = _no_edges()
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
async def test_get_applicable_rules_surfaces_suppressed_with_context():
"""Suppressed rules and topics come back with full title + rulebook context
so the UI can render them without an extra round-trip."""
async def test_a_co_surfaces_partner_on_another_project_is_not_dragged_in():
"""An edge is not a way into a project: a partner that is global or on this
project arrives; one on a different project does not."""
mock_session = make_mock_session()
suppressed_rules_result = MagicMock()
suppressed_rules_result.all.return_value = [
# (rule_id, title, topic_id, topic_title, rulebook_id, rulebook_title)
(17, "Old rule", 5, "old-topic", 1, "FabledSword family"),
proj_rules_result = MagicMock()
proj_rules_result.all.return_value = [(fake_rule(id=100, topic_id=None, project_id=3),)]
mock_session.execute = AsyncMock(side_effect=[_areas(), proj_rules_result])
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, _p1, _p2, _p3:
with patch("scribe.services.rulebooks.async_session") as mock_cls, \
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
from scribe.services.rulebooks import get_applicable_rules
result = await get_applicable_rules(project_id=3, user_id=7)
assert len(result["suppressed_rules"]) == 1
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"
assert [r["id"] for r in result["rules"]] == [200, 201]
# ── rule_brief (milestone 307) ──────────────────────────────────────────
+4 -5
View File
@@ -44,16 +44,15 @@ async def test_delete_returns_none_when_not_found():
@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()
# exists-check + 6 cascade ops:
# exists-check + 4 cascade ops:
# notes (soft) → milestones (soft) → project-scoped rules (soft) →
# project_rule_suppressions (hard DELETE) → project_topic_suppressions (hard DELETE) →
# project (soft)
# The two suppression hard-DELETEs went with their tables (milestone 414).
session.execute = AsyncMock(side_effect=[
_exists_result(True),
MagicMock(), MagicMock(), MagicMock(),
MagicMock(), MagicMock(),
MagicMock(),
])
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
batch = await delete(user_id=1, entity_type="project", entity_id=3)
assert isinstance(batch, str)
assert session.execute.await_count == 7
assert session.execute.await_count == 5
@pytest.mark.asyncio