Merge pull request 'Milestone 414 steps 1–3: a rule is global or belongs to one project, and retrieval honours which; #3191 read-scope fix' (#158) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m0s
CI & Build / Python tests (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 16s

This commit was merged in pull request #158.
This commit is contained in:
2026-09-15 12:49:19 -04:00
54 changed files with 1404 additions and 1696 deletions
@@ -0,0 +1,82 @@
"""drop rulebook subscriptions and per-project suppressions
Revision ID: 0101
Revises: 0100
Create Date: 2026-09-15
Milestone 414. A rule lives in a rulebook topic, where it is GLOBAL, or on one
project, and retrieval reads that home directly (step 1). Subscriptions were
the last thing that pretended a rulebook reached some projects and not others,
and after milestone 394 they changed nothing a session received — only what a
project's rule LISTING showed. Operator, 2026-09-15: "we have global and
project scoped rules, we don't need the subscriptions now."
WHAT GOES
- ``project_rulebook_subscriptions`` (migration 0058).
- ``project_rule_suppressions`` and ``project_topic_suppressions``. They let a
project mute rules from a rulebook it subscribed to. With no subscription
there is nothing to mute; a project that departs from a global rule writes
a project rule with an ``overrides`` relation, which says why.
- The ``subscribe_rulebooks`` key inside ``projects.inception.choices``, and
the ``exclude_always_on_rulebooks`` key milestone 394 left behind in the
same place. Both describe decisions that can no longer be made; a stored
record carrying them would be read back as a choice the product offers.
IRREVERSIBLE, AND THE DOWNGRADE SAYS SO
The downgrade recreates the three tables empty. Which projects subscribed to
which rulebooks, and what they muted, is in what this drops. Restore a backup
taken before this ran if the prior state matters.
"""
import sqlalchemy as sa
from alembic import op
revision = "0101"
down_revision = "0100"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_table("project_topic_suppressions")
op.drop_table("project_rule_suppressions")
op.drop_table("project_rulebook_subscriptions")
op.execute(
"""
UPDATE projects
SET inception = jsonb_set(
inception, '{choices}',
(inception->'choices') - 'subscribe_rulebooks' - 'exclude_always_on_rulebooks'
)
WHERE inception IS NOT NULL
AND jsonb_typeof(inception->'choices') = 'object'
"""
)
def _join_table(name: str, other: str, other_table: str) -> None:
op.create_table(
name,
sa.Column(
"project_id", sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
other, sa.BigInteger(),
sa.ForeignKey(f"{other_table}.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=True,
),
)
def downgrade() -> None:
"""Structure only. See the module docstring — the rows are gone."""
_join_table("project_rulebook_subscriptions", "rulebook_id", "rulebooks")
_join_table("project_rule_suppressions", "rule_id", "rules")
_join_table("project_topic_suppressions", "topic_id", "rulebook_topics")
+1 -1
View File
@@ -89,7 +89,7 @@ table here. The tools are grouped by family:
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes | | Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
| Search / Recall | `search`, `get_recent`, `list_tags`, `retrieval_telemetry` | Semantic + structured recall, and the readout its thresholds are tuned from | | Search / Recall | `search`, `get_recent`, `list_tags`, `retrieval_telemetry` | Semantic + structured recall, and the readout its thresholds are tuned from |
| Systems | `create_system`, `list_systems`, `list_system_records` | Reusable per-project subsystems/areas | | Systems | `create_system`, `list_systems`, `list_system_records` | Reusable per-project subsystems/areas |
| Rulebooks | `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules | | Rulebooks | `list_rules`, `create_rule`, `create_project_rule`, `relate_rules`, … | Engineering/workflow rules |
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows | | Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes | | Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
| Admin | `get_app_logs` (write/admin key) | Diagnostics | | Admin | `get_app_logs` (write/admin key) | Diagnostics |
+3 -5
View File
@@ -77,7 +77,7 @@ endpoint at `/mcp`, not these REST routes.
|--------|------|-------------| |--------|------|-------------|
| GET / POST | `/api/projects` | List (owned + shared) / create | | GET / POST | `/api/projects` | List (owned + shared) / create |
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete | | GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete |
| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {subscribe_rulebooks, design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) | | POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) |
| GET | `/api/projects/:id/inception/defaults` | What binds if nobody decides — the inception card's payload | | GET | `/api/projects/:id/inception/defaults` | What binds if nobody decides — the inception card's payload |
| GET | `/api/projects/:id/notes` | Notes + tasks in this project | | GET | `/api/projects/:id/notes` | Notes + tasks in this project |
| GET / POST | `/api/projects/:id/milestones` | List / create milestones | | GET / POST | `/api/projects/:id/milestones` | List / create milestones |
@@ -115,11 +115,9 @@ endpoint at `/mcp`, not these REST routes.
| GET | `/api/rules` | List rules | | GET | `/api/rules` | List rules |
| POST | `/api/rulebook-topics/:tid/rules` | Add a rule to a topic | | POST | `/api/rulebook-topics/:tid/rules` | Add a rule to a topic |
| GET / PATCH / DELETE | `/api/rules/:id` | Read / update / delete a rule | | GET / PATCH / DELETE | `/api/rules/:id` | Read / update / delete a rule |
| POST | `/api/projects/:id/rulebook-subscriptions` | Subscribe a project to a rulebook | | POST | `/api/rules/:id/move` | Move a rule: `{topic_id}` makes it global, `{project_id}` makes it that project's |
| 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 | `/api/projects/:id/rules` | Create a project-scoped rule |
| POST / DELETE | `/api/projects/:id/suppressions/rules/:rid` | Suppress / unsuppress a rule |
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
## Sharing ## Sharing
+5 -2
View File
@@ -64,8 +64,11 @@ across sessions.
session when what the agent is about to do matches its trigger: a command, session when what the agent is about to do matches its trigger: a command,
a file being written, or the operator's own message. `when_to_apply` is a file being written, or the operator's own message. `when_to_apply` is
therefore the field that decides whether a rule is ever seen. therefore the field that decides whether a rule is ever seen.
- **Per-project scope** — A project subscribes to rulebooks, and can add - **Global or project scope** — A rule in a rulebook is global: it applies in
project-scoped rules or suppress individual inherited rules/topics. every project. A project rule applies to that project only. Retrieval honours
the difference, so a session sees global rules plus its own project's, never
another project's. A project that departs from a global rule writes its own
and links it with an `overrides` relation.
## Stored Processes ## Stored Processes
+2 -5
View File
@@ -2,7 +2,6 @@
import { apiGet, apiPost } from "@/api/client"; import { apiGet, apiPost } from "@/api/client";
export interface InceptionChoices { export interface InceptionChoices {
subscribe_rulebooks: number[];
design_system_id: number | null; design_system_id: number | null;
seed_systems: boolean; seed_systems: boolean;
} }
@@ -15,8 +14,6 @@ export interface InceptionRecord {
} }
export interface InceptionDefaults { export interface InceptionDefaults {
rulebooks: { id: number; title: string }[];
subscribed_rulebooks: { id: number; title: string }[];
design_system_id: number | null; design_system_id: number | null;
design_systems: { id: number; title: string }[]; design_systems: { id: number; title: string }[];
systems: number; systems: number;
@@ -25,11 +22,11 @@ export interface InceptionDefaults {
export interface InceptionDecision { export interface InceptionDecision {
project_id: number; project_id: number;
inception: InceptionRecord; inception: InceptionRecord;
effects: { excluded: number[]; subscribed: number[]; design_system_id: number | null; systems_seeded: string[] }; effects: { design_system_id: number | null; systems_seeded: string[] };
} }
export const emptyChoices = (): InceptionChoices => ({ export const emptyChoices = (): InceptionChoices => ({
subscribe_rulebooks: [], design_system_id: null, seed_systems: false, design_system_id: null, seed_systems: false,
}); });
export const fetchInceptionDefaults = (projectId: number) => export const fetchInceptionDefaults = (projectId: number) =>
+12 -46
View File
@@ -108,23 +108,7 @@ export interface ApplicableRules {
rulebook_title: string; rulebook_title: string;
})[]; })[];
project_rules: RuleHeader[]; project_rules: RuleHeader[];
suppressed_rules: {
id: number;
title: string;
topic_id: number;
topic_title: string;
rulebook_id: number;
rulebook_title: string;
}[];
suppressed_topics: {
id: number;
title: string;
rulebook_id: number;
rulebook_title: string;
}[];
truncated: boolean; truncated: boolean;
subscribed_rulebooks: { id: number; title: string }[];
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
} }
// ── Rulebooks ─────────────────────────────────────────────────────── // ── Rulebooks ───────────────────────────────────────────────────────
@@ -214,6 +198,14 @@ export async function updateRule(id: number, data: Partial<RuleWrite>): Promise<
return apiPatch(`/api/rules/${id}`, data); return apiPatch(`/api/rules/${id}`, data);
} }
/** Give a rule a new home: a topic makes it global, a project makes it that
* project's. Keeps its id, history, areas and relations (milestone 414). */
export async function moveRule(
id: number, to: { topic_id: number } | { project_id: number },
): Promise<Rule> {
return apiPost(`/api/rules/${id}/move`, to);
}
/** Draw a typed edge from one rule to another. Idempotent. */ /** Draw a typed edge from one rule to another. Idempotent. */
export async function relateRules( export async function relateRules(
fromRuleId: number, fromRuleId: number,
@@ -272,15 +264,7 @@ export async function deleteRule(id: number): Promise<void> {
return apiDelete(`/api/rules/${id}`); return apiDelete(`/api/rules/${id}`);
} }
// ── Subscriptions ────────────────────────────────────────────────── // ── A project's rules ──────────────────────────────────────────────
export async function subscribeProject(projectId: number, rulebookId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/rulebook-subscriptions`, { rulebook_id: rulebookId });
}
export async function unsubscribeProject(projectId: number, rulebookId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/rulebook-subscriptions/${rulebookId}`);
}
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> { export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
return apiGet(`/api/projects/${projectId}/rules`); return apiGet(`/api/projects/${projectId}/rules`);
@@ -293,24 +277,6 @@ export async function createProjectRule(
return apiPost(`/api/projects/${projectId}/rules`, data); return apiPost(`/api/projects/${projectId}/rules`, data);
} }
// ── Suppressions ───────────────────────────────────────────────────
export async function suppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/suppressions/rules/${ruleId}`, {});
}
export async function unsuppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/rules/${ruleId}`);
}
export async function suppressTopicForProject(projectId: number, topicId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/suppressions/topics/${topicId}`, {});
}
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
}
/** /**
* One row of the staleness sweep. Unlike RuleHeader this carries the CHECK * One row of the staleness sweep. Unlike RuleHeader this carries the CHECK
@@ -337,9 +303,9 @@ export interface RuleVerificationRow {
* first, never-checked at the top. Rules without a check never appear: * first, never-checked at the top. Rules without a check never appear:
* they are decisions, and there is nothing to go and check. * they are decisions, and there is nothing to go and check.
* *
* Not filterable by project — a project reaches rules through project * Not filterable by project — a project is bound by its own rules and by
* scope, subscriptions, always-on rulebooks and exclusions, and a filter * every global rule, and a filter that dropped the global ones would
* missing one of those paths would under-report. * under-report.
*/ */
export async function listRulesDueForVerification(opts: { export async function listRulesDueForVerification(opts: {
olderThanDays?: number; olderThanDays?: number;
+7 -35
View File
@@ -14,7 +14,6 @@ import {
decideInception, emptyChoices, fetchInceptionDefaults, decideInception, emptyChoices, fetchInceptionDefaults,
type InceptionChoices, type InceptionDecision, type InceptionDefaults, type InceptionChoices, type InceptionDecision, type InceptionDefaults,
} from "@/api/inception"; } from "@/api/inception";
import { listRulebooks } from "@/api/rulebooks";
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
mode: "create" | "decide"; mode: "create" | "decide";
@@ -28,7 +27,6 @@ const emit = defineEmits<{
}>(); }>();
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices()); const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
const others = ref<{ id: number; title: string }[]>([]);
const designSystems = ref<{ id: number; title: string }[]>([]); const designSystems = ref<{ id: number; title: string }[]>([]);
const systemsCount = ref(0); const systemsCount = ref(0);
const loading = ref(true); const loading = ref(true);
@@ -46,18 +44,12 @@ async function load() {
try { try {
if (props.mode === "decide" && props.projectId) { if (props.mode === "decide" && props.projectId) {
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId); const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
others.value = d.rulebooks;
designSystems.value = d.design_systems; designSystems.value = d.design_systems;
systemsCount.value = d.systems; systemsCount.value = d.systems;
// Start from what stands today so "record" without changes is a true inherit-all. // Start from what stands today, so "record" without changes keeps it.
local.value = { local.value = { design_system_id: d.design_system_id, seed_systems: false };
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
design_system_id: d.design_system_id,
seed_systems: false,
};
} else { } else {
const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]); const ds = await fetchDesignSystems();
others.value = rulebooks.map((r) => ({ id: r.id, title: r.title }));
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title })); designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
} }
} catch (e: unknown) { } catch (e: unknown) {
@@ -67,17 +59,7 @@ async function load() {
} }
} }
function subscribed(id: number): boolean { const nothingToDecide = computed(() => !designSystems.value.length);
return local.value.subscribe_rulebooks.includes(id);
}
function toggleSubscribe(id: number) {
const list = local.value.subscribe_rulebooks;
local.value.subscribe_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
}
const nothingToDecide = computed(
() => !others.value.length && !designSystems.value.length,
);
async function record() { async function record() {
if (!props.projectId) return; if (!props.projectId) return;
@@ -101,22 +83,12 @@ onMounted(load);
<h3 id="inception-title" class="inception-title">What does this project inherit?</h3> <h3 id="inception-title" class="inception-title">What does this project inherit?</h3>
<p class="inception-lede"> <p class="inception-lede">
A project's inheritance is a decision, not a default. Until it is recorded, A project's inheritance is a decision, not a default. Until it is recorded,
every always-on rulebook binds, nothing is subscribed, and there is no design there is no design system and no Systems. Rules aren't part of this: global
system or Systems. rules apply to every project, and a project's own rules are added on it.
</p> </p>
<p v-if="loading" class="inception-muted">Loading…</p> <p v-if="loading" class="inception-muted">Loading…</p>
<p v-else-if="error" class="error-msg">{{ error }}</p> <p v-else-if="error" class="error-msg">{{ error }}</p>
<template v-else> <template v-else>
<div v-if="others.length" class="inception-group">
<h4>Subscribe to rulebooks</h4>
<p class="inception-muted">
A rulebook binds this project only if it is subscribed — nothing is inherited automatically.
</p>
<label v-for="rb in others" :key="rb.id" class="inception-choice">
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
<span>{{ rb.title }}</span>
</label>
</div>
<div class="inception-group"> <div class="inception-group">
<h4>Design system</h4> <h4>Design system</h4>
<select v-model="local.design_system_id" class="inception-select" aria-label="Design system"> <select v-model="local.design_system_id" class="inception-select" aria-label="Design system">
@@ -134,7 +106,7 @@ onMounted(load);
</label> </label>
</div> </div>
<p v-if="nothingToDecide" class="inception-muted"> <p v-if="nothingToDecide" class="inception-muted">
Nothing to inherit yet on this install — recording still settles the question. No design systems on this install yet — recording still settles the question.
</p> </p>
<div v-if="mode === 'decide'" class="inception-actions"> <div v-if="mode === 'decide'" class="inception-actions">
<button class="btn-primary" :disabled="saving" @click="record"> <button class="btn-primary" :disabled="saving" @click="record">
+30 -184
View File
@@ -3,24 +3,27 @@ import { ref, onMounted, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { import {
getProjectApplicableRules, getProjectApplicableRules,
subscribeProject,
unsubscribeProject,
listRulebooks,
getRule, getRule,
createProjectRule, createProjectRule,
deleteRule, deleteRule,
suppressRuleForProject,
unsuppressRuleForProject,
suppressTopicForProject,
unsuppressTopicForProject,
} from "@/api/rulebooks"; } from "@/api/rulebooks";
import type { ApplicableRules, Rulebook } from "@/api/rulebooks"; import type { ApplicableRules } from "@/api/rulebooks";
import RuleHomePicker from "@/components/rules/RuleHomePicker.vue";
/**
* A project's view of its rules (milestone 414). A rule's home is its reach:
* the project's own rules apply here and nowhere else, and every global rule
* (one in a rulebook) applies here too. There is no subscribing this project
* to a rulebook and no skipping a global rule for it — a project that departs
* from one writes its own rule and links it with an `overrides` relation.
*
* The second list is the global rules TAGGED to an area this project works in,
* not every global rule: those arrive by retrieval when the work matches them,
* and listing them all under every project would say nothing.
*/
const props = defineProps<{ projectId: number }>(); const props = defineProps<{ projectId: number }>();
const router = useRouter(); const router = useRouter();
const applicable = ref<ApplicableRules | null>(null); const applicable = ref<ApplicableRules | null>(null);
const allRulebooks = ref<Rulebook[]>([]);
const showPicker = ref(false);
const expandedRuleIds = ref<Set<number>>(new Set()); const expandedRuleIds = ref<Set<number>>(new Set());
const ruleDetails = ref<Record<number, { const ruleDetails = ref<Record<number, {
@@ -38,22 +41,6 @@ async function load() {
applicable.value = await getProjectApplicableRules(props.projectId); applicable.value = await getProjectApplicableRules(props.projectId);
} }
async function loadAllRulebooks() {
allRulebooks.value = await listRulebooks();
}
async function subscribe(rulebookId: number) {
await subscribeProject(props.projectId, rulebookId);
showPicker.value = false;
await load();
}
async function unsubscribe(rulebookId: number) {
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
await unsubscribeProject(props.projectId, rulebookId);
await load();
}
async function toggleRuleExpand(ruleId: number) { async function toggleRuleExpand(ruleId: number) {
if (expandedRuleIds.value.has(ruleId)) { if (expandedRuleIds.value.has(ruleId)) {
expandedRuleIds.value.delete(ruleId); expandedRuleIds.value.delete(ruleId);
@@ -143,66 +130,13 @@ async function removeProjectRule(ruleId: number) {
await load(); await load();
} }
const showSuppressed = ref(false); onMounted(load);
async function suppressRule(ruleId: number) {
await suppressRuleForProject(props.projectId, ruleId);
await load();
}
async function unsuppressRule(ruleId: number) {
await unsuppressRuleForProject(props.projectId, ruleId);
await load();
}
async function suppressTopic(topicId: number) {
await suppressTopicForProject(props.projectId, topicId);
await load();
}
async function unsuppressTopic(topicId: number) {
await unsuppressTopicForProject(props.projectId, topicId);
await load();
}
onMounted(async () => {
await load();
await loadAllRulebooks();
});
watch(() => props.projectId, load); watch(() => props.projectId, load);
</script> </script>
<template> <template>
<div class="rules-tab" v-if="applicable"> <div class="rules-tab" v-if="applicable">
<section class="subscribed">
<h3>Subscribed rulebooks</h3>
<div class="chips">
<span
v-for="rb in applicable.subscribed_rulebooks"
:key="rb.id"
class="chip"
>
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
<button class="chip-remove" @click="unsubscribe(rb.id)" aria-label="Unsubscribe">×</button>
</span>
<button v-if="!showPicker" class="add" @click="showPicker = true">+ Subscribe</button>
<select
v-else
@change="subscribe(Number(($event.target as HTMLSelectElement).value))"
>
<option value="">Choose a rulebook</option>
<option
v-for="rb in allRulebooks.filter((rb) => !applicable!.subscribed_rulebooks.some((s) => s.id === rb.id))"
:key="rb.id"
:value="rb.id"
>
{{ rb.title }}
</option>
</select>
</div>
</section>
<section class="project-rules"> <section class="project-rules">
<div class="section-head"> <div class="section-head">
<h3>Project rules</h3> <h3>Project rules</h3>
@@ -273,6 +207,12 @@ watch(() => props.projectId, load);
<div v-if="ruleDetails[r.id].expires_when"> <div v-if="ruleDetails[r.id].expires_when">
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }} <strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
</div> </div>
<RuleHomePicker
:rule-id="r.id"
:topic-id="r.topic_id"
:project-id="projectId"
@moved="load"
/>
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button> <button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
</div> </div>
</li> </li>
@@ -286,9 +226,13 @@ watch(() => props.projectId, load);
</section> </section>
<section class="applicable"> <section class="applicable">
<h3>Applicable rules</h3> <h3>Global rules for this project's areas</h3>
<p class="applicable-note">
Every global rule applies to this project and arrives when the work matches it.
These are the ones tagged to an area this project works in.
</p>
<p v-if="applicable.rules.length === 0" class="empty"> <p v-if="applicable.rules.length === 0" class="empty">
No rules yet — subscribe to a rulebook above, or create one at None tagged to this project's areas. Global rules live in
<a @click="router.push('/rules')">Rulebooks</a>. <a @click="router.push('/rules')">Rulebooks</a>.
</p> </p>
<div <div
@@ -298,27 +242,13 @@ watch(() => props.projectId, load);
> >
<h4>{{ rb.rulebook_title }}</h4> <h4>{{ rb.rulebook_title }}</h4>
<div v-for="topic in rb.topics" :key="topic.topic_id" class="topic-group"> <div v-for="topic in rb.topics" :key="topic.topic_id" class="topic-group">
<h5> <h5>{{ topic.topic_title }}</h5>
<span>{{ topic.topic_title }}</span>
<button
class="skip-btn"
:title="`Skip the entire ${topic.topic_title} topic for this project`"
@click="suppressTopic(topic.topic_id)"
>× skip topic</button>
</h5>
<ul> <ul>
<li v-for="r in topic.rules" :key="r.id" class="rule"> <li v-for="r in topic.rules" :key="r.id" class="rule">
<div class="rule-head"> <div class="rule-head" @click="toggleRuleExpand(r.id)">
<div class="rule-head-text" @click="toggleRuleExpand(r.id)">
<span class="rule-title">{{ r.title }}</span> <span class="rule-title">{{ r.title }}</span>
<span class="rule-statement">{{ r.statement }}</span> <span class="rule-statement">{{ r.statement }}</span>
</div> </div>
<button
class="skip-btn"
title="Skip this rule for this project"
@click.stop="suppressRule(r.id)"
>× skip</button>
</div>
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail"> <div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
<div v-if="ruleDetails[r.id].why"> <div v-if="ruleDetails[r.id].why">
<strong>Why:</strong> {{ ruleDetails[r.id].why }} <strong>Why:</strong> {{ ruleDetails[r.id].why }}
@@ -349,64 +279,25 @@ watch(() => props.projectId, load);
</p> </p>
</section> </section>
<section
v-if="applicable.suppressed_rules.length + applicable.suppressed_topics.length > 0"
class="suppressed"
>
<button class="suppressed-toggle" @click="showSuppressed = !showSuppressed">
<span>Suppressed ({{ applicable.suppressed_rules.length + applicable.suppressed_topics.length }})</span>
<span class="caret">{{ showSuppressed ? "▾" : "▸" }}</span>
</button>
<div v-if="showSuppressed" class="suppressed-body">
<ul v-if="applicable.suppressed_topics.length > 0" class="suppressed-list">
<li v-for="t in applicable.suppressed_topics" :key="`topic-${t.id}`">
<span class="suppressed-kind">topic</span>
<span class="suppressed-path">{{ t.rulebook_title }} → {{ t.title }}</span>
<button class="reenable-btn" @click="unsuppressTopic(t.id)">↻ re-enable</button>
</li>
</ul>
<ul v-if="applicable.suppressed_rules.length > 0" class="suppressed-list">
<li v-for="r in applicable.suppressed_rules" :key="`rule-${r.id}`">
<span class="suppressed-kind">rule</span>
<span class="suppressed-path">{{ r.rulebook_title }} → {{ r.topic_title }} → {{ r.title }}</span>
<button class="reenable-btn" @click="unsuppressRule(r.id)">↻ re-enable</button>
</li>
</ul>
</div>
</section>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.trigger-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); } .trigger-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); }
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
.rules-tab { padding: 1rem; } .rules-tab { padding: 1rem; }
h3 { h3 {
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
margin-top: 0; margin-top: 0;
} }
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.chip {
display: inline-flex; align-items: center; gap: 0.25rem;
background: var(--fs-accent-soft);
padding: 0.25rem 0.5rem; border-radius: 999px;
}
.chip a { cursor: pointer; }
.chip-remove { background: none; border: none; cursor: pointer; opacity: 0.5; font-size: 1.1em; }
.chip-remove:hover { opacity: 1; }
.add { .add {
background: none; background: none;
border: 1px dashed var(--fs-border-color); border: 1px dashed var(--fs-border-color);
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer; padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
color: inherit; color: inherit;
} }
select {
background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.25rem 0.5rem;
}
.applicable { margin-top: 2rem; } .applicable { margin-top: 2rem; }
.applicable-note { margin: 0 0 0.75rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
.rb-group { margin-bottom: 1.5rem; } .rb-group { margin-bottom: 1.5rem; }
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; } .rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
/* `.topic-group` is deliberately bare — a namespace for the two h5 rules (this /* `.topic-group` is deliberately bare — a namespace for the two h5 rules (this
@@ -441,7 +332,7 @@ ul { list-style: none; padding: 0; margin: 0; }
} }
.empty, .truncated { opacity: 0.7; font-style: italic; } .empty, .truncated { opacity: 0.7; font-style: italic; }
.empty a { cursor: pointer; text-decoration: underline; } .empty a { cursor: pointer; text-decoration: underline; }
.project-rules { margin-top: 1.5rem; } .project-rules { margin-top: 0; }
.section-head { display: flex; justify-content: space-between; align-items: center; } .section-head { display: flex; justify-content: space-between; align-items: center; }
.new-rule-form { .new-rule-form {
display: flex; flex-direction: column; gap: 0.5rem; display: flex; flex-direction: column; gap: 0.5rem;
@@ -459,49 +350,4 @@ ul { list-style: none; padding: 0; margin: 0; }
background: none; border: none; cursor: pointer; background: none; border: none; cursor: pointer;
color: var(--fs-destructive); padding: 0.5rem 0 0 0; color: var(--fs-destructive); padding: 0.5rem 0 0 0;
} }
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
.topic-group h5 {
display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;
}
.rule-head {
display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem;
}
.rule-head-text { flex: 1; cursor: pointer; }
.skip-btn {
background: none; border: none; cursor: pointer;
color: var(--fs-text-tertiary); font-size: 0.75rem;
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
white-space: nowrap;
}
.topic-group h5:hover .skip-btn,
.rule:hover .skip-btn,
.skip-btn:focus { opacity: 1; }
.skip-btn:hover { color: var(--fs-destructive); }
/* Suppressed section */
.suppressed { margin-top: 1.5rem; }
.suppressed-toggle {
display: flex; align-items: center; gap: 0.4rem;
background: none; border: none; cursor: pointer;
font-size: 0.85rem; opacity: 0.7; padding: 0.25rem 0; color: inherit;
}
.suppressed-toggle:hover { opacity: 1; }
.suppressed-toggle .caret { font-size: 0.7em; }
.suppressed-body { margin-top: 0.5rem; }
.suppressed-list { padding-left: 0; }
.suppressed-list li {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.25rem 0; opacity: 0.75;
}
.suppressed-kind {
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.1rem 0.4rem; border-radius: 3px;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
}
.suppressed-path { flex: 1; }
.reenable-btn {
background: none; border: none; cursor: pointer;
color: var(--fs-accent); font-size: 0.85em;
}
.reenable-btn:hover { text-decoration: underline; }
</style> </style>
@@ -3,6 +3,8 @@ import { computed, ref, watch, onMounted } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks"; import { useRulebooksStore } from "@/stores/rulebooks";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems"; import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue"; import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
import RuleHomePicker from "@/components/rules/RuleHomePicker.vue";
import type { Rule } from "@/api/rulebooks";
const props = defineProps<{ ruleId: number | null; topicId: number | null }>(); const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
const emit = defineEmits<{ close: [] }>(); const emit = defineEmits<{ close: [] }>();
@@ -115,6 +117,14 @@ async function save() {
emit("close"); emit("close");
} }
// A moved rule has left the topic this view lists (or joined another). The
// store re-places it, then the editor saves any text edits and closes, the
// way the backdrop does.
async function onMoved(rule: Rule) {
store.placeMovedRule(rule);
await save();
}
async function remove() { async function remove() {
if (props.ruleId === null) return; if (props.ruleId === null) return;
if (!confirm("Delete this rule? This cannot be undone.")) return; if (!confirm("Delete this rule? This cannot be undone.")) return;
@@ -210,6 +220,14 @@ watch(() => props.ruleId, load);
</p> </p>
</fieldset> </fieldset>
<RuleHomePicker
v-if="!isCreating && ruleId !== null && store.currentRule"
:rule-id="ruleId"
:topic-id="store.currentRule.topic_id"
:project-id="store.currentRule.project_id"
@moved="onMoved"
/>
<section v-if="relations.length" class="relations"> <section v-if="relations.length" class="relations">
<h3>Related rules</h3> <h3>Related rules</h3>
<ul> <ul>
@@ -221,7 +239,7 @@ watch(() => props.ruleId, load);
</ul> </ul>
<p class="field-note"> <p class="field-note">
Rules that <em>fail together</em> are linked, never merged a merged rule cannot be Rules that <em>fail together</em> are linked, never merged a merged rule cannot be
cited, surfaced or suppressed a clause at a time. cited or surfaced a clause at a time.
</p> </p>
</section> </section>
@@ -0,0 +1,131 @@
<script setup lang="ts">
/**
* Where a rule lives, and moving it (milestone 414).
*
* A rule's home IS its reach: in a rulebook topic it is global and applies to
* every project; on a project it applies there alone. Moving keeps the rule's
* id, edit history, areas and relations — the reason this is a move and not
* "recreate it over there and delete this one".
*
* One component for both places a rule is read: the rule editor (a global
* rule) and a project's rules tab (a project rule). Each would otherwise grow
* its own picker, and the two would drift on what a destination is.
*/
import { computed, onMounted, ref } from "vue";
import { apiErrorMessage, apiGet } from "@/api/client";
import { listRulebooks, listTopics, moveRule, type Rule } from "@/api/rulebooks";
const props = defineProps<{
ruleId: number;
topicId: number | null;
projectId: number | null;
}>();
const emit = defineEmits<{ moved: [rule: Rule] }>();
interface TopicChoice { id: number; label: string }
interface ProjectChoice { id: number; title: string }
const topics = ref<TopicChoice[]>([]);
const projects = ref<ProjectChoice[]>([]);
// "topic:12" / "project:4" — one select, two kinds of destination.
const destination = ref("");
const loading = ref(true);
const moving = ref(false);
const error = ref("");
const currentLabel = computed(() => {
if (props.topicId !== null) {
const t = topics.value.find((x) => x.id === props.topicId);
return t ? `Global — ${t.label}` : "Global";
}
const p = projects.value.find((x) => x.id === props.projectId);
return p ? `Project — ${p.title}` : "Project";
});
async function load() {
loading.value = true;
error.value = "";
try {
const [books, proj] = await Promise.all([
listRulebooks(),
apiGet<{ projects: ProjectChoice[] }>("/api/projects"),
]);
const perBook = await Promise.all(books.map(async (rb) => {
const ts = await listTopics(rb.id);
return ts.map((t) => ({ id: t.id, label: `${rb.title} ${t.title}` }));
}));
topics.value = perBook.flat();
projects.value = proj.projects.map((p) => ({ id: p.id, title: p.title }));
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not load where this rule could live");
} finally {
loading.value = false;
}
}
async function move() {
const [kind, raw] = destination.value.split(":");
const id = Number(raw);
if (!id) return;
const where = kind === "topic"
? "global — it will apply to every project"
: "this project's only — other projects will stop receiving it";
if (!confirm(`Move this rule? It becomes ${where}.`)) return;
moving.value = true;
error.value = "";
try {
const rule = await moveRule(props.ruleId, kind === "topic" ? { topic_id: id } : { project_id: id });
destination.value = "";
emit("moved", rule);
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not move the rule");
} finally {
moving.value = false;
}
}
onMounted(load);
</script>
<template>
<fieldset class="rule-home">
<legend>Where this rule applies</legend>
<p v-if="loading" class="state-msg">Loading</p>
<template v-else>
<p class="rule-home-current">{{ currentLabel }}</p>
<div class="rule-home-move">
<select v-model="destination" class="fs-input" aria-label="Move this rule to">
<option value="">Move to</option>
<optgroup v-if="topics.length" label="Global (a rulebook topic)">
<option
v-for="t in topics.filter((x) => x.id !== topicId)"
:key="`topic-${t.id}`"
:value="`topic:${t.id}`"
>{{ t.label }}</option>
</optgroup>
<optgroup v-if="projects.length" label="One project">
<option
v-for="p in projects.filter((x) => x.id !== projectId)"
:key="`project-${p.id}`"
:value="`project:${p.id}`"
>{{ p.title }}</option>
</optgroup>
</select>
<button
type="button"
class="btn-secondary btn-compact"
:disabled="!destination || moving"
@click="move"
>{{ moving ? "Moving…" : "Move" }}</button>
</div>
</template>
<p v-if="error" class="error-msg">{{ error }}</p>
</fieldset>
</template>
<style scoped>
.rule-home { margin-bottom: 1rem; }
.rule-home-current { margin: 0 0 0.5rem; font-size: 0.88rem; }
.rule-home-move { display: flex; gap: var(--fs-space-2); align-items: center; }
.rule-home-move select { flex: 1; min-width: 0; }
</style>
@@ -1,12 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from "vue"; import { ref } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks"; import { useRulebooksStore } from "@/stores/rulebooks";
import { apiGet } from "@/api/client";
import {
subscribeProject, unsubscribeProject, getProjectApplicableRules,
} from "@/api/rulebooks";
import type { RulebookTopic } from "@/api/rulebooks"; import type { RulebookTopic } from "@/api/rulebooks";
// A rulebook's rules are global — they apply to every project — so this pane
// lists topics and nothing else. It carried a "Subscribers" checklist of
// projects until milestone 414 retired subscriptions.
const props = defineProps<{ const props = defineProps<{
rulebookId: number; rulebookId: number;
topics: RulebookTopic[]; topics: RulebookTopic[];
@@ -18,42 +17,6 @@ const store = useRulebooksStore();
const isCreating = ref(false); const isCreating = ref(false);
const newTitle = ref(""); const newTitle = ref("");
interface ProjectLite { id: number; title: string }
const projects = ref<ProjectLite[]>([]);
// Map<project_id, Set<rulebook_id>>
const subscribedRulebookIds = ref<Map<number, Set<number>>>(new Map());
async function loadProjects() {
const data = await apiGet<{ projects: ProjectLite[] }>("/api/projects");
projects.value = data.projects;
for (const p of projects.value) {
const result = await getProjectApplicableRules(p.id);
subscribedRulebookIds.value.set(
p.id,
new Set(result.subscribed_rulebooks.map((rb) => rb.id)),
);
}
}
function isSubscribed(projectId: number): boolean {
return subscribedRulebookIds.value.get(projectId)?.has(props.rulebookId) ?? false;
}
async function toggleSubscription(projectId: number, checked: boolean) {
if (checked) {
await subscribeProject(projectId, props.rulebookId);
const set = subscribedRulebookIds.value.get(projectId) || new Set<number>();
set.add(props.rulebookId);
subscribedRulebookIds.value.set(projectId, set);
} else {
await unsubscribeProject(projectId, props.rulebookId);
subscribedRulebookIds.value.get(projectId)?.delete(props.rulebookId);
}
// trigger reactivity on Map mutation
subscribedRulebookIds.value = new Map(subscribedRulebookIds.value);
}
async function submitNew() { async function submitNew() {
const title = newTitle.value.trim(); const title = newTitle.value.trim();
if (!title) return; if (!title) return;
@@ -63,8 +26,6 @@ async function submitNew() {
emit("select-topic", topic.id); emit("select-topic", topic.id);
} }
onMounted(loadProjects);
watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing map */});
</script> </script>
<template> <template>
@@ -92,21 +53,6 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
</div> </div>
</form> </form>
</div> </div>
<div class="subscriptions">
<h3>Subscribers</h3>
<ul class="sub-list">
<li v-for="p in projects" :key="p.id">
<label>
<input
type="checkbox"
:checked="isSubscribed(p.id)"
@change="toggleSubscription(p.id, ($event.target as HTMLInputElement).checked)"
/>
{{ p.title }}
</label>
</li>
</ul>
</div>
</section> </section>
</template> </template>
@@ -117,24 +63,13 @@ ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; } li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li.active { background: var(--fs-accent-soft); } li.active { background: var(--fs-accent-soft); }
li:hover { background: var(--fs-surface-hover); } li:hover { background: var(--fs-surface-hover); }
/* `.new-topic` and `.sub-list` are deliberately bare (#2444). The first wraps a /* `.new-topic` is deliberately bare (#2444): it wraps a button-or-form whose
button-or-form whose children style themselves; the second is a `<ul>`, and children style themselves, and namespaces the descendant rule below. */
the bare `ul` rule above already gives it list-style, padding and margin —
a base a class-name check cannot see, since it comes from an element
selector. Both namespace descendant rules and assume nothing about layout. */
.new-topic input { .new-topic input {
width: 100%; margin-bottom: 0.5rem; width: 100%; margin-bottom: 0.5rem;
background: var(--fs-surface-page); color: inherit; background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px; border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.5rem; padding: 0.5rem;
} }
.subscriptions {
margin-top: 2rem;
border-top: 1px solid var(--fs-border-color);
padding-top: 1rem;
}
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
.sub-list li { cursor: default; }
.sub-list label { display: flex; gap: 0.5rem; align-items: center; cursor: pointer; }
button { cursor: pointer; } button { cursor: pointer; }
</style> </style>
+15
View File
@@ -137,6 +137,20 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
return rule; return rule;
} }
/** After a move (milestone 414): take the rule out of whichever topic list
* held it, and into its new topic's list if that one is loaded. A rule moved
* onto a project belongs to no topic list at all. */
function placeMovedRule(rule: Rule) {
if (currentRule.value?.id === rule.id) currentRule.value = rule;
for (const tid of Object.keys(rulesByTopic.value)) {
const key = Number(tid);
rulesByTopic.value[key] = rulesByTopic.value[key].filter((r) => r.id !== rule.id);
}
if (rule.topic_id !== null && rulesByTopic.value[rule.topic_id]) {
rulesByTopic.value[rule.topic_id].push(toHeader(rule));
}
}
async function relateRules( async function relateRules(
fromRuleId: number, fromRuleId: number,
data: { to_rule_id: number; kind: api.RuleRelationKind; note?: string }, data: { to_rule_id: number; kind: api.RuleRelationKind; note?: string },
@@ -198,6 +212,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
return { return {
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading, rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
placeMovedRule,
fetchRulebooks, fetchTopics, fetchRules, fetchRule, fetchRulebooks, fetchTopics, fetchRules, fetchRule,
createRulebook, updateRulebook, deleteRulebook, createRulebook, updateRulebook, deleteRulebook,
createTopic, updateTopic, deleteTopic, createTopic, updateTopic, deleteTopic,
-1
View File
@@ -24,7 +24,6 @@ export interface StartPlanningResult {
topic_title: string; topic_title: string;
rulebook_title: string; rulebook_title: string;
}[]; }[];
subscribed_rulebooks: { id: number; title: string }[];
applicable_rules_truncated: boolean; applicable_rules_truncated: boolean;
project_goal: string; project_goal: string;
open_task_count: number; open_task_count: number;
-3
View File
@@ -716,9 +716,6 @@ async function confirmDelete() {
/> />
<p v-else-if="project.inception" class="inception-line"> <p v-else-if="project.inception" class="inception-line">
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }} Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
<template v-if="project.inception.choices.subscribe_rulebooks.length">
· subscribes {{ project.inception.choices.subscribe_rulebooks.length }}
</template>
· design system {{ project.inception.choices.design_system_id ? "#" + project.inception.choices.design_system_id : "none" }} · design system {{ project.inception.choices.design_system_id ? "#" + project.inception.choices.design_system_id : "none" }}
<template v-if="project.inception.choices.seed_systems"> · Systems seeded</template> <template v-if="project.inception.choices.seed_systems"> · Systems seeded</template>
</p> </p>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scribe", "name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).", "description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.09.15.0204", "version": "2026.09.15.1626",
"author": { "author": {
"name": "Bryan Van Deusen" "name": "Bryan Van Deusen"
}, },
+26 -21
View File
@@ -62,8 +62,10 @@ Two constraints on *how* that's achieved:
empty session is not evidence of an empty rulebook. Retrieval fires when empty session is not evidence of an empty rulebook. Retrieval fires when
something asks: before a consequential act, `search(content_type="rule")` on something asks: before a consequential act, `search(content_type="rule")` on
what you are about to do, and pull a record's full statement with what you are about to do, and pull a record's full statement with
`get_rule(id)` when it is about to bite. When a project is in scope, `get_rule(id)` when it is about to bite. When a project is in scope, pass
`enter_project(id)` also returns the rules bound to its areas. its `project_id`: the answer is then the global rules plus that project's
own, never another project's. `enter_project(id)` lists the project's own
rules by title.
**`kind` says how much force a record carries, and it is never something to **`kind` says how much force a record carries, and it is never something to
infer.** A **rule** must be followed: ignoring it breaks something or infer.** A **rule** must be followed: ignoring it breaks something or
@@ -243,40 +245,43 @@ bound — confine the session to it:
## Starting a project: decide what it inherits ## Starting a project: decide what it inherits
A project's inheritance is a **decision, not a default**. Before A project's inheritance is a **decision, not a default**. Before
`create_project`, ask the operator the three inception questions and pass the `create_project`, ask the operator the two inception questions and pass the
answers — never create a project bare by default: answers — never create a project bare by default:
- which rulebooks to **subscribe** (`list_rulebooks` shows them; default: none
— a rulebook binds a project only when it opts in) →
`subscribe_rulebooks=[...]`
- which **design system** its UI is built from (`list_design_systems`; or - which **design system** its UI is built from (`list_design_systems`; or
none) → `design_system_id=<id | -1>` none) → `design_system_id=<id | -1>`
- whether to **seed the standard starter Systems** so records can be tagged - whether to **seed the standard starter Systems** so records can be tagged
from day one → `seed_systems=true|false` from day one → `seed_systems=true|false`
Rules are not an inception question: a global rule already applies to every
project, and a project's own rules are written on it as they come up.
If `enter_project` returns an `inception` key, the project was never decided If `enter_project` returns an `inception` key, the project was never decided
(it inherits its defaults silently): raise that ask once, with the defaults it (it inherits its defaults silently): raise that ask once, with the defaults it
carries, then `decide_project_inception(project_id, …)`. Existing projects carries, then `decide_project_inception(project_id, …)`. Existing projects
were stamped "legacy" (inherit-all) and do not ask; any project can be were stamped "legacy" and do not ask; any project can be re-decided. The
re-decided. The rules/design-system/Systems tools still work one at a time — design-system and Systems tools still work one at a time — inception is the
inception is the moment they are decided together, and the record of why. moment they are decided together, and the record of why.
## Where a new rule goes ## Where a new rule goes
When codifying a rule, pick its home by **who it should bind** — and keep A rule has one of two homes, and the home IS its reach:
shared homes general:
- **Rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a reusable, - **Global** — in a rulebook (`create_rule` into a topic). It applies in every
*themed* module of general rules that binds the projects which opt in (e.g. a project, and reaches a session wherever the work matches it. A rulebook is a
review checklist → every service). Themed, but project-agnostic. *themed* grouping of general rules (e.g. a review checklist), not a list of
- **Project rule** (`create_project_rule`) — anything specific to one project projects it binds — there is no subscribing a project to one.
(its files, paths, quirks). - **Project** (`create_project_rule`) — anything specific to one project (its
files, paths, quirks). It reaches only that project's sessions.
There used to be a third home — an `always_on` rulebook that bound every Names one project's specifics → project rule; a standard that holds wherever
project automatically. It is gone: subscription is the only reach a rulebook the kind of work it describes happens → global. Never put project-specific
has. Names one project's specifics → project rule; anything a category of detail in a rulebook — it would reach every other project. A project that
projects shares → rulebook. Never put project-specific detail in a rulebook — departs from a global rule writes its own and links it with
it leaks to every other project that subscribes. `relate_rules(kind="overrides")`, which says why. A rule that turns out to be
in the wrong home — a project rule that holds everywhere, a global one only a
single project needs — moves with `move_rule`, which keeps its id, history,
areas and edges. Propose the move and make it on a yes.
**Whichever home it gets, a rule needs `when_to_apply`.** It is the only thing **Whichever home it gets, a rule needs `when_to_apply`.** It is the only thing
that decides whether the rule is ever seen: nothing is preloaded, so a rule that decides whether the rule is ever seen: nothing is preloaded, so a rule
+48 -5
View File
@@ -72,9 +72,10 @@ shared:true records are another user's suggestion, not settled practice.
# what you hand to something you don't fully trust — a dashboard, a CI job, a # what you hand to something you don't fully trust — a dashboard, a CI job, a
# shared integration — and a boundary inferred from a naming convention grants # shared integration — and a boundary inferred from a naming convention grants
# access to whatever a future author happens to call `get_*`. Enumerating it is # access to whatever a future author happens to call `get_*`. Enumerating it is
# the point; staleness is the cost, and test_mcp_auth covers that (a read-shaped # the point; staleness is the cost, and test_mcp_auth covers that: EVERY
# tool must appear here or in _DELIBERATELY_WRITE_SCOPED below, so adding one # registered tool must appear in exactly one of _READ_ONLY_TOOLS, _WRITE_TOOLS or
# forces a decision instead of silently denying it). # _DELIBERATELY_WRITE_SCOPED below, so adding one forces a decision instead of
# silently denying it — whatever the tool is called (#3191).
# #
# Membership means "reads the operator's data and mutates none of it". Several # Membership means "reads the operator's data and mutates none of it". Several
# getters record a retrieval event via record_pulled; that is telemetry about # getters record a retrieval event via record_pulled; that is telemetry about
@@ -121,9 +122,51 @@ _READ_ONLY_TOOLS = frozenset({
# is the write, and it is deliberately NOT here. Spelled out for # is the write, and it is deliberately NOT here. Spelled out for
# retrieval_telemetry's reason: `notes_due_for_verification` matches none of # retrieval_telemetry's reason: `notes_due_for_verification` matches none of
# the prefixes the completeness test derives from, so nothing would have # the prefixes the completeness test derives from, so nothing would have
# prompted this decision. `rules_due_for_verification` is in the same # prompted this decision.
# position and is NOT listed — see #3191.
"notes_due_for_verification", "notes_due_for_verification",
# Its rule twin and a rule's edit history (milestones 312 and 323). Both
# pure reads, and both sat unlisted — so a read key was refused them — for
# the same reason: no read prefix, back when the completeness test only
# looked at names that had one (#3191). rule_history records a pull the way
# the getters above do.
"rules_due_for_verification", "rule_history",
})
# Every tool that WRITES, by name. Nothing reads this set at runtime — a tool
# absent from _READ_ONLY_TOOLS is already denied to a read key. It exists so the
# classification is total: test_mcp_auth requires every registered tool to sit
# in exactly one of the three sets, which is what makes forgetting impossible
# rather than merely unlikely. Before #3191 the test only asked about tools whose
# names looked like reads, and two reads with other names were denied for weeks.
_WRITE_TOOLS = frozenset({
# notes, tasks, planning
"create_note", "update_note", "delete_note",
"create_task", "update_task", "delete_task", "add_task_log",
"create_records", "start_planning",
"create_milestone", "update_milestone", "delete_milestone",
"mark_note_verified",
# projects, Systems, repos
"create_project", "update_project", "delete_project", "decide_project_inception",
"create_system", "update_system", "delete_system", "map_system_to_canonical",
"bind_repo", "unbind_repo",
# snippets, processes, the shape ledger
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
"merge_snippets", "unmerge_snippet",
"create_process", "update_process", "delete_process",
"classify_shapes", "classify_shapes_by_rule", "confirm_shape_proposals",
"refresh_pattern_coverage",
# design systems
"create_design_system", "update_design_system", "delete_design_system",
"create_design_token", "update_design_token", "delete_design_token",
"set_project_design_system",
# rules
"create_rulebook", "update_rulebook", "delete_rulebook",
"create_topic", "update_topic", "delete_topic",
"create_rule", "create_project_rule", "update_rule", "move_rule", "delete_rule",
"create_preference", "update_preference",
"relate_rules", "unrelate_rules", "mark_rule_verified",
# trash
"restore", "purge_trash",
}) })
# Read-SHAPED tools that must NOT be reachable with a read key — a getter that # Read-SHAPED tools that must NOT be reachable with a read key — a getter that
+1 -1
View File
@@ -43,7 +43,7 @@ async def get_milestone(milestone_id: int) -> dict:
rules surface again on recall. rules surface again on recall.
Returns: milestone (incl. body), progress, steps (its tasks ordered by Returns: milestone (incl. body), progress, steps (its tasks ordered by
status then update), and applicable_rules / subscribed_rulebooks. status then update), and applicable_rules / project_rules.
""" """
uid = current_user_id() uid = current_user_id()
milestone = await milestones_svc.get_milestone(uid, milestone_id) milestone = await milestones_svc.get_milestone(uid, milestone_id)
+2 -2
View File
@@ -384,8 +384,8 @@ async def notes_due_for_verification(
thing there is. 0 = no age filter. thing there is. 0 = no age filter.
project_id: narrow to one project. 0 = every project. Unlike the rules project_id: narrow to one project. 0 = every project. Unlike the rules
sweep, this filter is safe: a note belongs to at most one project sweep, this filter is safe: a note belongs to at most one project
outright, with none of the subscription paths that would make a outright, where a project is bound by every GLOBAL rule as well as
project filter UNDER-report a rule. its own — so a project filter would UNDER-report rules.
never_only: only notes nobody has ever verified. never_only: only notes nobody has ever verified.
""" """
uid = current_user_id() uid = current_user_id()
+26 -41
View File
@@ -66,7 +66,7 @@ async def enter_project(project_id: int) -> dict:
project_id: The project to enter. project_id: The project to enter.
Returns a dict with keys: project, milestone_summary, open_tasks, systems, Returns a dict with keys: project, milestone_summary, open_tasks, systems,
design_system, project_rules, subscribed_rulebooks, pattern_coverage — design_system, project_rules, pattern_coverage —
plus milestone_summary_omitted, inception and systems_bootstrap, each plus milestone_summary_omitted, inception and systems_bootstrap, each
present only when it applies (see below). present only when it applies (see below).
@@ -83,11 +83,12 @@ async def enter_project(project_id: int) -> dict:
with or without a milestone. A work-log counts as touching its task. Each with or without a milestone. A work-log counts as touching its task. Each
names its milestone. list_tasks has the rest. names its milestone. list_tasks has the rest.
`project_rules` lists the project's own rules by id and title, and `project_rules` lists the project's own rules by id and title. Global
`subscribed_rulebooks` the rulebooks it draws on. A rule reaches you in rules (the ones in rulebooks) apply here too and are not listed. Any rule
full when your work matches it; get_rule(id) reads one, and reaches you in full when your work matches it — a global one or one of
search(content_type="rule") asks whether one covers what you are about this project's, never another project's; get_rule(id) reads one, and
to do. search(content_type="rule", project_id=...) asks whether one covers what
you are about to do.
`pattern_coverage` (usually null) is the shape-accounting line — how many `pattern_coverage` (usually null) is the shape-accounting line — how many
of the bound repo's extracted shapes carry a classification against canon of the bound repo's extracted shapes carry a classification against canon
@@ -109,7 +110,7 @@ async def enter_project(project_id: int) -> dict:
`inception` (milestone 297) appears ONLY when the project is yours and `inception` (milestone 297) appears ONLY when the project is yours and
nobody has decided what it inherits: it carries the current defaults nobody has decided what it inherits: it carries the current defaults
(the rulebooks it could subscribe to, design system, Systems), what to ask the (design system, Systems), what to ask the
operator — once — and the decide_project_inception call that answers it; operator — once — and the decide_project_inception call that answers it;
it repeats on every enter until a decision is recorded. it repeats on every enter until a decision is recorded.
@@ -178,8 +179,7 @@ async def enter_project(project_id: int) -> dict:
) )
# The inception ask (milestone 297): a project nobody has decided on # The inception ask (milestone 297): a project nobody has decided on
# inherits nothing, silently — no rulebook subscriptions, no design system, # inherits nothing, silently — no design system, no Systems. Owner-only (deciding is the owner's), and only until a
# no Systems. Owner-only (deciding is the owner's), and only until a
# decision is recorded; the key is ABSENT otherwise (#2483). # decision is recorded; the key is ABSENT otherwise (#2483).
inception_ask = None inception_ask = None
if project.user_id == uid and not inception_svc.is_decided(project): if project.user_id == uid and not inception_svc.is_decided(project):
@@ -268,8 +268,9 @@ async def get_project(project_id: int) -> dict:
Returns full project fields, a milestone_summary list (every milestone, Returns full project fields, a milestone_summary list (every milestone,
with description and progress but no plan body; get_milestone reads a with description and progress but no plan body; get_milestone reads a
plan), and the rulebook-applicable_rules / subscribed_rulebooks pair the plan), the project's own rules (project_rules), and applicable_rules: the
assistant should consult when working on this project. global rules tagged to an area this project works in. Every other global
rule applies too and arrives by retrieval when the work matches it.
""" """
uid = current_user_id() uid = current_user_id()
project = await projects_svc.get_project(uid, project_id) project = await projects_svc.get_project(uid, project_id)
@@ -285,18 +286,14 @@ async def get_project(project_id: int) -> dict:
return data return data
def _inception_choices( def _inception_choices(design_system_id, seed_systems) -> dict | None:
subscribe_rulebooks, design_system_id, seed_systems,
) -> dict | None:
"""The tool args → an inception choices object, or None when no inception """The tool args → an inception choices object, or None when no inception
arg was given at all (a bare create stays undecided and enter_project arg was given at all (a bare create stays undecided and enter_project
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
system.""" system."""
if (subscribe_rulebooks is None if not design_system_id and seed_systems is None:
and not design_system_id and seed_systems is None):
return None return None
return { return {
"subscribe_rulebooks": list(subscribe_rulebooks or []),
"design_system_id": None if design_system_id in (0, -1) else design_system_id, "design_system_id": None if design_system_id in (0, -1) else design_system_id,
"seed_systems": bool(seed_systems), "seed_systems": bool(seed_systems),
} }
@@ -308,18 +305,18 @@ async def create_project(
goal: str = "", goal: str = "",
status: str = "active", status: str = "active",
color: str = "", color: str = "",
subscribe_rulebooks: list[int] | None = None,
design_system_id: int = 0, design_system_id: int = 0,
seed_systems: bool | None = None, seed_systems: bool | None = None,
) -> dict: ) -> dict:
"""Create a new project in Scribe — and decide what it inherits. """Create a new project in Scribe — and decide what it inherits.
A project's inheritance is a decision, not a default (milestone 297): A project's inheritance is a decision, not a default (milestone 297):
before calling, ask the operator the four inception questions and pass before calling, ask the operator the two inception questions and pass
the answers; a project created without any of them is UNDECIDED and the answers; a project created without either is UNDECIDED and
enter_project will ask until decide_project_inception records it. enter_project will ask until decide_project_inception records it.
Defaults if nobody decides: no rulebook subscriptions, no design system, Defaults if nobody decides: no design system, no Systems. Rules are not
no Systems. an inception question: global rules (in rulebooks) apply to every
project, and a project's own rules are written with create_project_rule.
Args: Args:
title: Project name (required). title: Project name (required).
@@ -327,10 +324,6 @@ async def create_project(
goal: The desired outcome or definition of done for the project. goal: The desired outcome or definition of done for the project.
status: one of active (default), paused, completed, archived. status: one of active (default), paused, completed, archived.
color: Optional hex colour for the project card (e.g. "#6366f1"). color: Optional hex colour for the project card (e.g. "#6366f1").
subscribe_rulebooks: rulebook ids this project opts into.
Subscription is the only way a rulebook binds a project, so a
rulebook left out simply does not apply. list_rulebooks shows
which exist.
design_system_id: the design system this project's UI is built from design_system_id: the design system this project's UI is built from
(list_design_systems); -1 = explicitly none; 0 = not stated. (list_design_systems); -1 = explicitly none; 0 = not stated.
seed_systems: true mints the standard starter Systems (CI & Release, seed_systems: true mints the standard starter Systems (CI & Release,
@@ -346,9 +339,7 @@ async def create_project(
color=color or None, color=color or None,
) )
data = project.to_dict() data = project.to_dict()
choices = _inception_choices( choices = _inception_choices(design_system_id, seed_systems)
subscribe_rulebooks, design_system_id, seed_systems,
)
if choices is not None: if choices is not None:
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp") decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
data["inception"] = decided["inception"] data["inception"] = decided["inception"]
@@ -364,7 +355,6 @@ async def create_project(
async def decide_project_inception( async def decide_project_inception(
project_id: int, project_id: int,
subscribe_rulebooks: list[int] | None = None,
design_system_id: int = 0, design_system_id: int = 0,
seed_systems: bool | None = None, seed_systems: bool | None = None,
) -> dict: ) -> dict:
@@ -372,22 +362,17 @@ async def decide_project_inception(
or re-decide later (milestone 297). or re-decide later (milestone 297).
Owner-only. Applies the effects through the ordinary tools' paths — Owner-only. Applies the effects through the ordinary tools' paths —
subscribe_project_to_rulebook, set_project_design_system and the standard Systems seed — and writes the
set_project_design_system, the standard Systems seed — and writes the
decision on the project last, so get_project/enter_project can say why decision on the project last, so get_project/enter_project can say why
the project has the rules, design and Systems it has. Re-deciding is the project has the design and Systems it has. Re-deciding replaces the
additive for subscriptions (use design system and never re-seeds Systems a project already has.
unsubscribe_project_from_rulebook to undo one), replaces the design
system, and never re-seeds Systems a project already has.
Args: as create_project's inception args. Passing nothing records a Args: as create_project's inception args. Passing nothing records a
decision to take nothing (no subscriptions, no design system, no seed) — decision to take nothing (no design system, no seed) — a valid answer,
a valid answer, stated. stated.
""" """
uid = current_user_id() uid = current_user_id()
choices = _inception_choices( choices = _inception_choices(design_system_id, seed_systems) or {}
subscribe_rulebooks, design_system_id, seed_systems,
) or {}
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp") decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
return {"project_id": project_id, **decided} return {"project_id": project_id, **decided}
+63 -122
View File
@@ -1,6 +1,6 @@
"""MCP tools for the Scribe Rulebook system. """MCP tools for the Scribe Rulebook system.
Rulebook / topic / rule CRUD, subscription management, and the rule-to-rule Rulebook / topic / rule CRUD and the rule-to-rule
edges. Thin wrappers over services/rulebooks.py — ownership is enforced in the edges. Thin wrappers over services/rulebooks.py — ownership is enforced in the
service, and the record shape comes from rule_brief / rule_detail there rather service, and the record shape comes from rule_brief / rule_detail there rather
than being rebuilt here. than being rebuilt here.
@@ -46,16 +46,14 @@ async def get_rulebook(rulebook_id: int) -> dict:
async def create_rulebook(title: str, description: str = "") -> dict: async def create_rulebook(title: str, description: str = "") -> dict:
"""Create a new rulebook (a shared, reusable module of general rules). """Create a new rulebook (a themed grouping of GLOBAL rules).
A rulebook reaches a project ONE way: the project subscribes to it A rule in a rulebook is global: it applies in every project its owner works
(subscribe_project_to_rulebook). There was a second until milestone 394 — on, and reaches a session by retrieval when the work makes it relevant
an `always_on` flag that bound every project automatically — and it is (milestone 414). There is no subscribing a project to a rulebook, and no
gone with the tier it belonged to. Opt-in is now the whole model, so a muting one per project — that machinery is gone. So a rulebook's rules must
rulebook binds what asked for it and nothing else. stay general, agnostic to any single project. Project-specific rules go in
create_project_rule.
A rulebook is SHARED, so its rules must stay general — agnostic
to any single project. Project-specific rules go in create_project_rule.
Args: Args:
title: Rulebook name. title: Rulebook name.
@@ -210,10 +208,12 @@ async def list_rules(
Args: Args:
rulebook_id: 0 = no filter; positive = restrict to that rulebook. rulebook_id: 0 = no filter; positive = restrict to that rulebook.
topic_id: 0 = no filter; positive = restrict to that topic. topic_id: 0 = no filter; positive = restrict to that topic.
project_id: 0 = no filter; positive = restrict to rules applicable project_id: 0 = no filter; positive = that project's OWN rules.
to that project (via its rulebook subscriptions). Global rules apply to every project, so they are listed by
rulebook or topic (or unfiltered), not under each project.
All filters are AND-combined; ownership-scoped. rulebook_id and topic_id AND-combine; project_id lists a project's rules
on its own. Ownership-scoped.
""" """
uid = current_user_id() uid = current_user_id()
rows = await rulebooks_svc.list_rules( rows = await rulebooks_svc.list_rules(
@@ -308,12 +308,12 @@ async def create_rule(
and let the answer stand; re-raising a declined proposal argues a rule and let the answer stand; re-raising a declined proposal argues a rule
into existence, which is the thing this whole loop exists to prevent. into existence, which is the thing this whole loop exists to prevent.
A rulebook rule is shared by every project subscribed to the rulebook, so A rulebook rule is GLOBAL — it applies in every project — so it must read as
it must read as a general standard a general standard: never pin it to one project's files, paths, or quirks.
never pin it to one project's files, paths, or quirks. For a rule that For a rule that applies to a single project only, use create_project_rule
applies to a single project only, use create_project_rule instead (no instead (no rulebook+topic ceremony). A standard only some projects share
rulebook+topic ceremony). If it's a standard a CATEGORY of projects shares, is still global in reach; write it so it names the kind of work it is
put it in a rulebook for that category and subscribe those projects to it. about, and it arrives where that work happens.
Write it general WITHOUT hedging for the exceptions. A project that needs Write it general WITHOUT hedging for the exceptions. A project that needs
to strengthen, narrow or replace this rule writes its own and links it to strengthen, narrow or replace this rule writes its own and links it
@@ -334,7 +334,7 @@ async def create_rule(
own, and fixing that breakage doesn't require the neighbouring clauses, it own, and fixing that breakage doesn't require the neighbouring clauses, it
is a separate rule. Rules that FAIL TOGETHER get linked with relate_rules is a separate rule. Rules that FAIL TOGETHER get linked with relate_rules
(kind="co_surfaces"), never merged into one row: a merged rule cannot be (kind="co_surfaces"), never merged into one row: a merged rule cannot be
cited, surfaced or suppressed a clause at a time, and it grows without cited or surfaced a clause at a time, and it grows without
limit because adding to it is always cheaper than adding a rule. limit because adding to it is always cheaper than adding a rule.
Args: Args:
@@ -420,11 +420,11 @@ async def create_project_rule(
Use this for anything SPECIFIC to one project — its files, paths, layout, Use this for anything SPECIFIC to one project — its files, paths, layout,
or quirks. This is the correct home for the project-specific detail that or quirks. This is the correct home for the project-specific detail that
must NOT go into a shared rulebook (where it would leak to every other must NOT go into a rulebook (where it would be global, and reach every
project that gets the rulebook). General standards belong in a rulebook other project). General standards belong in a rulebook instead
instead (create_rule). It bypasses the Rulebook -> Topic -> Rule ceremony; (create_rule). It bypasses the Rulebook -> Topic -> Rule ceremony; the
the rule is returned in get_project's applicable_rules (under rule surfaces by retrieval in this project's sessions only, and is listed
project_rules) and in list_rules(project_id=...). in get_project's project_rules and in list_rules(project_id=...).
PROPOSE, THEN WRITE ON A YES — create_rule's opening carries the whole PROPOSE, THEN WRITE ON A YES — create_rule's opening carries the whole
loop: the four things a proposal states (what it would require, its loop: the four things a proposal states (what it would require, its
@@ -862,6 +862,38 @@ async def rule_history(rule_id: int, version_id: int = 0) -> dict:
} }
async def move_rule(rule_id: int, topic_id: int = 0, project_id: int = 0) -> dict:
"""Move a rule to a new home, keeping its id, history, areas and edges.
A rule's home IS its reach. In a rulebook topic it is GLOBAL: it applies in
every project and reaches any session whose work matches it. On a project
it applies to that project only. So this is how a project rule that turns
out to hold everywhere becomes global (pass `topic_id`), and how a global
rule that only one project needs becomes that project's (pass
`project_id`). Name exactly one.
Reach for this INSTEAD of recreating the rule in the other home and
deleting the original: that loses the id every record cites it by, its
edit history, its area tags and its relations.
A move is a decision about where a rule binds, so propose it and move on a
yes, the way create_rule proposes a new rule — and record why where the
decision lives (a task or note). The rule's history does not record a
move: it holds what the rule SAID, and a move changes none of that.
Refused with a message when: neither or both destinations are named, the
destination is not yours, the rule is already there, or the topic already
has a rule with this title (rename one first).
"""
uid = current_user_id()
rule = await rulebooks_svc.move_rule(
rule_id, uid, topic_id=topic_id, project_id=project_id,
)
if rule is None:
raise ValueError(f"rule {rule_id} not found")
return await rulebooks_svc.rule_detail(uid, rule)
async def delete_rule(rule_id: int, confirmed: bool = False) -> dict: async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
"""Move a rule to the trash (recoverable). Requires confirmed=True.""" """Move a rule to the trash (recoverable). Requires confirmed=True."""
uid = current_user_id() uid = current_user_id()
@@ -882,94 +914,6 @@ async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
f"Restore with restore('{batch}')."} f"Restore with restore('{batch}')."}
# ── Subscriptions ──────────────────────────────────────────────────────
async def subscribe_project_to_rulebook(
project_id: int, rulebook_id: int,
) -> dict:
"""Subscribe a project to a rulebook — its rules then bind that project.
Subscription is the ONLY path for a rulebook (milestone 394): a reusable,
themed module of GENERAL rules shared across the projects that subscribe.
Subscribe a project because it fits the rulebook's theme (e.g. a visual app
-> the design-system rulebook), not to host rules about this one project —
those belong in create_project_rule.
"""
uid = current_user_id()
await rulebooks_svc.subscribe_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
)
return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": True}
async def unsubscribe_project_from_rulebook(
project_id: int, rulebook_id: int,
) -> dict:
"""Remove a project's subscription to a rulebook."""
uid = current_user_id()
await rulebooks_svc.unsubscribe_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
)
return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": False}
# ── Suppressions — project-level mute of rulebook rules / topics ────────
async def suppress_rule_for_project(
project_id: int, rule_id: int,
) -> dict:
"""Mute a single rulebook rule for one project.
The rule stays in its rulebook for other projects; only this project
skips it. Idempotent. Use unsuppress_rule_for_project to re-enable.
Project-scoped rules (create_project_rule) are NOT suppressible — delete
them with delete_rule instead.
"""
uid = current_user_id()
await rulebooks_svc.suppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=uid,
)
return {"project_id": project_id, "rule_id": rule_id, "suppressed": True}
async def unsuppress_rule_for_project(
project_id: int, rule_id: int,
) -> dict:
"""Re-enable a previously-suppressed rule for one project. Idempotent."""
uid = current_user_id()
await rulebooks_svc.unsuppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=uid,
)
return {"project_id": project_id, "rule_id": rule_id, "suppressed": False}
async def suppress_topic_for_project(
project_id: int, topic_id: int,
) -> dict:
"""Mute every rule under a topic for one project.
Equivalent to suppressing each rule in the topic individually, but
auto-includes new rules added to the topic later. Idempotent.
"""
uid = current_user_id()
await rulebooks_svc.suppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=uid,
)
return {"project_id": project_id, "topic_id": topic_id, "suppressed": True}
async def unsuppress_topic_for_project(
project_id: int, topic_id: int,
) -> dict:
"""Re-enable a previously-suppressed topic for one project. Idempotent."""
uid = current_user_id()
await rulebooks_svc.unsuppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=uid,
)
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
async def relate_rules( async def relate_rules(
from_rule_id: int, to_rule_id: int, kind: str, note: str = "", from_rule_id: int, to_rule_id: int, kind: str, note: str = "",
) -> dict: ) -> dict:
@@ -981,7 +925,7 @@ async def relate_rules(
together. Use it when you are tempted to fold one rule into another together. Use it when you are tempted to fold one rule into another
because "either could surface without the other": that instinct is because "either could surface without the other": that instinct is
right and merging is the wrong fix, because a merged rule cannot be right and merging is the wrong fix, because a merged rule cannot be
cited, suppressed or surfaced a clause at a time. Symmetric — draw it cited or surfaced a clause at a time. Symmetric — draw it
once, it reads from both ends. once, it reads from both ends.
- kind="overrides" — this rule supersedes that one for its scope. Use it - kind="overrides" — this rule supersedes that one for its scope. Use it
when a project rule is stricter than, or replaces, an inherited one, when a project rule is stricter than, or replaces, an inherited one,
@@ -1046,10 +990,10 @@ async def rules_due_for_verification(
Never-checked rules always qualify. 0 = no age filter. Never-checked rules always qualify. 0 = no age filter.
never_only: only rules nobody has ever verified. never_only: only rules nobody has ever verified.
NOT filterable by project, deliberately: a project reaches rules through NOT filterable by project, deliberately: a project is bound by its own
project scope and rulebook subscriptions, and a filter that missed one of rules AND every global rule, and a filter that dropped the global ones
those paths would UNDER-report — which is the would UNDER-report — which is the exact failure this whole surface exists
exact failure this whole surface exists to prevent. Read the whole list. to prevent. Read the whole list.
""" """
uid = current_user_id() uid = current_user_id()
rules = await rulebooks_svc.rules_due_for_verification( rules = await rulebooks_svc.rules_due_for_verification(
@@ -1107,12 +1051,9 @@ def register(mcp) -> None:
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook, list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
list_topics, create_topic, update_topic, delete_topic, list_topics, create_topic, update_topic, delete_topic,
list_rules, get_rule, list_rules, get_rule,
create_rule, create_project_rule, update_rule, delete_rule, create_rule, create_project_rule, update_rule, move_rule, delete_rule,
create_preference, update_preference, create_preference, update_preference,
relate_rules, unrelate_rules, relate_rules, unrelate_rules,
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
suppress_rule_for_project, unsuppress_rule_for_project,
suppress_topic_for_project, unsuppress_topic_for_project,
rules_due_for_verification, mark_rule_verified, rules_due_for_verification, mark_rule_verified,
rule_history, rule_history,
): ):
+15 -6
View File
@@ -18,7 +18,7 @@ from scribe.services import rulebooks as rulebooks_svc
from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summary from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summary
async def _search_rules(uid: int, q: str, limit: int) -> dict: async def _search_rules(uid: int, q: str, limit: int, project_id: int) -> dict:
"""Rules by meaning — a separate result shape because a rule IS different. """Rules by meaning — a separate result shape because a rule IS different.
A rule hit carries `why` and `how_to_apply`: they are the operational half A rule hit carries `why` and `how_to_apply`: they are the operational half
@@ -29,10 +29,17 @@ async def _search_rules(uid: int, q: str, limit: int) -> dict:
the moment someone is about to act on a rule, and "this asserts a fact the moment someone is about to act on a rule, and "this asserts a fact
nobody has confirmed" is part of what the rule says. nobody has confirmed" is part of what the rule says.
Rules are not project-scoped the way notes are (a family rule belongs to no `project_id` scopes the way it does for notes, with one difference: a
project), so `project_id` and `system_id` do not apply here. GLOBAL rule (one in a rulebook) belongs to no project and applies in every
one, so a scoped search returns global rules plus that project's own.
Without a project it asks the whole rulebook — every rule, whatever its
home — because that is the question an unscoped "is there a rule about
this" is asking. `system_id` does not apply to rules.
""" """
raw = await semantic_search_rules(uid, q, limit=limit) if project_id:
raw = await semantic_search_rules(uid, q, limit=limit, project_id=project_id)
else:
raw = await semantic_search_rules(uid, q, limit=limit, everywhere=True)
return { return {
"results": [ "results": [
{ {
@@ -84,7 +91,9 @@ async def search(
Reach for 'rule' when you want to know whether a standing Reach for 'rule' when you want to know whether a standing
instruction covers something: "is there a rule about release instruction covers something: "is there a rule about release
tagging?". A hit carries the rule's `why` and `how_to_apply`, tagging?". A hit carries the rule's `why` and `how_to_apply`,
which the session-start payload does not. which the session-start payload does not. With a project_id,
rules come back as the global rules plus that project's own;
with 0, every rule in the rulebook.
limit: maximum number of results (1-50). limit: maximum number of results (1-50).
project_id: Scope results to one project. PASS THE ACTIVE PROJECT'S ID project_id: Scope results to one project. PASS THE ACTIVE PROJECT'S ID
whenever a project is in scope (the one you entered with whenever a project is in scope (the one you entered with
@@ -108,7 +117,7 @@ async def search(
uid = current_user_id() uid = current_user_id()
limit = max(1, min(limit, 50)) limit = max(1, min(limit, 50))
if content_type == "rule": if content_type == "rule":
return await _search_rules(uid, q, limit) return await _search_rules(uid, q, limit, project_id)
is_task = {"note": False, "task": True}.get(content_type) # None => any is_task = {"note": False, "task": True}.get(content_type) # None => any
t0 = time.perf_counter() t0 = time.perf_counter()
report: dict = {} report: dict = {}
+2 -3
View File
@@ -81,9 +81,8 @@ async def get_task(task_id: int) -> dict:
(the areas this task is filed under; read a subsystem's whole pile with (the areas this task is filed under; read a subsystem's whole pile with
list_system_records) or, for an untagged project task, the `systems_hint` list_system_records) or, for an untagged project task, the `systems_hint`
question. For legacy question. For legacy
kind=plan tasks, the response also includes applicable_rules + kind=plan tasks, the response also includes the project's applicable_rules
subscribed_rulebooks from the task's project's rulebook subscriptions (new and project_rules (new plans are milestones — use get_milestone for those).
plans are milestones — use get_milestone for those).
A task another user shared with you also carries `shared`, `owner` and A task another user shared with you also carries `shared`, `owner` and
`permission` — it's their work item, not one you took on. `permission` — it's their work item, not one you took on.
+1 -2
View File
@@ -44,8 +44,7 @@ from scribe.models.user_profile import UserProfile # noqa: E402, F401
# Imported before rulebook: rule_systems foreign-keys canonical_systems. # Imported before rulebook: rule_systems foreign-keys canonical_systems.
from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401 from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401
from scribe.models.rulebook import ( # noqa: E402, F401 from scribe.models.rulebook import ( # noqa: E402, F401
Rulebook, RulebookTopic, Rule, RuleRelation, project_rulebook_subscriptions, Rulebook, RulebookTopic, Rule, RuleRelation, rule_systems,
rule_systems,
) )
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401 from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401 from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
+4 -5
View File
@@ -39,11 +39,10 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
) )
# The inception record (milestone 297): what this project was decided to # The inception record (milestone 297): what this project was decided to
# inherit, when, and through which door — {decided_at, decided_by, via, # inherit, when, and through which door — {decided_at, decided_by, via,
# choices: {subscribe_rulebooks, # choices: {design_system_id, seed_systems}}. NULL means nobody has
# design_system_id, seed_systems}}. NULL means nobody has decided yet, # decided yet, and enter_project asks; the effects themselves live in
# and enter_project asks; the effects themselves live in the subscription # design_system_id and the project's Systems — this is the WHY, kept so
# / exclusion tables, design_system_id and the project's Systems — this is # later surfaces can say it. See services/inception.py.
# the WHY, kept so later surfaces can say it. See services/inception.py.
inception: Mapped[dict | None] = mapped_column(JSONB, nullable=True) inception: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
def to_dict(self) -> dict: def to_dict(self) -> dict:
+7 -33
View File
@@ -236,36 +236,10 @@ class RuleRelation(Base, CreatedAtMixin):
} }
# Pure many-to-many — no model class, just the join table. # `project_rulebook_subscriptions`, `project_rule_suppressions` and
project_rulebook_subscriptions = Table( # `project_topic_suppressions` lived here until milestone 414 (migration 0101).
"project_rulebook_subscriptions", # A rule's home is its scope now: a rule in a rulebook topic is global, a rule
Base.metadata, # on a project applies to that project, and retrieval reads that directly. A
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True), # subscription had stopped changing anything a session received, and a
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True), # suppression muted rules from a subscription. A project that departs from a
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)), # global rule writes a project rule with an `overrides` relation instead.
)
# Suppressions — let a project mute individual rules or whole topics from
# rulebooks it subscribes to, without unsubscribing the rulebook itself.
# FKs CASCADE so the row vanishes when its parent is removed.
project_rule_suppressions = Table(
"project_rule_suppressions",
Base.metadata,
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
# `project_rulebook_exclusions` lived here until milestone 394. It recorded a
# project's opt-out of a whole always-on rulebook — which only made sense
# while a rulebook could bind a project WITHOUT being asked. Subscription is
# now the only reach a rulebook has, so declining one is expressed by not
# subscribing, and there is nothing left to opt out of.
project_topic_suppressions = Table(
"project_topic_suppressions",
Base.metadata,
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
Column("topic_id", BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
+1 -2
View File
@@ -99,8 +99,7 @@ async def create_project_route():
@login_required @login_required
async def decide_inception_route(project_id: int): async def decide_inception_route(project_id: int):
"""Record (or re-record) what a project inherits — milestone 297. """Record (or re-record) what a project inherits — milestone 297.
Body: the choices object {subscribe_rulebooks, Body: the choices object {design_system_id, seed_systems}; owner-only."""
design_system_id, seed_systems}; owner-only."""
uid = get_current_user_id() uid = get_current_user_id()
data = await request.get_json() or {} data = await request.get_json() or {}
choices = data.get("choices", data) choices = data.get("choices", data)
+22 -79
View File
@@ -297,6 +297,27 @@ async def unrelate_rules(relation_id: int):
return "", 204 return "", 204
@rulebooks_bp.post("/rules/<int:rule_id>/move")
@login_required
async def move_rule(rule_id: int):
"""Give a rule a new home: {topic_id} makes it global, {project_id} makes
it that project's. The MCP twin is move_rule (rule 33: same names)."""
data = await request.get_json() or {}
uid = get_current_user_id()
try:
rule = await rulebooks_svc.move_rule(
rule_id, uid,
topic_id=int(data.get("topic_id") or 0),
project_id=int(data.get("project_id") or 0),
)
except (TypeError, ValueError) as exc:
msg = str(exc)
return jsonify({"error": msg}), 404 if "not found" in msg else 400
if rule is None:
return jsonify({"error": "rule not found"}), 404
return jsonify(await rulebooks_svc.rule_detail(uid, rule))
@rulebooks_bp.delete("/rules/<int:rule_id>") @rulebooks_bp.delete("/rules/<int:rule_id>")
@login_required @login_required
async def delete_rule(rule_id: int): async def delete_rule(rule_id: int):
@@ -305,37 +326,7 @@ async def delete_rule(rule_id: int):
return "", 204 return "", 204
# ── Subscriptions ────────────────────────────────────────────────────── # ── A project's rule listing ───────────────────────────────────────────
@rulebooks_bp.post("/projects/<int:project_id>/rulebook-subscriptions")
@login_required
async def subscribe_project(project_id: int):
data = await request.get_json() or {}
rulebook_id = data.get("rulebook_id")
if not rulebook_id:
return jsonify({"error": "rulebook_id is required"}), 400
try:
await rulebooks_svc.subscribe_project(
project_id=project_id, rulebook_id=int(rulebook_id), user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.delete(
"/projects/<int:project_id>/rulebook-subscriptions/<int:rulebook_id>"
)
@login_required
async def unsubscribe_project(project_id: int, rulebook_id: int):
try:
await rulebooks_svc.unsubscribe_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.get("/projects/<int:project_id>/rules") @rulebooks_bp.get("/projects/<int:project_id>/rules")
@login_required @login_required
@@ -346,54 +337,6 @@ async def get_project_rules(project_id: int):
return jsonify(result) return jsonify(result)
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
@login_required
async def suppress_project_rule(project_id: int, rule_id: int):
try:
await rulebooks_svc.suppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
@login_required
async def unsuppress_project_rule(project_id: int, rule_id: int):
try:
await rulebooks_svc.unsuppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
@login_required
async def suppress_project_topic(project_id: int, topic_id: int):
try:
await rulebooks_svc.suppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
@login_required
async def unsuppress_project_topic(project_id: int, topic_id: int):
try:
await rulebooks_svc.unsuppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/rules") @rulebooks_bp.post("/projects/<int:project_id>/rules")
@login_required @login_required
async def create_project_rule(project_id: int): async def create_project_rule(project_id: int):
+11 -89
View File
@@ -22,9 +22,6 @@ from scribe.models.rulebook import (
Rule, Rule,
Rulebook, Rulebook,
RulebookTopic, RulebookTopic,
project_rule_suppressions,
project_rulebook_subscriptions,
project_topic_suppressions,
) )
from scribe.models.setting import Setting from scribe.models.setting import Setting
from scribe.models.system import RecordSystem, System from scribe.models.system import RecordSystem, System
@@ -66,8 +63,12 @@ logger = logging.getLogger(__name__)
# (milestone 333). Carrying it is the WHOLE REASON the table is separate: the # (milestone 333). Carrying it is the WHOLE REASON the table is separate: the
# note importer maps note_id through note_id_map, so a rule id parked there # note importer maps note_id through note_id_map, so a rule id parked there
# would restore attached to whatever note took that number. # would restore attached to whatever note took that number.
# v15 (2026-09) dropped rulebook_subscriptions / rule_suppressions /
# topic_suppressions with their tables (milestone 414): a rule's scope is its
# home now. Older archives carrying those sections still restore — the keys are
# simply not read — as do the subscribe_rulebooks inception choices they hold.
# Bump when the serialized schema changes. # Bump when the serialized schema changes.
BACKUP_VERSION = 14 BACKUP_VERSION = 15
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED # Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is # below, these two lists must together account for the entire schema — which is
@@ -80,8 +81,6 @@ BACKUP_VERSION = 14
_BACKED_UP = [ _BACKED_UP = [
"users", "projects", "milestones", "notes", "task_logs", "note_drafts", "users", "projects", "milestones", "notes", "task_logs", "note_drafts",
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules", "note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
"project_rulebook_subscriptions", "project_rule_suppressions",
"project_topic_suppressions",
# v5 (2026-08): the five-year gap this list was written to stop. # v5 (2026-08): the five-year gap this list was written to stop.
"systems", "record_systems", "design_systems", "design_tokens", "systems", "record_systems", "design_systems", "design_tokens",
"note_usage_events", "repo_bindings", "note_supersessions", "note_usage_events", "repo_bindings", "note_supersessions",
@@ -234,18 +233,6 @@ def _d(val: str | None) -> date | None:
return date.fromisoformat(val) if val else None return date.fromisoformat(val) if val else None
def _subscription_rows(rows) -> list[dict]:
return [{"project_id": r.project_id, "rulebook_id": r.rulebook_id} for r in rows]
def _rule_suppression_rows(rows) -> list[dict]:
return [{"project_id": r.project_id, "rule_id": r.rule_id} for r in rows]
def _topic_suppression_rows(rows) -> list[dict]:
return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows]
# The v5 sections. Pure row-builders like the join-table helpers above, for the # The v5 sections. Pure row-builders like the join-table helpers above, for the
@@ -640,15 +627,6 @@ async def export_full_backup() -> dict:
rulebooks = (await session.execute(select(Rulebook))).scalars().all() rulebooks = (await session.execute(select(Rulebook))).scalars().all()
topics = (await session.execute(select(RulebookTopic))).scalars().all() topics = (await session.execute(select(RulebookTopic))).scalars().all()
rules = (await session.execute(select(Rule))).scalars().all() rules = (await session.execute(select(Rule))).scalars().all()
subscriptions = (await session.execute(
select(project_rulebook_subscriptions)
)).all()
rule_suppressions = (await session.execute(
select(project_rule_suppressions)
)).all()
topic_suppressions = (await session.execute(
select(project_topic_suppressions)
)).all()
return { return {
"version": BACKUP_VERSION, "version": BACKUP_VERSION,
@@ -671,9 +649,6 @@ async def export_full_backup() -> dict:
"rulebooks": _rulebook_rows(rulebooks), "rulebooks": _rulebook_rows(rulebooks),
"rulebook_topics": _topic_rows(topics), "rulebook_topics": _topic_rows(topics),
"rules": _rule_rows(rules), "rules": _rule_rows(rules),
"rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
"canonical_systems": _canonical_system_rows(canonical_systems), "canonical_systems": _canonical_system_rows(canonical_systems),
"rule_systems": _rule_system_rows(rule_system_rows), "rule_systems": _rule_system_rows(rule_system_rows),
"rule_relations": _rule_relation_rows(rule_relations), "rule_relations": _rule_relation_rows(rule_relations),
@@ -825,24 +800,6 @@ async def export_user_backup(user_id: int) -> dict:
RuleRelation.to_rule_id.in_(_rule_ids), RuleRelation.to_rule_id.in_(_rule_ids),
) )
)).scalars().all() if _rule_ids else [] )).scalars().all() if _rule_ids else []
if project_ids:
subscriptions = (await session.execute(
select(project_rulebook_subscriptions).where(
project_rulebook_subscriptions.c.project_id.in_(project_ids)
)
)).all()
rule_suppressions = (await session.execute(
select(project_rule_suppressions).where(
project_rule_suppressions.c.project_id.in_(project_ids)
)
)).all()
topic_suppressions = (await session.execute(
select(project_topic_suppressions).where(
project_topic_suppressions.c.project_id.in_(project_ids)
)
)).all()
else:
subscriptions = rule_suppressions = topic_suppressions = []
return { return {
"version": BACKUP_VERSION, "version": BACKUP_VERSION,
@@ -867,9 +824,6 @@ async def export_user_backup(user_id: int) -> dict:
"rulebooks": _rulebook_rows(rulebooks), "rulebooks": _rulebook_rows(rulebooks),
"rulebook_topics": _topic_rows(topics), "rulebook_topics": _topic_rows(topics),
"rules": _rule_rows(rules), "rules": _rule_rows(rules),
"rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
"canonical_systems": _canonical_system_rows(canonical_systems), "canonical_systems": _canonical_system_rows(canonical_systems),
"rule_systems": _rule_system_rows(rule_system_rows), "rule_systems": _rule_system_rows(rule_system_rows),
"rule_relations": _rule_relation_rows(rule_relations), "rule_relations": _rule_relation_rows(rule_relations),
@@ -1014,8 +968,6 @@ async def _restore_v2(data: dict) -> dict:
"users": 0, "projects": 0, "milestones": 0, "notes": 0, "users": 0, "projects": 0, "milestones": 0, "notes": 0,
"task_logs": 0, "note_drafts": 0, "note_versions": 0, "task_logs": 0, "note_drafts": 0, "note_versions": 0,
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0, "settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
"rulebook_subscriptions": 0, "rule_suppressions": 0,
"topic_suppressions": 0,
"systems": 0, "record_systems": 0, "design_systems": 0, "systems": 0, "record_systems": 0, "design_systems": 0,
"design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0, "design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0,
"repo_bindings": 0, "repo_bindings": 0,
@@ -1300,38 +1252,10 @@ async def _restore_v2(data: dict) -> dict:
rule_id_map[r_data["id"]] = rule.id rule_id_map[r_data["id"]] = rule.id
stats["rules"] += 1 stats["rules"] += 1
# 12. Rulebook subscriptions (v3 join table) # 12-14. Rulebook subscriptions, rule and topic suppressions (v3-v14)
for sub in data.get("rulebook_subscriptions", []): # `rulebook_subscriptions`, `rule_suppressions` and `topic_suppressions`
mapped_pid = project_id_map.get(sub.get("project_id", 0)) # are READ BY NOBODY since milestone 414 dropped their tables. An
mapped_rbid = rulebook_id_map.get(sub.get("rulebook_id", 0)) # archive carrying them still imports, for the reason 14b gives.
if mapped_pid is None or mapped_rbid is None:
continue
await session.execute(project_rulebook_subscriptions.insert().values(
project_id=mapped_pid, rulebook_id=mapped_rbid,
))
stats["rulebook_subscriptions"] += 1
# 13. Rule suppressions (v3 join table)
for sup in data.get("rule_suppressions", []):
mapped_pid = project_id_map.get(sup.get("project_id", 0))
mapped_rid = rule_id_map.get(sup.get("rule_id", 0))
if mapped_pid is None or mapped_rid is None:
continue
await session.execute(project_rule_suppressions.insert().values(
project_id=mapped_pid, rule_id=mapped_rid,
))
stats["rule_suppressions"] += 1
# 14. Topic suppressions (v3 join table)
for sup in data.get("topic_suppressions", []):
mapped_pid = project_id_map.get(sup.get("project_id", 0))
mapped_tid = topic_id_map.get(sup.get("topic_id", 0))
if mapped_pid is None or mapped_tid is None:
continue
await session.execute(project_topic_suppressions.insert().values(
project_id=mapped_pid, topic_id=mapped_tid,
))
stats["topic_suppressions"] += 1
# 14b. Always-on rulebook exclusions (v10, milestone 297) # 14b. Always-on rulebook exclusions (v10, milestone 297)
# `rulebook_exclusions` was a v10 section and is READ BY NOBODY since # `rulebook_exclusions` was a v10 section and is READ BY NOBODY since
@@ -1669,10 +1593,8 @@ async def _restore_v2(data: dict) -> dict:
# so the next edit to that project would fail on data this # so the next edit to that project would fail on data this
# importer wrote. # importer wrote.
choices.pop("exclude_always_on_rulebooks", None) choices.pop("exclude_always_on_rulebooks", None)
choices["subscribe_rulebooks"] = [ # Same for subscribe_rulebooks since milestone 414.
rulebook_id_map[i] for i in choices.get("subscribe_rulebooks") or [] choices.pop("subscribe_rulebooks", None)
if i in rulebook_id_map
]
ds = choices.get("design_system_id") ds = choices.get("design_system_id")
choices["design_system_id"] = design_system_id_map.get(ds) if ds else None choices["design_system_id"] = design_system_id_map.get(ds) if ds else None
proj.inception = {**inception, "choices": choices} proj.inception = {**inception, "choices": choices}
+36 -12
View File
@@ -23,7 +23,7 @@ from sqlalchemy import delete, or_, select
from scribe.models import async_session from scribe.models import async_session
from scribe.models.embedding import NoteEmbedding, RuleEmbedding from scribe.models.embedding import NoteEmbedding, RuleEmbedding
from scribe.models.note import Note from scribe.models.note import Note
from scribe.services.access import notes_visibility_clause from scribe.services.access import can_read_project, notes_visibility_clause
if TYPE_CHECKING: # resolves the Rule forward ref without importing at runtime if TYPE_CHECKING: # resolves the Rule forward ref without importing at runtime
from scribe.models.rulebook import Rule from scribe.models.rulebook import Rule
@@ -819,6 +819,9 @@ async def semantic_search_rules(
threshold: float = _SIMILARITY_THRESHOLD, threshold: float = _SIMILARITY_THRESHOLD,
kind: str | None = None, kind: str | None = None,
report: dict | None = None, report: dict | None = None,
*,
project_id: int | None = None,
everywhere: bool = False,
) -> list[tuple[float, "Rule"]]: ) -> list[tuple[float, "Rule"]]:
"""Return up to *limit* (score, rule) pairs most relevant to *query*. """Return up to *limit* (score, rule) pairs most relevant to *query*.
@@ -836,12 +839,26 @@ async def semantic_search_rules(
reports a decline the ranker never made (#3765). ABSENT means no search reports a decline the ranker never made (#3765). ABSENT means no search
touched the dict at all, which is a stand-in in a test, not a real call. touched the dict at all, which is a stand-in in a test, not a real call.
Scoped by OWNERSHIP — a rule is the caller's if they own its rulebook or SCOPED, and the scope is a rule's home (milestone 414). A rule lives in a
its project. Deliberately not filtered to what currently BINDS a given rulebook topic — GLOBAL, it applies wherever its owner works — or on one
project: this answers "is there a rule about this", which a person asking project, where it applies to that project and nowhere else:
wants answered across their whole rulebook. Deciding which rules bind where
is the surfacing question, and it has its own machinery - default (`project_id=None`): global rules only. A hook with no bound
(get_applicable_rules) rather than a second, subtly different copy here. project gets these, and so does any caller that forgets to say; the
safe failure is surfacing less, not another project's rules.
- `project_id=N`: global rules plus project N's own, and N's only when
the caller can read that project (access.can_read_project, so a shared
project's rules reach its collaborators too).
- `everywhere=True`: every rule the caller owns, in any home. Only for an
explicit whole-rulebook question — `search(content_type="rule")` with no
project — where "is there a rule about this" is asked across everything.
This used to be scoped by OWNERSHIP alone, on the argument that "is there
a rule about this" wants the whole rulebook. That is still right for the
explicit ask. It was wrong for the hooks, which inject unasked: every
project's rules surfaced in every other project's sessions — one repo's
template conventions arriving while editing an unrelated one — and a
project rule meant nothing a session could feel.
THERE IS NO TIER TO NARROW BY ANY MORE (milestone 394). This carried a THERE IS NO TIER TO NARROW BY ANY MORE (milestone 394). This carried a
`tier` parameter, and the arms deliberately passed nothing: filtering on it `tier` parameter, and the arms deliberately passed nothing: filtering on it
@@ -883,6 +900,17 @@ async def semantic_search_rules(
distance = RuleEmbedding.embedding.cosine_distance(query_vec) distance = RuleEmbedding.embedding.cosine_distance(query_vec)
try: try:
# topic_id XOR project_id (migration 0059), so a rule matches exactly
# one arm of whichever clause applies. Inside the try: the access
# check reads the database too, and this function fails open.
global_rule = Rulebook.owner_user_id == user_id
if everywhere:
home = or_(global_rule, Project.user_id == user_id)
elif project_id and await can_read_project(user_id, project_id):
home = or_(global_rule, Rule.project_id == project_id)
else:
home = global_rule
async with async_session() as session: async with async_session() as session:
rows = (await session.execute( rows = (await session.execute(
select(Rule, distance.label("distance")) select(Rule, distance.label("distance"))
@@ -895,11 +923,7 @@ async def semantic_search_rules(
Rule.deleted_at.is_(None), Rule.deleted_at.is_(None),
# No threshold predicate — see the note above # No threshold predicate — see the note above
# semantic_search_notes. Applied below, after the collapse. # semantic_search_notes. Applied below, after the collapse.
# topic_id XOR project_id, so exactly one arm can match. home,
or_(
Rulebook.owner_user_id == user_id,
Project.user_id == user_id,
),
*( [Rule.kind == kind] if kind else [] ), *( [Rule.kind == kind] if kind else [] ),
) )
# Overfetch so collapsing chunks to their best row still fills # Overfetch so collapsing chunks to their best row still fills
+24 -81
View File
@@ -7,7 +7,6 @@ A project's inheritance is a decision, not a default. The record lives on
"decided_at": "<iso>", "decided_by": <user id> | null, "decided_at": "<iso>", "decided_by": <user id> | null,
"via": "mcp" | "ui" | "legacy", "via": "mcp" | "ui" | "legacy",
"choices": { "choices": {
"subscribe_rulebooks": [rulebook ids],
"design_system_id": <id> | null, "design_system_id": <id> | null,
"seed_systems": bool "seed_systems": bool
} }
@@ -17,14 +16,15 @@ NULL = undecided → enter_project asks. ``legacy`` is the migration's stamp on
projects that existed before the step did (inherit-all / no design system / projects that existed before the step did (inherit-all / no design system /
no seed), so the ask fires only for projects created after this shipped. no seed), so the ask fires only for projects created after this shipped.
``exclude_always_on_rulebooks`` was a fourth choice until milestone 394. It Rules are not a choice any more. ``exclude_always_on_rulebooks`` went with the
let a project decline to inherit an always-on rulebook, and with no always-on always-on tier (milestone 394), and ``subscribe_rulebooks`` went with
tier there is nothing to decline — a rulebook now reaches a project by subscriptions (milestone 414): a rule in a rulebook is global and applies to
subscription, which is opt-IN, so declining is expressed by not subscribing. every project, and a project's own rules are written on it directly. Migration
0101 strips both keys from stored records.
The shape and its validator are pure; ``decide`` composes the existing The shape and its validator are pure; ``decide`` composes the existing
services — subscriptions, set_project_design_system, the standard Systems services — set_project_design_system and the standard Systems seed — checks
seed — checks every target BEFORE touching anything, every target BEFORE touching anything,
applies the effects (each idempotent), and writes the record LAST, so a applies the effects (each idempotent), and writes the record LAST, so a
half-applied decision is re-runnable rather than recorded as done. half-applied decision is re-runnable rather than recorded as done.
``current_defaults`` is what the enter_project ask shows: what binds today ``current_defaults`` is what the enter_project ask shows: what binds today
@@ -34,20 +34,11 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session from scribe.models import async_session
from scribe.models.project import Project from scribe.models.project import Project
from scribe.models.rulebook import Rulebook
INCEPTION_VIAS = ("mcp", "ui", "legacy") INCEPTION_VIAS = ("mcp", "ui", "legacy")
CHOICE_KEYS = ("subscribe_rulebooks", "design_system_id", "seed_systems") CHOICE_KEYS = ("design_system_id", "seed_systems")
def _is_id_list(value) -> bool:
return isinstance(value, list) and all(
isinstance(v, int) and not isinstance(v, bool) and v > 0 for v in value
)
def validate_inception(choices) -> str | None: def validate_inception(choices) -> str | None:
@@ -55,18 +46,14 @@ def validate_inception(choices) -> str | None:
None. Pure and checked BEFORE any effect is applied: a decision either None. Pure and checked BEFORE any effect is applied: a decision either
applies whole or errors whole (the StrictArgs lesson, #2709). applies whole or errors whole (the StrictArgs lesson, #2709).
Accepts the four keys, each optional: two id lists (positive ints, no Accepts two keys, each optional: ``design_system_id`` an int or None,
duplicates between exclude and subscribe), ``design_system_id`` an int ``seed_systems`` a bool. Unknown keys are an error — a typo, or a choice
or None, ``seed_systems`` a bool. Unknown keys are an error — a typo the product no longer offers, must not become a silently ignored one."""
must not become a silently ignored choice."""
if not isinstance(choices, dict): if not isinstance(choices, dict):
return "choices must be an object" return "choices must be an object"
unknown = sorted(set(choices) - set(CHOICE_KEYS)) unknown = sorted(set(choices) - set(CHOICE_KEYS))
if unknown: if unknown:
return f"unknown inception choice(s): {', '.join(unknown)} (one of: {', '.join(CHOICE_KEYS)})" return f"unknown inception choice(s): {', '.join(unknown)} (one of: {', '.join(CHOICE_KEYS)})"
subs = choices.get("subscribe_rulebooks") or []
if not _is_id_list(subs):
return "subscribe_rulebooks must be a list of rulebook ids"
ds = choices.get("design_system_id") ds = choices.get("design_system_id")
if ds is not None and (isinstance(ds, bool) or not isinstance(ds, int) or ds <= 0): if ds is not None and (isinstance(ds, bool) or not isinstance(ds, int) or ds <= 0):
return "design_system_id must be a positive id or null" return "design_system_id must be a positive id or null"
@@ -77,11 +64,10 @@ def validate_inception(choices) -> str | None:
def normalize_choices(choices: dict | None) -> dict: def normalize_choices(choices: dict | None) -> dict:
"""The three keys, always present, in canonical form — what gets stored """Both keys, always present, in canonical form — what gets stored
and what the UI/agent reads back. Call after validate_inception.""" and what the UI/agent reads back. Call after validate_inception."""
choices = choices or {} choices = choices or {}
return { return {
"subscribe_rulebooks": sorted(set(choices.get("subscribe_rulebooks") or [])),
"design_system_id": choices.get("design_system_id"), "design_system_id": choices.get("design_system_id"),
"seed_systems": bool(choices.get("seed_systems", False)), "seed_systems": bool(choices.get("seed_systems", False)),
} }
@@ -95,37 +81,20 @@ def is_decided(project) -> bool:
async def current_defaults(user_id: int, project_id: int) -> dict: async def current_defaults(user_id: int, project_id: int) -> dict:
"""What the project inherits if nobody decides — the ask's payload. """What the project inherits if nobody decides — the ask's payload.
{rulebooks: [{id,title}], subscribed_rulebooks: [...], {design_system_id, design_systems: [{id,title}], systems: <count>}.
design_system_id, design_systems: [{id,title}], systems: <count>}. Instance-agnostic: an install with no design systems shows an empty list,
Instance-agnostic: an install with no rulebooks / design systems shows and the ask says so rather than inventing a default.
empty lists, and the ask says so rather than inventing a default.
""" """
from scribe.services import design_systems as design_systems_svc from scribe.services import design_systems as design_systems_svc
from scribe.services import projects as projects_svc from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc from scribe.services import systems as systems_svc
project = await projects_svc.get_project(user_id, project_id) project = await projects_svc.get_project(user_id, project_id)
if project is None: if project is None:
raise ValueError(f"project {project_id} not found") raise ValueError(f"project {project_id} not found")
async with async_session() as session:
rows = (
await session.execute(
select(Rulebook.id, Rulebook.title)
.where(Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None))
.order_by(Rulebook.title)
)
).all()
applicable = await rulebooks_svc.get_applicable_rules(project_id, user_id, limit=1)
designs = await design_systems_svc.list_design_systems(user_id) designs = await design_systems_svc.list_design_systems(user_id)
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True) systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
return { return {
# ONE list since milestone 394. This was split into always-on and
# "other" because the first bound the project whether it asked or not;
# with the tier gone every rulebook is opt-in, so the split named a
# difference that no longer exists.
"rulebooks": [{"id": i, "title": t} for i, t in rows],
"subscribed_rulebooks": applicable.get("subscribed_rulebooks", []),
"design_system_id": project.design_system_id, "design_system_id": project.design_system_id,
"design_systems": [{"id": d.id, "title": d.title} for d in designs], "design_systems": [{"id": d.id, "title": d.title} for d in designs],
"systems": len(systems), "systems": len(systems),
@@ -137,22 +106,6 @@ async def _check_targets(user_id: int, choices: dict) -> None:
effect lands — a decision applies whole or errors whole.""" effect lands — a decision applies whole or errors whole."""
from scribe.services import access from scribe.services import access
wanted = set(choices["subscribe_rulebooks"])
if wanted:
async with async_session() as session:
rows = (
await session.execute(
select(Rulebook.id).where(
Rulebook.id.in_(wanted),
Rulebook.owner_user_id == user_id,
Rulebook.deleted_at.is_(None),
)
)
).all()
found = {rid for (rid,) in rows}
missing = sorted(wanted - found)
if missing:
raise ValueError(f"rulebook(s) {missing} not found (or not yours)")
ds = choices["design_system_id"] ds = choices["design_system_id"]
if ds is not None and not await access.can_read_design_system(user_id, ds): if ds is not None and not await access.can_read_design_system(user_id, ds):
raise ValueError(f"design system {ds} not found (or not readable)") raise ValueError(f"design system {ds} not found (or not readable)")
@@ -167,20 +120,17 @@ async def decide(
) -> dict: ) -> dict:
"""Record a project's inception decision and apply it (milestone 297). """Record a project's inception decision and apply it (milestone 297).
Owner-only. Validates the choices (pure) and every target (owned / Owner-only. Validates the choices (pure) and every target (readable)
readable) first; then, each idempotent: subscribe the named rulebooks, first; then, each idempotent: point the project at the design system
point the project at the design system (None = explicitly none), seed the (None = explicitly none), seed the standard Systems if asked and the
standard Systems if asked and the project has none; then write project has none; then write ``projects.inception`` LAST. Re-deciding
``projects.inception`` LAST. Re-deciding is additive for subscriptions replaces the design system and re-seeds nothing a project already has.
(nothing is silently dropped — unsubscribe is an explicit call), replaces
the design system, and re-seeds nothing a project already has.
Returns {"inception": <record>, "effects": {excluded, subscribed, Returns {"inception": <record>, "effects": {design_system_id,
design_system_id, systems_seeded}}. systems_seeded}}.
""" """
from scribe.services import design_systems as design_systems_svc from scribe.services import design_systems as design_systems_svc
from scribe.services import projects as projects_svc from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc from scribe.services import systems as systems_svc
if via not in INCEPTION_VIAS or via == "legacy": if via not in INCEPTION_VIAS or via == "legacy":
@@ -194,8 +144,6 @@ async def decide(
raise ValueError(f"project {project_id} not found (or not yours)") raise ValueError(f"project {project_id} not found (or not yours)")
await _check_targets(user_id, choices) await _check_targets(user_id, choices)
for rb in choices["subscribe_rulebooks"]:
await rulebooks_svc.subscribe_project(project_id, rb, user_id)
if not await design_systems_svc.set_project_design_system( if not await design_systems_svc.set_project_design_system(
user_id, project_id, choices["design_system_id"] user_id, project_id, choices["design_system_id"]
): ):
@@ -219,7 +167,6 @@ async def decide(
return { return {
"inception": record, "inception": record,
"effects": { "effects": {
"subscribed": choices["subscribe_rulebooks"],
"design_system_id": choices["design_system_id"], "design_system_id": choices["design_system_id"],
"systems_seeded": [sy.name for sy in seeded], "systems_seeded": [sy.name for sy in seeded],
}, },
@@ -235,24 +182,20 @@ async def inception_ask(user_id: int, project_id: int) -> dict:
defaults = await current_defaults(user_id, project_id) defaults = await current_defaults(user_id, project_id)
except Exception: except Exception:
return {} return {}
books = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["rulebooks"]) or "none"
designs = ", ".join(f"{d['title']} (#{d['id']})" for d in defaults["design_systems"]) or "none" designs = ", ".join(f"{d['title']} (#{d['id']})" for d in defaults["design_systems"]) or "none"
return { return {
"defaults": defaults, "defaults": defaults,
"ask": ( "ask": (
"This project has no inception decision: nobody has said what it " "This project has no inception decision: nobody has said what it "
f"inherits. Rulebooks it could subscribe to — {books}; design system — " "inherits. Design system — "
f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} " f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, " f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
"once: which rulebooks to subscribe (default: none — a rulebook binds " "once: which design system (or none), and whether to seed "
"a project only when it opts in), which design system (or none), and "
"whether to seed "
"the standard starter Systems — then record the answers. This ask repeats on " "the standard starter Systems — then record the answers. This ask repeats on "
"every enter_project until a decision is recorded." "every enter_project until a decision is recorded."
), ),
"call": ( "call": (
f"decide_project_inception(project_id={project_id}, " f"decide_project_inception(project_id={project_id}, "
"subscribe_rulebooks=[...], "
"design_system_id=<id | -1 for none>, seed_systems=<true|false>)" "design_system_id=<id | -1 for none>, seed_systems=<true|false>)"
), ),
} }
+1 -1
View File
@@ -48,8 +48,8 @@ async def start_planning(
{ {
"milestone": <milestone dict>, "milestone": <milestone dict>,
"applicable_rules": [...], "applicable_rules": [...],
"subscribed_rulebooks": [...],
"applicable_rules_truncated": bool, "applicable_rules_truncated": bool,
"project_rules": [...],
"project_goal": str, "project_goal": str,
"open_task_count": int, "open_task_count": int,
"steps": [<task dict>, ...], # only when steps were given "steps": [<task dict>, ...], # only when steps were given
+10 -9
View File
@@ -855,7 +855,7 @@ async def _reserve_slot_for_preference(
# be indistinguishable from one that earned its place. # be indistinguishable from one that earned its place.
found = await semantic_search_rules( found = await semantic_search_rules(
user_id, query, limit=1, threshold=threshold, user_id, query, limit=1, threshold=threshold,
kind="preference", report=_rep, kind="preference", report=_rep, project_id=project_id or None,
) )
fresh = [(s, r) for s, r in found if r.id not in already] fresh = [(s, r) for s, r in found if r.id not in already]
# ITS OWN SOURCE, and both sides of the trade logged. #2463's own finding # ITS OWN SOURCE, and both sides of the trade logged. #2463's own finding
@@ -953,14 +953,15 @@ async def build_prompt_rule_hint(
t0 = time.perf_counter() t0 = time.perf_counter()
_rep: dict = {} _rep: dict = {}
# NOT scoped to the project, and that is the corpus's own decision # SCOPED TO THIS SESSION'S PROJECT (milestone 414): global rules plus
# rather than an omission here — semantic_search_rules is scoped by # the bound project's own. An unbound session (project_id 0) gets
# OWNERSHIP on purpose, because "is there a rule about this" is asked # global rules only. This arm used to search every rule the user owned,
# across a whole rulebook. `project_id` below reaches the log row and # so each project's rules were injected into every other project's
# nothing else. # sessions — this surface speaks unasked, and a whole-rulebook answer
# is only right for someone who asked the whole rulebook.
hits = await semantic_search_rules( hits = await semantic_search_rules(
user_id, q, limit=PROMPTRULE_LIMIT, threshold=threshold, user_id, q, limit=PROMPTRULE_LIMIT, threshold=threshold,
report=_rep, report=_rep, project_id=project_id or None,
) )
duration_ms = (time.perf_counter() - t0) * 1000.0 duration_ms = (time.perf_counter() - t0) * 1000.0
@@ -1831,7 +1832,7 @@ async def build_write_path_hint(
hits = await semantic_search_rules( hits = await semantic_search_rules(
user_id, code or path, limit=RULEHINT_LIMIT, user_id, code or path, limit=RULEHINT_LIMIT,
threshold=cfg["rule_threshold"], threshold=cfg["rule_threshold"],
report=_rep_wpr, report=_rep_wpr, project_id=project_id or None,
) )
rule_ms = (time.perf_counter() - rule_t0) * 1000.0 rule_ms = (time.perf_counter() - rule_t0) * 1000.0
# BAND FIRST, dedup second, and the order is the whole point (#3851). # BAND FIRST, dedup second, and the order is the whole point (#3851).
@@ -1986,7 +1987,7 @@ async def build_tool_rule_hint(
hits = await semantic_search_rules( hits = await semantic_search_rules(
user_id, query, limit=RULEHINT_LIMIT, user_id, query, limit=RULEHINT_LIMIT,
threshold=cfg["tool_rule_threshold"], threshold=cfg["tool_rule_threshold"],
report=_rep_ptr, report=_rep_ptr, project_id=project_id or None,
) )
duration_ms = (time.perf_counter() - t0) * 1000.0 duration_ms = (time.perf_counter() - t0) * 1000.0
+1 -1
View File
@@ -107,7 +107,7 @@ async def completion_preferences(user_id: int, *, project_id: int | None = None)
t0 = time.perf_counter() t0 = time.perf_counter()
hits = await semantic_search_rules( hits = await semantic_search_rules(
user_id, COMPLETION_QUERY, limit=LIMIT, threshold=threshold, user_id, COMPLETION_QUERY, limit=LIMIT, threshold=threshold,
kind="preference", report=report, kind="preference", report=report, project_id=project_id,
) )
hits = [(score, rule) for score, rule in hits if rule.kind == "preference"] hits = [(score, rule) for score, rule in hits if rule.kind == "preference"]
record_retrieval( record_retrieval(
+180 -410
View File
@@ -91,7 +91,7 @@ async def update_rulebook(
async def delete_rulebook(rulebook_id: int, user_id: int) -> None: async def delete_rulebook(rulebook_id: int, user_id: int) -> None:
"""Delete a rulebook. Cascade-deletes topics, rules, subscriptions.""" """Delete a rulebook. Cascade-deletes its topics and rules."""
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(Rulebook).where( select(Rulebook).where(
@@ -265,30 +265,6 @@ async def _assert_project_owned(session, project_id: int, user_id: int) -> None:
raise ValueError(f"project {project_id} not found") raise ValueError(f"project {project_id} not found")
async def _assert_rulebook_rule_owned(session, rule_id: int, user_id: int) -> None:
"""Raise ValueError if rule isn't a rulebook rule the user owns.
Project-scoped rules (Rule.project_id set, topic_id NULL) are NOT
suppressible — they belong to the project; delete them instead. This
helper deliberately excludes them.
"""
from scribe.models.rulebook import Rule
result = await session.execute(
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
Rule.id == rule_id,
Rulebook.owner_user_id == user_id,
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
)
if result.scalar_one_or_none() is None:
raise ValueError(f"rule {rule_id} not found or not a rulebook rule")
# The vocabularies migration 0088's CHECK constraints enforce. Named here so # The vocabularies migration 0088's CHECK constraints enforce. Named here so
# a caller can be corrected before the database refuses it (rule 36 keeps the # a caller can be corrected before the database refuses it (rule 36 keeps the
# two in step; this keeps the error readable). # two in step; this keeps the error readable).
@@ -414,9 +390,7 @@ def _refresh_rule_embedding(rule: Rule) -> None:
logger.exception("embedding refresh failed for rule %s", rule.id) logger.exception("embedding refresh failed for rule %s", rule.id)
async def co_surfaced_partners( async def co_surfaced_partners(user_id: int, rule_ids: list[int]) -> list[Rule]:
user_id: int, rule_ids: list[int], exclude_ids: set[int] | None = None,
) -> list[Rule]:
"""Rules that must arrive WITH the given ones, because they fail together. """Rules that must arrive WITH the given ones, because they fail together.
This is the whole reason `co_surfaces` exists. Rule 144 was split off rule This is the whole reason `co_surfaces` exists. Rule 144 was split off rule
@@ -425,18 +399,14 @@ async def co_surfaced_partners(
what the entire shape is intended to be." Merging was the only fix what the entire shape is intended to be." Merging was the only fix
available; this is the fix that should have been available. available; this is the fix that should have been available.
Two limits, both deliberate: Only rules the caller OWNS: an edge is not a back door into someone else's
rulebook. Whether a partner can reach a given PROJECT is the caller's
- Only rules the caller OWNS. An edge is not a back door into someone question (get_applicable_rules drops another project's rule), because this
else's rulebook. answers "what fails with these", which has no project in it.
- `exclude_ids` is honoured, and callers pass the project's SUPPRESSIONS.
A project that explicitly muted a rule should not have it dragged back in
by an edge — the suppression is a decision, and the edge does not
outrank it.
""" """
if not rule_ids: if not rule_ids:
return [] return []
known = set(rule_ids) | (exclude_ids or set()) known = set(rule_ids)
async with async_session() as session: async with async_session() as session:
edges = (await session.execute( edges = (await session.execute(
select(RuleRelation).where( select(RuleRelation).where(
@@ -523,9 +493,10 @@ async def create_project_rule(
) -> Rule: ) -> Rule:
"""Create a rule scoped to a single project (no rulebook ceremony). """Create a rule scoped to a single project (no rulebook ceremony).
Project-scoped rules apply only to the named project; they don't Project-scoped rules apply only to the named project: retrieval surfaces
propagate via rulebook subscriptions. Topic_id is left NULL — the them in that project's sessions and nowhere else (milestone 414), where a
CHECK constraint enforces exactly-one of (topic_id, project_id). rule in a rulebook topic is global. Topic_id is left NULL — the CHECK
constraint enforces exactly-one of (topic_id, project_id).
""" """
async with async_session() as session: async with async_session() as session:
await _assert_project_owned(session, project_id, user_id) await _assert_project_owned(session, project_id, user_id)
@@ -555,18 +526,40 @@ async def list_rules(
topic_id: int | None = None, topic_id: int | None = None,
project_id: int | None = None, project_id: int | None = None,
) -> list[Rule]: ) -> list[Rule]:
"""List rules filtered by any of the three IDs. All filters are ownership-scoped. """List rules by rulebook, topic or project. Ownership-scoped.
When project_id is set, the result includes both rulebook rules reached via A rule has one home (milestone 414): a rulebook topic, where it is global,
project_rulebook_subscriptions AND project-scoped rules (Rule.project_id). or a project. So the filters name homes rather than reach:
When rulebook_id or topic_id is set, project-scoped rules are excluded by
construction (they have neither). With no filter, only rulebook rules are - `project_id` lists that project's OWN rules. Global rules apply to every
returned — adding all of a user's project-scoped rules unprompted would project, so listing them under each one would say nothing; list them by
surprise existing callers. rulebook, or unfiltered. `rulebook_id` / `topic_id` don't combine with it
— a project rule has neither.
- `rulebook_id` / `topic_id` list global rules in that rulebook or topic.
- No filter lists every global rule. A user's project rules are left out:
they belong to their projects, and mixing them into the rulebook listing
would surprise its callers.
Before milestone 414, `project_id` returned the rules of every rulebook the
project SUBSCRIBED to plus its own. Subscriptions are gone.
""" """
from scribe.models.rulebook import project_rulebook_subscriptions from scribe.models.project import Project
async with async_session() as session: async with async_session() as session:
if project_id:
result = await session.execute(
select(Rule)
.join(Project, Rule.project_id == Project.id)
.where(
Project.user_id == user_id,
Rule.project_id == project_id,
Rule.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.order_by(Rule.order_index, Rule.title)
)
return list(result.scalars().all())
stmt = ( stmt = (
select(Rule) select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id) .join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
@@ -582,39 +575,11 @@ async def list_rules(
stmt = stmt.where(Rule.topic_id == topic_id) stmt = stmt.where(Rule.topic_id == topic_id)
if rulebook_id: if rulebook_id:
stmt = stmt.where(RulebookTopic.rulebook_id == rulebook_id) stmt = stmt.where(RulebookTopic.rulebook_id == rulebook_id)
if project_id:
stmt = (
stmt.join(
project_rulebook_subscriptions,
project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
)
.where(project_rulebook_subscriptions.c.project_id == project_id)
)
stmt = stmt.order_by( stmt = stmt.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title, Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
) )
result = await session.execute(stmt) result = await session.execute(stmt)
rulebook_rules = list(result.scalars().all()) return list(result.scalars().all())
if not project_id:
return rulebook_rules
# Project-scoped rules (topic_id IS NULL, project_id matches).
# Verifies ownership by joining Project on user_id.
from scribe.models.project import Project
proj_stmt = (
select(Rule)
.join(Project, Rule.project_id == Project.id)
.where(
Project.user_id == user_id,
Rule.project_id == project_id,
Rule.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.order_by(Rule.order_index, Rule.title)
)
proj_result = await session.execute(proj_stmt)
return rulebook_rules + list(proj_result.scalars().all())
async def _fetch_owned_rule(session, rule_id: int, user_id: int) -> Optional[Rule]: async def _fetch_owned_rule(session, rule_id: int, user_id: int) -> Optional[Rule]:
@@ -723,6 +688,71 @@ async def update_rule(
return rule return rule
async def move_rule(
rule_id: int, user_id: int, *, topic_id: int = 0, project_id: int = 0,
) -> Optional[Rule]:
"""Give a rule a new home — into a rulebook topic (global) or onto a
project — keeping its id, history, Systems and relations (milestone 414).
A rule's home IS its reach: in a topic it applies to every project, on a
project to that project alone. Recreating the rule in the other home and
trashing the original would lose its id (and every record citing it), its
edit history, its area tags and its typed edges, which is why this exists.
Exactly one of `topic_id` / `project_id`, matching the model's CHECK
(migration 0059). Raises ValueError for: neither or both named, a target
the caller does not own, the rule already living there, or a topic that
already holds a live rule with this title (uq_rule_per_topic) — the message
names that rule, rather than letting the constraint fail the commit.
Returns None when the rule itself is not the caller's.
WHAT A MOVE DOES NOT DO, deliberately:
- No version. A rule's history records its TEXT (milestone 323, decision
4); its place is not text, and folding it in would make "version" mean
two things. The rule's `updated_at` moves; say why a rule moved where
the decision is recorded.
- No duplicate gate. Nothing new enters the corpus — the same rule changes
home — so there is no second record to warn about.
- No re-embed. The rule's document is its title, statement and trigger;
retrieval reads the home from the row at query time.
"""
if bool(topic_id) == bool(project_id):
raise ValueError("name exactly one destination: topic_id (global) or project_id")
async with async_session() as session:
rule = await _fetch_owned_rule(session, rule_id, user_id)
if rule is None:
return None
if topic_id:
if rule.topic_id == topic_id:
raise ValueError(f"rule {rule_id} is already in topic {topic_id}")
await _assert_topic_owned(session, topic_id, user_id)
clash = (await session.execute(
select(Rule.id).where(
Rule.topic_id == topic_id,
Rule.title == rule.title,
Rule.deleted_at.is_(None),
Rule.id != rule.id,
)
)).scalar_one_or_none()
if clash is not None:
raise ValueError(
f'topic {topic_id} already has a rule titled "{rule.title}" '
f"(rule {clash}) — rename one before moving"
)
rule.project_id = None
rule.topic_id = topic_id
else:
if rule.project_id == project_id:
raise ValueError(f"rule {rule_id} is already on project {project_id}")
await _assert_project_owned(session, project_id, user_id)
rule.topic_id = None
rule.project_id = project_id
await session.commit()
await session.refresh(rule)
return rule
# ── Edit history (milestone 323) ─────────────────────────────────────── # ── Edit history (milestone 323) ───────────────────────────────────────
# #
# The ACL-scoped reads live HERE rather than in services/rule_versions.py, # The ACL-scoped reads live HERE rather than in services/rule_versions.py,
@@ -930,282 +960,42 @@ async def delete_rule(rule_id: int, user_id: int) -> None:
await session.commit() await session.commit()
# ── Subscriptions + get_applicable_rules ─────────────────────────────── # ── get_applicable_rules ────────────────────────────────────────────────
async def subscribe_project(
project_id: int, rulebook_id: int, user_id: int,
) -> None:
"""Add a subscription. Idempotent — duplicates raise; we swallow."""
from scribe.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_rulebook_owned(session, rulebook_id, user_id)
# ON CONFLICT DO NOTHING via try/except to keep dialect-agnostic.
try:
await session.execute(
insert(project_rulebook_subscriptions).values(
project_id=project_id, rulebook_id=rulebook_id,
)
)
await session.commit()
except Exception:
await session.rollback() # PK collision = already subscribed; fine.
async def unsubscribe_project(
project_id: int, rulebook_id: int, user_id: int,
) -> None:
from scribe.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_rulebook_owned(session, rulebook_id, user_id)
await session.execute(
sql_delete(project_rulebook_subscriptions).where(
project_rulebook_subscriptions.c.project_id == project_id,
project_rulebook_subscriptions.c.rulebook_id == rulebook_id,
)
)
await session.commit()
# ── Suppressions — project-level mute of rulebook rules / topics ────────
async def suppress_rule_for_project(
project_id: int, rule_id: int, user_id: int,
) -> None:
"""Mute one rulebook rule for one project. Idempotent."""
from scribe.models.rulebook import project_rule_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_rulebook_rule_owned(session, rule_id, user_id)
try:
await session.execute(
insert(project_rule_suppressions).values(
project_id=project_id, rule_id=rule_id,
)
)
await session.commit()
except Exception:
await session.rollback() # PK collision = already suppressed; fine.
async def unsuppress_rule_for_project(
project_id: int, rule_id: int, user_id: int,
) -> None:
"""Unmute one rulebook rule for one project. Idempotent."""
from scribe.models.rulebook import project_rule_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await session.execute(
sql_delete(project_rule_suppressions).where(
project_rule_suppressions.c.project_id == project_id,
project_rule_suppressions.c.rule_id == rule_id,
)
)
await session.commit()
async def suppress_topic_for_project(
project_id: int, topic_id: int, user_id: int,
) -> None:
"""Mute every rule under one topic for one project. Idempotent."""
from scribe.models.rulebook import project_topic_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_topic_owned(session, topic_id, user_id)
try:
await session.execute(
insert(project_topic_suppressions).values(
project_id=project_id, topic_id=topic_id,
)
)
await session.commit()
except Exception:
await session.rollback()
async def unsuppress_topic_for_project(
project_id: int, topic_id: int, user_id: int,
) -> None:
"""Unmute a topic for one project. Idempotent."""
from scribe.models.rulebook import project_topic_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await session.execute(
sql_delete(project_topic_suppressions).where(
project_topic_suppressions.c.project_id == project_id,
project_topic_suppressions.c.topic_id == topic_id,
)
)
await session.commit()
def _tagged_rule_ids():
"""Rules carrying at least one canonical area tag (milestone 394).
The complement is what matters: a rule NOT in this set was never narrowed
by its author, so it is general to its rulebook and applies wherever that
rulebook is subscribed. Expressed as a subquery rather than a fetched list
so the area test stays inside the one statement `limit` is counted on.
"""
return select(rule_systems.c.rule_id)
async def get_applicable_rules( async def get_applicable_rules(
project_id: int, user_id: int, limit: int = 50, project_id: int, user_id: int, limit: int = 50,
) -> dict: ) -> dict:
"""Return rules applicable to a project — both via rulebook subscriptions """The rules a project's LISTING shows: its own, and the global rules
and project-scoped rules (Rule.project_id matches), with suppressed rules deterministically bound to the areas it works in.
and suppressed topics filtered out.
Shape: Shape:
{ {
"rules": [{id, title, statement, "rules": [{id, title, statement, topic_id, topic_title,
topic_id, topic_title, rulebook_id, rulebook_title, ...}, ...],
rulebook_id, rulebook_title}, ...], "project_rules": [{id, title, statement, ...}, ...],
"project_rules": [{id, title, statement}, ...],
"suppressed_rules": [{id, title,
topic_id, topic_title,
rulebook_id, rulebook_title}, ...],
"suppressed_topics": [{id, title,
rulebook_id, rulebook_title}, ...],
"truncated": bool, "truncated": bool,
"subscribed_rulebooks": [{id, title}, ...]
} }
`rules` is the subscription-derived set with project-level suppressions NOT WHAT A SESSION RECEIVES. Rules reach a session by retrieval, which
applied. `project_rules` is the project-scoped set (never suppressed — reads a rule's home (milestone 414): global rules everywhere, a project's
delete instead). `suppressed_rules` / `suppressed_topics` carry the own rules in that project. This is the listing a planning read carries so
titles + rulebook context callers need to display what was filtered a reader can see which constraints are on the table, and it is narrower
without round-tripping for names. than retrieval on purpose — "every global rule" is not a list anyone reads.
`rules` is the global rules TAGGED to a canonical area this project works
in (milestone 307, D7): a deterministic tag match, never a similarity
score. Before milestone 414 this was every rule in a SUBSCRIBED rulebook,
narrowed by area only where an author had tagged one. With subscriptions
gone there is no opt-in left to scope the untagged ones, and an untagged
global rule is general by construction — it arrives by retrieval when the
work makes it relevant, like every other rule.
`project_rules` is the project's own, never filtered by area: a rule
written ON a project is scoped to it already.
""" """
from scribe.models.rulebook import ( from scribe.models.project import Project
project_rulebook_subscriptions,
project_rule_suppressions,
project_topic_suppressions,
)
async with async_session() as session: async with async_session() as session:
# Subscribed rulebooks for the project (ownership-scoped).
sub_q = (
select(Rulebook.id, Rulebook.title)
.join(
project_rulebook_subscriptions,
project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
)
.where(
project_rulebook_subscriptions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
Rulebook.deleted_at.is_(None),
)
.order_by(Rulebook.title)
)
sub_rows = (await session.execute(sub_q)).all()
subscribed_rulebooks = [
{"id": rb_id, "title": rb_title} for rb_id, rb_title in sub_rows
]
# Suppressed rules — joined to topic + rulebook so callers can render
# context without a follow-up lookup. Ownership-scoped via rulebook.
suppressed_rules_q = (
select(
Rule.id, Rule.title,
RulebookTopic.id.label("topic_id"),
RulebookTopic.title.label("topic_title"),
Rulebook.id.label("rulebook_id"),
Rulebook.title.label("rulebook_title"),
)
.join(project_rule_suppressions, project_rule_suppressions.c.rule_id == Rule.id)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
project_rule_suppressions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
)
.order_by(Rulebook.title, RulebookTopic.title, Rule.title)
)
suppressed_rule_rows = (await session.execute(suppressed_rules_q)).all()
suppressed_rules = [
{"id": rid, "title": rt, "topic_id": ti, "topic_title": tt,
"rulebook_id": rbi, "rulebook_title": rbt}
for rid, rt, ti, tt, rbi, rbt in suppressed_rule_rows
]
suppressed_rule_ids = [r["id"] for r in suppressed_rules]
# Suppressed topics — joined to rulebook for context.
suppressed_topics_q = (
select(
RulebookTopic.id, RulebookTopic.title,
Rulebook.id.label("rulebook_id"),
Rulebook.title.label("rulebook_title"),
)
.join(project_topic_suppressions, project_topic_suppressions.c.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
project_topic_suppressions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
)
.order_by(Rulebook.title, RulebookTopic.title)
)
suppressed_topic_rows = (await session.execute(suppressed_topics_q)).all()
suppressed_topics = [
{"id": tid, "title": tt, "rulebook_id": rbi, "rulebook_title": rbt}
for tid, tt, rbi, rbt in suppressed_topic_rows
]
suppressed_topic_ids = [t["id"] for t in suppressed_topics]
# Applicable rules (limit + 1 so we can detect truncation). Filter
# in SQL so truncation reflects the post-suppression count, not the
# raw subscription count.
# Selects the ENTITY, not a column list: rule_brief is the one place
# that decides which fields a surfaced rule carries, and a column list
# here would be a second such decision to keep in step. The row count
# is bounded by `limit`, so this is a listing, not a scan.
rules_q = (
select(
Rule,
RulebookTopic.title.label("topic_title"),
Rulebook.id.label("rulebook_id"),
Rulebook.title.label("rulebook_title"),
)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.join(
project_rulebook_subscriptions,
project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
)
.where(
project_rulebook_subscriptions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
# An inception exclusion is total (milestone 297): a rulebook the
# project opted out of contributes nothing, subscribed or not.
)
.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
.limit(limit + 1)
)
if suppressed_rule_ids:
rules_q = rules_q.where(Rule.id.notin_(suppressed_rule_ids))
if suppressed_topic_ids:
rules_q = rules_q.where(Rule.topic_id.notin_(suppressed_topic_ids))
# AREA BINDING (milestone 307, narrowed by 394). A rule reaches this
# project when it is tagged to an area the project actually works in —
# a deterministic tag match, never a similarity score, so bindingness
# never depends on a ranking (D7).
#
# Applied in SQL rather than by filtering afterwards, so `limit` counts
# the rules that will actually be surfaced instead of counting rules
# that are about to be dropped.
project_area_ids = (await session.execute( project_area_ids = (await session.execute(
select(System.canonical_id).where( select(System.canonical_id).where(
System.project_id == project_id, System.project_id == project_id,
@@ -1214,36 +1004,37 @@ async def get_applicable_rules(
System.status == "active", System.status == "active",
).distinct() ).distinct()
)).scalars().all() )).scalars().all()
reachable = select(rule_systems.c.rule_id).where(
rule_systems.c.canonical_id.in_(project_area_ids) rule_rows = []
) if project_area_ids else None if project_area_ids:
# SUBSCRIPTION IS THE SCOPE; AREAS NARROW ONLY WHERE AN AUTHOR ASKED. # Selects the ENTITY, not a column list: rule_brief is the one
# # place that decides which fields a surfaced rule carries. Filtered
# This read `always_on OR reachable` (milestone 307). The tier arm is # in SQL so `limit` counts the rules that will actually be shown.
# gone, and the first attempt at 394 kept only the reachable arm — so rule_rows = (await session.execute(
# a subscribed rulebook's untagged rules stopped arriving at all. That select(
# was wrong twice over: the query above is ALREADY scoped to rulebooks Rule,
# this project subscribed to, so the project opted in and was then RulebookTopic.title.label("topic_title"),
# handed a subset of what it asked for; and the milestone is explicit Rulebook.id.label("rulebook_id"),
# that subscription-derived rules are not what it removes. The Rulebook.title.label("rulebook_title"),
# integration suite caught it through a co_surfaces partner that never
# arrived because the rule it travels with had been filtered out.
#
# 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: .join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
# No canonical areas on this project: nothing can match by area, .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
# so only the untagged (general) rules apply. .where(
rules_q = rules_q.where(Rule.id.notin_(_tagged_rule_ids())) Rulebook.owner_user_id == user_id,
rule_rows = (await session.execute(rules_q)).all() 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 truncated = len(rule_rows) > limit
rules = [ rules = [
rule_brief(rule, topic_title=tt, rulebook_id=rbi, rulebook_title=rbt) rule_brief(rule, topic_title=tt, rulebook_id=rbi, rulebook_title=rbt)
@@ -1251,8 +1042,7 @@ async def get_applicable_rules(
] ]
# Project-scoped rules — verifies ownership via Project.user_id. # Project-scoped rules — verifies ownership via Project.user_id.
from scribe.models.project import Project proj_rule_rows = (await session.execute(
proj_rules_q = (
select(Rule) select(Rule)
.join(Project, Rule.project_id == Project.id) .join(Project, Rule.project_id == Project.id)
.where( .where(
@@ -1262,32 +1052,26 @@ async def get_applicable_rules(
Project.deleted_at.is_(None), Project.deleted_at.is_(None),
) )
.order_by(Rule.order_index, Rule.title) .order_by(Rule.order_index, Rule.title)
) )).all()
# A PROJECT'S OWN RULES ARE NOT FILTERED BY AREA, and the asymmetry
# with the family query above is the point. A family rule has to earn
# its way into this project; a rule written ON this project is scoped
# to it by construction, and filtering it again would drop rules whose
# only fault is that nobody tagged them to a System.
proj_rule_rows = (await session.execute(proj_rules_q)).all()
project_rules = [rule_brief(rule) for (rule,) in proj_rule_rows] project_rules = [rule_brief(rule) for (rule,) in proj_rule_rows]
# Edges travel with the rules they belong to (milestone 307). # Edges travel with the rules they belong to (milestone 307).
# #
# A co_surfaces partner that was not otherwise selected is ADDED, because a # A co_surfaces partner that was not otherwise selected is ADDED, because a
# rule that arrives without the half it fails with is the failure the edge # rule that arrives without the half it fails with is the failure the edge
# was created to prevent. Suppressions are passed as exclusions so an # was created to prevent — but only a partner that could reach this
# explicit mute still wins over an edge. # project at all. An edge to another project's rule is not a way in.
surfaced_ids = [r["id"] for r in rules] + [r["id"] for r in project_rules] surfaced_ids = [r["id"] for r in rules] + [r["id"] for r in project_rules]
partners = await co_surfaced_partners( partners = await co_surfaced_partners(user_id, surfaced_ids)
user_id, surfaced_ids, exclude_ids=set(suppressed_rule_ids),
)
for partner in partners: for partner in partners:
if partner.project_id not in (None, project_id):
continue
rules.append(rule_brief(partner, via="co_surfaces")) rules.append(rule_brief(partner, via="co_surfaces"))
surfaced_ids.append(partner.id) surfaced_ids.append(partner.id)
# Relations on every surfaced rule, so a reader can see that an override # Relations on every surfaced rule, so a reader can see that an override
# exists rather than discovering the contradiction by acting on the wrong # exists rather than discovering the contradiction by acting on the wrong
# one. Areas too — they are why a conditional rule is here at all. # one. Areas too — they are why a global rule is in this listing at all.
edges = await list_rule_relations(surfaced_ids) edges = await list_rule_relations(surfaced_ids)
areas = await list_rule_systems(surfaced_ids) areas = await list_rule_systems(surfaced_ids)
for brief in (*rules, *project_rules): for brief in (*rules, *project_rules):
@@ -1296,14 +1080,7 @@ async def get_applicable_rules(
if areas.get(brief["id"]): if areas.get(brief["id"]):
brief["systems"] = areas[brief["id"]] brief["systems"] = areas[brief["id"]]
return { return {"rules": rules, "project_rules": project_rules, "truncated": truncated}
"rules": rules,
"project_rules": project_rules,
"suppressed_rules": suppressed_rules,
"suppressed_topics": suppressed_topics,
"truncated": truncated,
"subscribed_rulebooks": subscribed_rulebooks,
}
def rules_payload( def rules_payload(
@@ -1313,11 +1090,11 @@ def rules_payload(
Every surface that hands rules to an agent (enter_project, get_project, Every surface that hands rules to an agent (enter_project, get_project,
get_milestone, get_task for legacy plans, start_planning) carries the get_milestone, get_task for legacy plans, start_planning) carries the
same seven keys under the same names — so a reader learns them once. One same keys under the same names — so a reader learns them once. One
place renames `rules` → `applicable_rules` and `truncated` → place renames `rules` → `applicable_rules` and `truncated` →
`applicable_rules_truncated`; the tools merge this into their payloads. `applicable_rules_truncated`; the tools merge this into their payloads.
IT ALSO RECORDS THE SURFACING, which is why it now takes a caller and a IT ALSO RECORDS THE SURFACING, which is why it takes a caller and a
source. Every one of those surfaces is a bulk delivery — the applicable set source. Every one of those surfaces is a bulk delivery — the applicable set
handed over whole, chosen by nobody — so this is the one place that has to handed over whole, chosen by nobody — so this is the one place that has to
emit for all of them. Doing it per-caller instead would be five sites to emit for all of them. Doing it per-caller instead would be five sites to
@@ -1330,17 +1107,16 @@ def rules_payload(
`RANKED_SOURCES` in `rule_usage` is what folds them back together. `RANKED_SOURCES` in `rule_usage` is what folds them back together.
Emitting from here is safe in a way emitting from `get_applicable_rules` Emitting from here is safe in a way emitting from `get_applicable_rules`
would not be: this function is only ever called to BUILD A REPLY. The two would not be: this function is only ever called to BUILD A REPLY. The
other callers of the rules machinery — the write-path etag arm other caller of the rules machinery — the write-path etag arm
(`plugin_context`) — computed a marker and showed nobody (`plugin_context`) — computes a marker and shows nobody anything, and
anything, and counting those would put rules in the denominator that no counting it would put rules in the denominator that no agent ever saw.
agent ever saw.
`brief` is the session handshake's form (#4045): the project's own rules `brief` is the session handshake's form (#4045): the project's own rules
as id and title, and the subscribed rulebooks, nothing else. Rules reach a as id and title, nothing else. Rules reach a session in full by
session in full by retrieval, which ignores subscriptions, so the handshake retrieval, so the handshake lists which of the project's constraints exist
lists which constraints exist rather than restating them; get_rule reads rather than restating them; get_rule reads one. Only what is shown is
one. Only what is shown is recorded as surfaced. recorded as surfaced.
""" """
if brief: if brief:
project_rules = [ project_rules = [
@@ -1350,10 +1126,7 @@ def rules_payload(
record_rule_surfaced( record_rule_surfaced(
user_id=user_id, rule_ids=[r["id"] for r in project_rules], source=source, user_id=user_id, rule_ids=[r["id"] for r in project_rules], source=source,
) )
return { return {"project_rules": project_rules}
"project_rules": project_rules,
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
}
record_rule_surfaced( record_rule_surfaced(
user_id=user_id, user_id=user_id,
rule_ids=( rule_ids=(
@@ -1365,10 +1138,7 @@ def rules_payload(
return { return {
"applicable_rules": applicable["rules"], "applicable_rules": applicable["rules"],
"applicable_rules_truncated": applicable["truncated"], "applicable_rules_truncated": applicable["truncated"],
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"project_rules": applicable.get("project_rules", []), "project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
} }
-16
View File
@@ -83,22 +83,6 @@ async def _cascade(session, user_id: int, etype: str, eid: int, batch: str, now)
await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.project_id == eid], batch, now) await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.project_id == eid], batch, now)
# Project-scoped rules cascade with the project they're attached to. # Project-scoped rules cascade with the project they're attached to.
await _set(session, Rule, [Rule.project_id == eid], batch, now) await _set(session, Rule, [Rule.project_id == eid], batch, now)
# Suppressions are pure associations (no deleted_at) — hard-delete
# them here so restoring the project doesn't bring stale mutes back.
# FK CASCADE would handle a full DELETE on the project row, but the
# soft-delete path keeps the project row alive; this guarantees the
# rows are gone whether or not the project ever gets purged.
from scribe.models.rulebook import (
project_rule_suppressions, project_topic_suppressions,
)
await session.execute(
sql_delete(project_rule_suppressions)
.where(project_rule_suppressions.c.project_id == eid)
)
await session.execute(
sql_delete(project_topic_suppressions)
.where(project_topic_suppressions.c.project_id == eid)
)
await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now) await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now)
elif etype == "milestone": elif etype == "milestone":
await _set(session, Note, [Note.user_id == user_id, Note.milestone_id == eid], batch, now) await _set(session, Note, [Note.user_id == user_id, Note.milestone_id == eid], batch, now)
+10 -14
View File
@@ -3,10 +3,9 @@
The WHY a project inherits what it does lives on projects.inception. Pure The WHY a project inherits what it does lives on projects.inception. Pure
validation is pinned here; the effects are step 3's integration tests. validation is pinned here; the effects are step 3's integration tests.
The opt-out of an always-on rulebook had its own association table until Rules left inception in two steps: the always-on opt-out with the tier
milestone 394. With no always-on tier there is nothing to opt out OF — a (milestone 394), and the rulebook subscription with subscriptions (milestone
rulebook binds a project only by subscription — so the table and the test 414). A rule in a rulebook is global, so there is nothing to choose.
that pinned its shape both went with it.
""" """
from scribe.models.project import Project from scribe.models.project import Project
from scribe.services.inception import ( from scribe.services.inception import (
@@ -28,10 +27,9 @@ def test_project_carries_an_inception_record_and_to_dict_shows_it():
def test_validate_inception_pins_the_choice_vocabulary(): def test_validate_inception_pins_the_choice_vocabulary():
assert CHOICE_KEYS == ("subscribe_rulebooks", "design_system_id", "seed_systems") assert CHOICE_KEYS == ("design_system_id", "seed_systems")
assert validate_inception({}) is None assert validate_inception({}) is None
assert validate_inception({"subscribe_rulebooks": [2], assert validate_inception({"design_system_id": 3, "seed_systems": True}) is None
"design_system_id": 3, "seed_systems": True}) is None
assert validate_inception({"design_system_id": None}) is None assert validate_inception({"design_system_id": None}) is None
assert "must be an object" in validate_inception([]) assert "must be an object" in validate_inception([])
assert "unknown inception choice" in validate_inception({"repo": "x"}) assert "unknown inception choice" in validate_inception({"repo": "x"})
@@ -41,19 +39,17 @@ def test_validate_inception_pins_the_choice_vocabulary():
# believe a rulebook had been declined. # believe a rulebook had been declined.
assert "unknown inception choice" in validate_inception( assert "unknown inception choice" in validate_inception(
{"exclude_always_on_rulebooks": [1]}) {"exclude_always_on_rulebooks": [1]})
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [0]}) # Same for the subscription choice since milestone 414.
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [True]}) assert "unknown inception choice" in validate_inception({"subscribe_rulebooks": [2]})
assert "positive id or null" in validate_inception({"design_system_id": 0}) assert "positive id or null" in validate_inception({"design_system_id": 0})
assert "positive id or null" in validate_inception({"design_system_id": True}) assert "positive id or null" in validate_inception({"design_system_id": True})
assert "true or false" in validate_inception({"seed_systems": "yes"}) assert "true or false" in validate_inception({"seed_systems": "yes"})
def test_normalize_choices_is_canonical_and_complete(): def test_normalize_choices_is_canonical_and_complete():
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3]}) out = normalize_choices({"design_system_id": 4})
assert out == {"subscribe_rulebooks": [1, 3], assert out == {"design_system_id": 4, "seed_systems": False}
"design_system_id": None, "seed_systems": False} assert normalize_choices(None) == {"design_system_id": None, "seed_systems": False}
assert normalize_choices(None) == {"subscribe_rulebooks": [],
"design_system_id": None, "seed_systems": False}
def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant(): def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant():
+19 -37
View File
@@ -1,9 +1,11 @@
"""Real-Postgres integration tests for project inception (milestone 297). """Real-Postgres integration tests for project inception (milestone 297).
What mocks can't prove: a decision's effects land through the real services What mocks can't prove: a decision's effects land through the real services
(exclusions filter the always-on set, subscriptions bind, the design system (the design system points, the standard Systems seed once), the record is
points, the standard Systems seed once), the record is written last, a bad written last, a bad target applies nothing.
target applies nothing.
Rules are not part of inception since milestone 414: a rule in a rulebook is
global and applies to every project, so there is nothing to subscribe to.
""" """
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@@ -11,7 +13,6 @@ import pytest_asyncio
from scribe.models import async_session from scribe.models import async_session
from scribe.models.project import Project from scribe.models.project import Project
from scribe.services import inception as inception_svc from scribe.services import inception as inception_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import canonical_systems as canonical_svc from scribe.services import canonical_systems as canonical_svc
from scribe.services import systems as systems_svc from scribe.services import systems as systems_svc
from tests.helpers import ensure_user from tests.helpers import ensure_user
@@ -21,8 +22,7 @@ pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine"
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def seeded(): async def seeded():
"""Owner, a fresh project, one always-on rulebook (with a rule) and one """Owner and a fresh, undecided project."""
ordinary rulebook (with a rule)."""
async with async_session() as s: async with async_session() as s:
owner = await ensure_user(s, "inception_owner") owner = await ensure_user(s, "inception_owner")
project = Project(user_id=owner.id, title="Inception target") project = Project(user_id=owner.id, title="Inception target")
@@ -30,56 +30,33 @@ async def seeded():
await s.flush() await s.flush()
ids = {"owner": owner.id, "pid": project.id} ids = {"owner": owner.id, "pid": project.id}
await s.commit() await s.commit()
# Two ordinary rulebooks. One was flagged always-on until milestone 394
# removed the tier; a rulebook now reaches a project only by subscription,
# so what used to be "binds automatically" and "binds if you opt in" are
# the same kind of thing.
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
other = await rulebooks_svc.create_rulebook(ids["owner"], "Optional practices")
t1 = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
await rulebooks_svc.create_rule(t1.id, ids["owner"], "dev is home", "Work on dev.")
t2 = await rulebooks_svc.create_topic(other.id, ids["owner"], "docs")
await rulebooks_svc.create_rule(t2.id, ids["owner"], "Write the why", "Record reasons.")
ids.update({"always": always.id, "other": other.id})
return ids return ids
@pytest.mark.integration @pytest.mark.integration
async def test_decide_applies_every_effect_and_records_last(seeded): async def test_decide_applies_every_effect_and_records_last(seeded):
owner, pid = seeded["owner"], seeded["pid"] owner, pid = seeded["owner"], seeded["pid"]
# Undecided: nothing binds, because nothing has been subscribed. Before
# milestone 394 the always-on rulebook bound here without being asked for,
# and this assertion read the other way round.
defaults = await inception_svc.current_defaults(owner, pid) defaults = await inception_svc.current_defaults(owner, pid)
assert sorted(r["id"] for r in defaults["rulebooks"]) == sorted( assert set(defaults) == {"design_system_id", "design_systems", "systems"}
[seeded["always"], seeded["other"]])
assert defaults["systems"] == 0 and defaults["design_system_id"] is None assert defaults["systems"] == 0 and defaults["design_system_id"] is None
assert (await rulebooks_svc.get_applicable_rules(pid, owner))["rules"] == []
out = await inception_svc.decide(owner, pid, via="mcp", choices={ out = await inception_svc.decide(owner, pid, via="mcp", choices={
"subscribe_rulebooks": [seeded["other"]],
"design_system_id": None, "design_system_id": None,
"seed_systems": True, "seed_systems": True,
}) })
assert out["effects"]["subscribed"] == [seeded["other"]] assert set(out["effects"]) == {"design_system_id", "systems_seeded"}
catalog = await canonical_svc.list_canonical_systems() catalog = await canonical_svc.list_canonical_systems()
assert len(out["effects"]["systems_seeded"]) == len(catalog) assert len(out["effects"]["systems_seeded"]) == len(catalog)
# Seeded Systems come out mapped, not needing a later reconciliation. # Seeded Systems come out mapped, not needing a later reconciliation.
seeded_systems = await systems_svc.list_systems(owner, pid) seeded_systems = await systems_svc.list_systems(owner, pid)
assert all(s.canonical_id is not None for s in seeded_systems) assert all(s.canonical_id is not None for s in seeded_systems)
# The subscription is what binds, and it is the ONLY thing that does —
# the unsubscribed rulebook contributes nothing even though it used to
# bind every project by default.
applicable = await rulebooks_svc.get_applicable_rules(pid, owner)
assert [s["id"] for s in applicable["subscribed_rulebooks"]] == [seeded["other"]]
assert "dev is home" not in [r["title"] for r in applicable["rules"]]
# The record, written last, says why. # The record, written last, says why.
async with async_session() as s: async with async_session() as s:
project = await s.get(Project, pid) project = await s.get(Project, pid)
assert inception_svc.is_decided(project) assert inception_svc.is_decided(project)
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
assert project.inception["choices"]["subscribe_rulebooks"] == [seeded["other"]] assert project.inception["choices"] == {"design_system_id": None, "seed_systems": True}
# Re-deciding with seed again mints nothing twice. # Re-deciding with seed again mints nothing twice.
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True}) again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
assert again["effects"]["systems_seeded"] == [] assert again["effects"]["systems_seeded"] == []
@@ -89,15 +66,20 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
@pytest.mark.integration @pytest.mark.integration
async def test_a_bad_decision_applies_nothing(seeded): async def test_a_bad_decision_applies_nothing(seeded):
owner, pid = seeded["owner"], seeded["pid"] owner, pid = seeded["owner"], seeded["pid"]
# A subscription to a rulebook that is not yours is refused BEFORE any # A design system that is not readable is refused BEFORE any effect
# effect lands — the seed must not happen on a decision that fails. # lands — the seed must not happen on a decision that fails.
with pytest.raises(ValueError, match="not found"): with pytest.raises(ValueError, match="not found"):
await inception_svc.decide(owner, pid, via="mcp", choices={ await inception_svc.decide(owner, pid, via="mcp", choices={
"subscribe_rulebooks": [999999], "seed_systems": True, "design_system_id": 999999, "seed_systems": True,
})
assert await systems_svc.list_systems(owner, pid) == []
# The retired rulebook choice is an unknown key, refused whole: a caller
# still passing it must not believe a rulebook was subscribed.
with pytest.raises(ValueError, match="unknown inception choice"):
await inception_svc.decide(owner, pid, via="mcp", choices={
"subscribe_rulebooks": [1], "seed_systems": True,
}) })
assert await systems_svc.list_systems(owner, pid) == [] assert await systems_svc.list_systems(owner, pid) == []
with pytest.raises(ValueError, match="not found"):
await inception_svc.decide(owner, pid, via="mcp", choices={"subscribe_rulebooks": [999999]})
with pytest.raises(ValueError, match="legacy"): with pytest.raises(ValueError, match="legacy"):
await inception_svc.decide(owner, pid, via="legacy", choices={}) await inception_svc.decide(owner, pid, via="legacy", choices={})
async with async_session() as s: async with async_session() as s:
+138
View File
@@ -0,0 +1,138 @@
"""Real-Postgres tests for moving a rule between homes (milestone 414, step 3).
A rule's home is its reach: a rulebook topic makes it global, a project makes
it that project's. The alternative to a move — recreate the rule in the other
home and trash the original — loses the id every record cites, the edit
history, the area tags and the typed edges. These pin that a move keeps all
four, and that the refusals happen before anything is written: the topic/
project CHECK (migration 0059) and the per-topic title index would otherwise
fail the commit with a raw database error.
"""
import uuid
from unittest.mock import MagicMock, patch
import pytest
import pytest_asyncio
from scribe.models import async_session
from scribe.models.project import Project
from scribe.models.rulebook import Rule
from scribe.services import canonical_systems as canonical_svc
from scribe.services import rule_versions as rv_svc
from scribe.services import rulebooks as rulebooks_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@pytest.fixture(autouse=True)
def _no_reindex():
"""Rule writes detach an embedding refresh that outlives the test's loop
and races the next fixture; nothing here is about recall."""
with patch("scribe.services.rulebooks._refresh_rule_embedding", MagicMock()):
yield
@pytest_asyncio.fixture
async def homes():
"""A project rule with a version, an area tag and an incoming edge, plus a
topic to move it into and a second project."""
tag = uuid.uuid4().hex[:8]
async with async_session() as s:
owner = await ensure_user(s, f"rule_move_owner_{tag}")
stranger = await ensure_user(s, f"rule_move_stranger_{tag}")
home = Project(user_id=owner.id, title="Where it started")
other = Project(user_id=owner.id, title="Somewhere else")
theirs = Project(user_id=stranger.id, title="Not yours")
s.add_all([home, other, theirs])
await s.flush()
ids = {"owner": owner.id, "stranger": stranger.id, "home": home.id,
"other": other.id, "theirs": theirs.id}
await s.commit()
owner = ids["owner"]
book = await rulebooks_svc.create_rulebook(owner, "House style")
topic = await rulebooks_svc.create_topic(book.id, owner, "transport")
rule = await rulebooks_svc.create_project_rule(
ids["home"], owner, "Plain HTTP only", "No app-level TLS.",
when_to_apply="setting a cookie flag or a URL scheme",
)
await rulebooks_svc.update_rule(rule.id, owner, statement="No app-level TLS, ever.")
area = await canonical_svc.find_by_name("CI & Release")
await rulebooks_svc.set_rule_systems(rule.id, owner, [area.id])
downstream = await rulebooks_svc.create_project_rule(
ids["home"], owner, "No Secure-Context APIs", "Browsers withhold them.",
when_to_apply="reaching for the clipboard API",
)
await rulebooks_svc.add_rule_relation(owner, downstream.id, rule.id, "elaborates")
ids.update(topic=topic.id, rule=rule.id, area=area.id)
return ids
async def _row(rule_id: int) -> Rule:
async with async_session() as s:
return await s.get(Rule, rule_id)
async def test_a_project_rule_becomes_global_and_keeps_everything(homes):
owner, rule_id = homes["owner"], homes["rule"]
moved = await rulebooks_svc.move_rule(rule_id, owner, topic_id=homes["topic"])
assert moved.id == rule_id
row = await _row(rule_id)
assert (row.topic_id, row.project_id) == (homes["topic"], None)
assert len(await rv_svc.list_versions(rule_id)) == 1, "the move must not drop history"
areas = await rulebooks_svc.list_rule_systems([rule_id])
assert [a["id"] for a in areas[rule_id]] == [homes["area"]]
edges = await rulebooks_svc.list_rule_relations([rule_id])
assert [e["kind"] for e in edges[rule_id]] == ["elaborates"]
async def test_a_move_writes_no_version(homes):
"""A version records what a rule SAID (milestone 323, decision 4). A move
changes where it binds, not a word of it."""
before = len(await rv_svc.list_versions(homes["rule"]))
await rulebooks_svc.move_rule(homes["rule"], homes["owner"], topic_id=homes["topic"])
assert len(await rv_svc.list_versions(homes["rule"])) == before
async def test_a_global_rule_can_move_onto_a_project(homes):
owner, rule_id = homes["owner"], homes["rule"]
await rulebooks_svc.move_rule(rule_id, owner, topic_id=homes["topic"])
await rulebooks_svc.move_rule(rule_id, owner, project_id=homes["other"])
row = await _row(rule_id)
assert (row.topic_id, row.project_id) == (None, homes["other"])
async def test_refusals_happen_before_anything_is_written(homes):
owner, rule_id = homes["owner"], homes["rule"]
with pytest.raises(ValueError, match="exactly one"):
await rulebooks_svc.move_rule(rule_id, owner)
with pytest.raises(ValueError, match="exactly one"):
await rulebooks_svc.move_rule(rule_id, owner, topic_id=homes["topic"],
project_id=homes["other"])
with pytest.raises(ValueError, match="already on project"):
await rulebooks_svc.move_rule(rule_id, owner, project_id=homes["home"])
with pytest.raises(ValueError, match="not found"):
await rulebooks_svc.move_rule(rule_id, owner, project_id=homes["theirs"])
# A topic already holding a live rule with this title: named, not a raw
# IntegrityError from uq_rule_per_topic at commit.
clash = await rulebooks_svc.create_rule(
homes["topic"], owner, "Plain HTTP only", "Already here.",
when_to_apply="setting a cookie flag",
)
with pytest.raises(ValueError, match=f"rule {clash.id}"):
await rulebooks_svc.move_rule(rule_id, owner, topic_id=homes["topic"])
row = await _row(rule_id)
assert (row.topic_id, row.project_id) == (None, homes["home"])
async def test_someone_elses_rule_is_not_found(homes):
"""None, like every other rule read the caller cannot see — not an error
that confirms the rule exists."""
assert await rulebooks_svc.move_rule(
homes["rule"], homes["stranger"], project_id=homes["theirs"],
) is None
+105
View File
@@ -0,0 +1,105 @@
"""Real-Postgres tests for WHERE a rule reaches (milestone 414, step 1).
A rule lives in a rulebook topic (global) or on one project. Retrieval used to
ignore that and search every rule the user owned, so each project's rules were
injected into every other project's sessions. What a mock cannot show is the
join doing the scoping: these seed real rules with hand-made vectors and stub
only the embedder, so every rule is an equally good match and the home alone
decides what comes back.
"""
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from scribe.models import async_session
from scribe.models.embedding import EMBEDDING_DIM, RuleEmbedding
from scribe.models.project import Project
from scribe.models.share import ProjectShare
from scribe.services import rulebooks as rulebooks_svc
from scribe.services.embeddings import CHUNKER_VERSION, semantic_search_rules
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
QUERY_VEC = [1.0] + [0.0] * (EMBEDDING_DIM - 1)
@pytest_asyncio.fixture
async def homes():
"""One global rule, one rule on project A, one on project B, and a
collaborator A is shared with. Every rule embeds identically to the query."""
# Fresh users per test: every rule matches the query equally, so a rule
# left by another test would be indistinguishable from a scoping leak.
tag = uuid.uuid4().hex[:8]
async with async_session() as s:
owner = await ensure_user(s, f"rule_scope_owner_{tag}")
collaborator = await ensure_user(s, f"rule_scope_collaborator_{tag}")
a = Project(user_id=owner.id, title="Scope A")
b = Project(user_id=owner.id, title="Scope B")
s.add_all([a, b])
await s.flush()
s.add(ProjectShare(project_id=a.id, shared_with_user_id=collaborator.id,
permission="viewer", invited_by=owner.id))
ids = {"owner": owner.id, "collaborator": collaborator.id, "a": a.id, "b": b.id}
await s.commit()
with patch("scribe.services.rulebooks._refresh_rule_embedding", MagicMock()):
book = await rulebooks_svc.create_rulebook(ids["owner"], "Scope house style")
topic = await rulebooks_svc.create_topic(book.id, ids["owner"], "everywhere")
glob = await rulebooks_svc.create_rule(
topic.id, ids["owner"], "Global scope rule", "Applies in every project.",
when_to_apply="always",
)
on_a = await rulebooks_svc.create_project_rule(
ids["a"], ids["owner"], "Project A rule", "Applies to A only.",
when_to_apply="working on A",
)
on_b = await rulebooks_svc.create_project_rule(
ids["b"], ids["owner"], "Project B rule", "Applies to B only.",
when_to_apply="working on B",
)
async with async_session() as s:
for rule in (glob, on_a, on_b):
s.add(RuleEmbedding(
rule_id=rule.id, chunk_index=0, embedding=QUERY_VEC,
chunk_text=rule.title, chunker_version=CHUNKER_VERSION,
))
await s.commit()
ids.update(glob=glob.id, on_a=on_a.id, on_b=on_b.id)
return ids
async def _found(user_id: int, **scope) -> set[int]:
with patch("scribe.services.embeddings.get_embedding",
AsyncMock(return_value=QUERY_VEC)):
hits = await semantic_search_rules(user_id, "anything", limit=10,
threshold=0.5, **scope)
return {rule.id for _score, rule in hits}
async def test_retrieval_reaches_a_rule_only_from_its_home(homes):
owner = homes["owner"]
glob, on_a, on_b = homes["glob"], homes["on_a"], homes["on_b"]
# A session bound to A: the global rule and A's own, never B's.
assert await _found(owner, project_id=homes["a"]) == {glob, on_a}
assert await _found(owner, project_id=homes["b"]) == {glob, on_b}
# No bound project, and the default: global only. A caller that forgets
# to pass a scope surfaces less, not another project's rules.
assert await _found(owner) == {glob}
# The explicit whole-rulebook question still reaches everything.
assert await _found(owner, everywhere=True) == {glob, on_a, on_b}
async def test_a_shared_project_brings_its_rules_to_a_collaborator(homes):
"""Readability goes through access.can_read_project (rule 78): a viewer on
A gets A's rules. Not the owner's global rules — rulebooks are the
owner's — and not B's, which is not shared."""
collaborator = homes["collaborator"]
assert await _found(collaborator, project_id=homes["a"]) == {homes["on_a"]}
assert await _found(collaborator, project_id=homes["b"]) == set()
+49 -64
View File
@@ -1,21 +1,21 @@
"""Real-Postgres tests for WHICH rules reach a session (milestone 307 step 5, """Real-Postgres tests for WHICH rules a project's listing names (milestone
narrowed by 394). 307 step 5, narrowed by 394 and 414).
What mocks can't prove, and what this design must not get wrong: A session receives rules by retrieval, scoped by a rule's home (see
test_integration_rule_scope). This is the other surface — the listing a
planning read carries — and what mocks can't prove about it:
1. A rule is invisible to a project that doesn't work in its area, and 1. A global rule tagged to an area arrives in the listing of a project that
arrives — binding, not suggested — to one that does. Area matching is works in that area, and not before. Area matching is DETERMINISTIC: a tag
DETERMINISTIC: a tag comparison, never a similarity score. comparison, never a similarity score.
2. A `co_surfaces` partner arrives with its other half, which is the failure 2. An UNTAGGED global rule is not listed: it applies everywhere and arrives by
that made merging rule 144 into rule 46 look like the only fix. retrieval, and listing every one under every project would say nothing.
3. An explicit suppression outranks an edge. 3. A `co_surfaces` partner arrives with its other half — the failure that made
merging rule 144 into rule 46 look like the only fix — unless the partner
lives on a different project, because an edge is not a way in.
TWO CLAIMS WERE DROPPED HERE BY MILESTONE 394, and it is worth saying which Milestone 414 dropped the suppression claim ("an explicit suppression outranks
rather than leaving a shorter list. "A rule with no tier binds exactly as an edge") with suppressions themselves.
before" and "a conditional rule is reachable, not resident" were both about
the always-on tier. There is no tier and no resident payload, so neither
states anything that can now be true or false — they were not failing, they
had stopped being claims.
""" """
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@@ -32,40 +32,27 @@ pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine"
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def world(): async def world():
"""A project with two rulebooks — one subscribed, one not. """A project with one rule of its own, and a rulebook of global rules."""
Both are ordinary rulebooks since milestone 394; the fixture used to flag
one always-on because that was a second, separate way to reach a project.
Keeping two is still worth it: a rulebook nobody subscribed to must
contribute nothing, and a fixture with only the subscribed one could not
tell "correctly scoped" from "returns everything".
"""
async with async_session() as s: async with async_session() as s:
owner = await ensure_user(s, "surfacing_owner") owner = await ensure_user(s, "surfacing_owner")
project = Project(user_id=owner.id, title="Surfacing target") project = Project(user_id=owner.id, title="Surfacing target")
s.add(project) elsewhere = Project(user_id=owner.id, title="Another project")
s.add_all([project, elsewhere])
await s.flush() await s.flush()
ids = {"owner": owner.id, "pid": project.id} ids = {"owner": owner.id, "pid": project.id, "elsewhere": elsewhere.id}
await s.commit() await s.commit()
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards") book = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
always_topic = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
await rulebooks_svc.create_rule(
always_topic.id, ids["owner"], "dev is home", "Work on dev.",
)
book = await rulebooks_svc.create_rulebook(ids["owner"], "Subscribed practices")
topic = await rulebooks_svc.create_topic(book.id, ids["owner"], "release") topic = await rulebooks_svc.create_topic(book.id, ids["owner"], "release")
plain = await rulebooks_svc.create_rule( await rulebooks_svc.create_rule(
topic.id, ids["owner"], "Between batches, keep stacking", "Keep going.", topic.id, ids["owner"], "dev is home", "Work on dev.",
when_to_apply="before pushing a branch",
) )
await rulebooks_svc.subscribe_project( own = await rulebooks_svc.create_project_rule(
project_id=ids["pid"], rulebook_id=book.id, user_id=ids["owner"], ids["pid"], ids["owner"], "Between batches, keep stacking", "Keep going.",
when_to_apply="when a batch goes green",
) )
ids.update({ ids.update({"book": book.id, "topic": topic.id, "own": own.id})
"always": always.id, "always_topic": always_topic.id,
"book": book.id, "topic": topic.id, "plain": plain.id,
})
return ids return ids
@@ -74,12 +61,18 @@ async def _titles(ids) -> set[str]:
return {r["title"] for r in applicable["rules"]} return {r["title"] for r in applicable["rules"]}
@pytest.mark.integration
async def test_an_untagged_global_rule_is_not_listed(world):
"""It applies here — and everywhere — so naming it under this project says
nothing a reader can act on. The project's own rule is always listed."""
applicable = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
assert "dev is home" not in await _titles(world)
assert [r["title"] for r in applicable["project_rules"]] == [
"Between batches, keep stacking"]
@pytest.mark.integration @pytest.mark.integration
async def test_a_conditional_rule_binds_a_project_that_works_in_its_area(world): async def test_a_tagged_global_rule_is_listed_for_a_project_that_works_in_its_area(world):
"""The payoff: the tag match carries it in deterministically. The project """The payoff: the tag match carries it in deterministically. The project
reaches the area through its own System's canonical_id — its local NAME is reaches the area through its own System's canonical_id — its local NAME is
irrelevant, which is the whole reason the catalog exists.""" irrelevant, which is the whole reason the catalog exists."""
@@ -103,7 +96,7 @@ async def test_a_conditional_rule_binds_a_project_that_works_in_its_area(world):
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"]) surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
hit = [r for r in surfaced["rules"] if r["title"] == "Release tagging"] hit = [r for r in surfaced["rules"] if r["title"] == "Release tagging"]
assert hit, "a tagged conditional rule must bind a project working in that area" assert hit, "a tagged global rule must be listed for a project working in that area"
assert [s["name"] for s in hit[0]["systems"]] == ["CI & Release"] assert [s["name"] for s in hit[0]["systems"]] == ["CI & Release"]
@@ -118,12 +111,8 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
"A name decides nothing.", "A name decides nothing.",
when_to_apply="when naming a build", when_to_apply="when naming a build",
) )
# TAGGED TO AN AREA THIS PROJECT DOES NOT WORK IN, which is what makes the # Tagged to an area this project does NOT work in, so nothing but the edge
# test able to fail at all. Since milestone 394 an UNTAGGED rule in a # can bring it in — otherwise this test could pass without the edge.
# subscribed rulebook applies on its own, so an untagged partner arrives
# through the ordinary query and the edge is never exercised — the
# assertion below passed while proving nothing, which is how this was
# noticed. Tagging it puts it out of reach of everything except the edge.
area = await canonical_svc.find_by_name("CI & Release") area = await canonical_svc.find_by_name("CI & Release")
assert area is not None, "migration 0087 seeds the standard vocabulary" assert area is not None, "migration 0087 seeds the standard vocabulary"
await rulebooks_svc.set_rule_systems(partner.id, world["owner"], [area.id]) await rulebooks_svc.set_rule_systems(partner.id, world["owner"], [area.id])
@@ -132,7 +121,7 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
) )
await rulebooks_svc.add_rule_relation( await rulebooks_svc.add_rule_relation(
world["owner"], world["plain"], partner.id, "co_surfaces", world["owner"], world["own"], partner.id, "co_surfaces",
note="they fail together", note="they fail together",
) )
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"]) surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
@@ -142,20 +131,16 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
@pytest.mark.integration @pytest.mark.integration
async def test_a_suppression_outranks_an_edge(world): async def test_an_edge_is_not_a_way_into_another_project(world):
"""The edge says these belong together; the suppression says this project """A partner that lives on a different project belongs to that project.
does not want that one. An explicit decision beats an inferred one.""" The edge says the two fail together; it does not make one project's rule
# Untagged on purpose, unlike the partner above: this test is about the apply to another."""
# SUPPRESSION winning, so the partner should be one that would otherwise foreign = await rulebooks_svc.create_project_rule(
# arrive by every available route — the ordinary query AND the edge. world["elsewhere"], world["owner"], "Another project's rule", "Not here.",
partner = await rulebooks_svc.create_rule( when_to_apply="working on the other project",
world["topic"], world["owner"], "Muted partner", "Should not arrive.",
) )
await rulebooks_svc.add_rule_relation( await rulebooks_svc.add_rule_relation(
world["owner"], world["plain"], partner.id, "co_surfaces", world["owner"], world["own"], foreign.id, "co_surfaces",
)
await rulebooks_svc.suppress_rule_for_project(
world["pid"], partner.id, world["owner"],
) )
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"]) surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
assert "Muted partner" not in {r["title"] for r in surfaced["rules"]} assert "Another project's rule" not in {r["title"] for r in surfaced["rules"]}
+55 -51
View File
@@ -96,66 +96,70 @@ def test_body_calls_write_tool_classifies_correctly():
assert _body_calls_write_tool(b"not json") is False assert _body_calls_write_tool(b"not json") is False
def test_every_read_shaped_tool_is_explicitly_classified(): def _registered_tool_names() -> set[str]:
"""A read-shaped tool must be classified, not left to default-deny. """What the server actually mounts — not a glob of function names, which
would count helpers and miss anything registered another way."""
from scribe.mcp.server import build_mcp_server
`_READ_ONLY_TOOLS` is hand-maintained, and default-deny means a getter return {tool.name for tool in build_mcp_server()._tool_manager.list_tools()}
def test_every_registered_tool_is_classified_exactly_once():
"""Every tool must be declared a read, a write, or a read-shaped write.
`_READ_ONLY_TOOLS` is hand-maintained, and default-deny means a read
omitted from it fails CLOSED — safe, but silent. That is how a read key omitted from it fails CLOSED — safe, but silent. That is how a read key
ended up able to `get_note` and not `get_snippet`, both pure reads of the ended up able to `get_note` and not `get_snippet` (#2496), and how
same table, while design systems were unreachable entirely (#2496). The `rules_due_for_verification` and `rule_history` sat denied (#3191).
`find_duplicate_snippets` entry was the tell: someone classified the report
and missed the getters beside it.
This is the same shape as #2476 (record_pulled on three of four getters) — The fix keeps the DECISION explicit and derives the CANDIDATES. Deriving
a hand-written enumeration that missed the members added after it. The fix the decision would make a security boundary follow a naming convention, so
there and here is the same: derive the CANDIDATES, keep the DECISION any future `get_*` grants itself access. This test used to derive
explicit. Deriving the decision itself would be worse than a stale list — candidates from names too — tools starting `get_`, `list_`, `search`… — and
it would make a security boundary follow a naming convention, so any future that narrowing is exactly what let the two #3191 reads through: neither
`get_*` grants itself access. name looked like a read. So the candidates are now EVERY registered tool.
So: every tool whose name reads like a read must appear in one of the two
sets. Adding a getter then forces a choice at review time.
""" """
import ast from scribe.mcp.server import (
import pathlib _DELIBERATELY_WRITE_SCOPED, _READ_ONLY_TOOLS, _WRITE_TOOLS,
from scribe.mcp.server import _DELIBERATELY_WRITE_SCOPED, _READ_ONLY_TOOLS
tools_dir = (pathlib.Path(__file__).resolve().parents[1]
/ "src" / "scribe" / "mcp" / "tools")
read_shaped = {
node.name
for path in tools_dir.glob("*.py") if path.name != "__init__.py"
for node in ast.parse(path.read_text()).body
if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef))
and node.name.startswith(("get_", "list_", "search", "resolve_",
"check_", "find_"))
}
assert read_shaped, "found no read-shaped tools — the tools package moved"
unclassified = sorted(read_shaped - _READ_ONLY_TOOLS
- _DELIBERATELY_WRITE_SCOPED)
assert not unclassified, (
f"these read-shaped tools are classified by neither set: {unclassified}. "
f"They currently fail closed for read-only keys, silently. Add each to "
f"_READ_ONLY_TOOLS if it mutates nothing, or to "
f"_DELIBERATELY_WRITE_SCOPED with a comment saying what it writes."
) )
# The reverse: a name in either set that no longer exists is a rename or a registered = _registered_tool_names()
# deletion, and a stale grant is worth surfacing even though it grants assert len(registered) > 50, "found almost no tools — registration moved"
# access to nothing. `enter_project` is the one read tool without a read
# prefix, so it is checked against the full tool set, not `read_shaped`. sets = {
all_tools = { "_READ_ONLY_TOOLS": _READ_ONLY_TOOLS,
node.name "_WRITE_TOOLS": _WRITE_TOOLS,
for path in tools_dir.glob("*.py") if path.name != "__init__.py" "_DELIBERATELY_WRITE_SCOPED": _DELIBERATELY_WRITE_SCOPED,
for node in ast.parse(path.read_text()).body
if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef))
and not node.name.startswith("_") and node.name != "register"
} }
phantom = sorted((_READ_ONLY_TOOLS | _DELIBERATELY_WRITE_SCOPED) - all_tools) unclassified = sorted(registered - set().union(*sets.values()))
assert not unclassified, (
f"these tools are classified by no set: {unclassified}. A read key is "
f"silently denied them. Add each to _READ_ONLY_TOOLS if it mutates "
f"nothing, otherwise to _WRITE_TOOLS."
)
names = list(sets)
for i, first in enumerate(names):
for second in names[i + 1:]:
both = sorted(sets[first] & sets[second])
assert not both, f"in both {first} and {second}: {both}"
# The reverse: a classified name that is not a tool is a rename or a
# deletion, and a stale grant is worth surfacing even though it grants
# access to nothing.
phantom = sorted(set().union(*sets.values()) - registered)
assert not phantom, ( assert not phantom, (
f"these names are classified but are not tools: {phantom}. They were " f"these names are classified but are not tools: {phantom}. They were "
f"renamed or removed — drop them, and check whatever replaced them got " f"renamed or removed — drop them, and check whatever replaced them got "
f"classified." f"classified."
) )
def test_the_completeness_check_can_fail():
"""A guard that cannot fail is indistinguishable from one that is broken
(rule 167). Drop a real tool from its set and the check must notice."""
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
registered = _registered_tool_names()
assert "rule_history" in _READ_ONLY_TOOLS
without = (_READ_ONLY_TOOLS - {"rule_history"}) | _WRITE_TOOLS
assert "rule_history" in registered - without
+1 -1
View File
@@ -78,7 +78,7 @@ async def test_get_milestone_returns_body_steps_and_rules():
step = MagicMock() step = MagicMock()
step.to_dict.return_value = {"id": 9, "title": "step 1", "status": "todo"} step.to_dict.return_value = {"id": 9, "title": "step 1", "status": "todo"}
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False, applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}]} "project_rules": [{"id": 3, "title": "own"}]}
with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone", with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone",
AsyncMock(return_value=m)), \ AsyncMock(return_value=m)), \
patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone_progress", patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone_progress",
+4 -3
View File
@@ -9,7 +9,7 @@ pytestmark = pytest.mark.usefixtures("_bind_user")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_planning_tool_delegates_to_service(): async def test_start_planning_tool_delegates_to_service():
payload = {"milestone": {"id": 5}, "applicable_rules": [], "subscribed_rulebooks": [], payload = {"milestone": {"id": 5}, "applicable_rules": [], "project_rules": [],
"applicable_rules_truncated": False, "project_goal": "", "open_task_count": 0} "applicable_rules_truncated": False, "project_goal": "", "open_task_count": 0}
with patch("scribe.mcp.tools.tasks.planning_svc.start_planning", with patch("scribe.mcp.tools.tasks.planning_svc.start_planning",
AsyncMock(return_value=payload)) as mock: AsyncMock(return_value=payload)) as mock:
@@ -26,7 +26,7 @@ async def test_start_planning_tool_delegates_to_service():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_task_augments_plan_with_rules(): async def test_get_task_augments_plan_with_rules():
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False, applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}]} "project_rules": [{"id": 3, "title": "own"}]}
with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user", with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user",
AsyncMock(return_value=(fake_task(task_kind="plan", id=9, project_id=3), "owner"))), \ AsyncMock(return_value=(fake_task(task_kind="plan", id=9, project_id=3), "owner"))), \
patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules", patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
@@ -34,7 +34,8 @@ async def test_get_task_augments_plan_with_rules():
from scribe.mcp.tools.tasks import get_task from scribe.mcp.tools.tasks import get_task
out = await get_task(task_id=9) out = await get_task(task_id=9)
assert out["applicable_rules"] == [{"id": 1, "title": "r"}] assert out["applicable_rules"] == [{"id": 1, "title": "r"}]
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}] assert out["project_rules"] == [{"id": 3, "title": "own"}]
assert "subscribed_rulebooks" not in out
assert out["applicable_rules_truncated"] is False assert out["applicable_rules_truncated"] is False
+18 -21
View File
@@ -73,7 +73,7 @@ async def test_get_project_enriches_with_milestone_summary():
p = fake_project(id=5, title="found") p = fake_project(id=5, title="found")
milestone_summary = [{"id": 10, "title": "MS", "status": "active", "total": 3}] milestone_summary = [{"id": 10, "title": "MS", "status": "active", "total": 3}]
applicable_payload = { applicable_payload = {
"rules": [], "truncated": False, "subscribed_rulebooks": [], "rules": [], "truncated": False,
} }
with patch( with patch(
"scribe.mcp.tools.projects.projects_svc.get_project", "scribe.mcp.tools.projects.projects_svc.get_project",
@@ -91,9 +91,10 @@ async def test_get_project_enriches_with_milestone_summary():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks(): async def test_get_project_includes_applicable_rules_and_project_rules():
"""The augmented get_project response includes applicable_rules and """The augmented get_project response includes applicable_rules and
subscribed_rulebooks pulled from services/rulebooks.get_applicable_rules. project_rules pulled from services/rulebooks.get_applicable_rules, and
nothing about subscriptions (milestone 414).
""" """
p = fake_project(id=3, title="Fabled Assistant") p = fake_project(id=3, title="Fabled Assistant")
milestone_summary = [] milestone_summary = []
@@ -105,7 +106,6 @@ async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks():
"rulebook_title": "FabledSword family"}, "rulebook_title": "FabledSword family"},
], ],
"truncated": False, "truncated": False,
"subscribed_rulebooks": [{"id": 1, "title": "FabledSword family"}],
} }
with patch( with patch(
"scribe.mcp.tools.projects.projects_svc.get_project", "scribe.mcp.tools.projects.projects_svc.get_project",
@@ -119,8 +119,10 @@ async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks():
): ):
out = await get_project(project_id=3) out = await get_project(project_id=3)
assert out["applicable_rules"][0]["title"] == "dev is home" assert out["applicable_rules"][0]["title"] == "dev is home"
assert out["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}] assert out["project_rules"] == []
assert out["applicable_rules_truncated"] is False assert out["applicable_rules_truncated"] is False
for gone in ("subscribed_rulebooks", "suppressed_rules", "suppressed_topics"):
assert gone not in out, gone
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -173,7 +175,6 @@ async def test_enter_project_composes_full_context():
"topic_title": "t", "rulebook_title": "rb"}], "topic_title": "t", "rulebook_title": "rb"}],
"project_rules": [{"id": 99, "title": "pr1", "statement": "ps"}], "project_rules": [{"id": 99, "title": "pr1", "statement": "ps"}],
"truncated": False, "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}],
} }
milestone_summary = [{"id": 10, "title": "MS", "status": "active", "total": 3}] milestone_summary = [{"id": 10, "title": "MS", "status": "active", "total": 3}]
@@ -202,10 +203,9 @@ async def test_enter_project_composes_full_context():
assert out["project"] == {"id": 5, "title": "P", "status": "active", "goal": ""} assert out["project"] == {"id": 5, "title": "P", "status": "active", "goal": ""}
assert out["milestone_summary"] == milestone_summary assert out["milestone_summary"] == milestone_summary
# Rules arrive in full by retrieval; the handshake lists the project's own # Rules arrive in full by retrieval; the handshake lists the project's own
# by id and title and drops the subscription bookkeeping (#4045). # by id and title (#4045), and nothing about subscriptions (milestone 414).
assert out["project_rules"] == [{"id": 99, "title": "pr1"}] assert out["project_rules"] == [{"id": 99, "title": "pr1"}]
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}] for gone in ("applicable_rules", "applicable_rules_truncated", "subscribed_rulebooks",
for gone in ("applicable_rules", "applicable_rules_truncated",
"suppressed_rules", "suppressed_topics", "recent_notes"): "suppressed_rules", "suppressed_topics", "recent_notes"):
assert gone not in out, gone assert gone not in out, gone
assert out["open_tasks"] == [{ assert out["open_tasks"] == [{
@@ -243,8 +243,7 @@ async def test_enter_project_surfaces_the_systems_vocabulary():
AsyncMock(return_value=p), AsyncMock(return_value=p),
), patch( ), patch(
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", "scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False, AsyncMock(return_value={"rules": [], "truncated": False}),
"subscribed_rulebooks": []}),
), patch( ), patch(
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", "scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=[]), AsyncMock(return_value=[]),
@@ -266,8 +265,7 @@ def _enter_project_stubs(p):
patch("scribe.mcp.tools.projects.projects_svc.get_project", patch("scribe.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=p)), AsyncMock(return_value=p)),
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False, AsyncMock(return_value={"rules": [], "truncated": False})),
"subscribed_rulebooks": []})),
patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=[])), AsyncMock(return_value=[])),
patch("scribe.mcp.tools.projects.notes_svc.list_notes", patch("scribe.mcp.tools.projects.notes_svc.list_notes",
@@ -357,8 +355,7 @@ async def test_enter_project_hands_back_the_design_system_when_the_project_has_o
AsyncMock(return_value=p), AsyncMock(return_value=p),
), patch( ), patch(
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", "scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False, AsyncMock(return_value={"rules": [], "truncated": False}),
"subscribed_rulebooks": []}),
), patch( ), patch(
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", "scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=[]), AsyncMock(return_value=[]),
@@ -417,11 +414,10 @@ async def test_create_project_with_inception_args_decides_via_mcp():
decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}} decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}}
with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \ with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide: patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
out = await create_project(title="P", subscribe_rulebooks=[1], design_system_id=-1, seed_systems=True) out = await create_project(title="P", design_system_id=-1, seed_systems=True)
kw = decide.await_args.kwargs kw = decide.await_args.kwargs
assert decide.await_args.args[1] == 5 and kw["via"] == "mcp" assert decide.await_args.args[1] == 5 and kw["via"] == "mcp"
assert kw["choices"] == {"subscribe_rulebooks": [1], assert kw["choices"] == {"design_system_id": None, "seed_systems": True}
"design_system_id": None, "seed_systems": True}
assert out["inception"]["via"] == "mcp" and "inception_effects" in out assert out["inception"]["via"] == "mcp" and "inception_effects" in out
@@ -437,8 +433,7 @@ async def test_decide_project_inception_tool_records_an_inherit_all_decision_whe
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project(): async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project():
applicable = {"rules": [], "project_rules": [], "truncated": False, applicable = {"rules": [], "project_rules": [], "truncated": False}
"subscribed_rulebooks": []}
ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"} ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"}
async def run(project): async def run(project):
@@ -471,6 +466,8 @@ def test_inception_routes_and_tool_are_registered():
mcp = build_mcp_server() mcp = build_mcp_server()
assert mcp._tool_manager.get_tool("decide_project_inception") is not None assert mcp._tool_manager.get_tool("decide_project_inception") is not None
tool = mcp._tool_manager.get_tool("create_project") tool = mcp._tool_manager.get_tool("create_project")
for name in ("subscribe_rulebooks", "design_system_id", "seed_systems"): for name in ("design_system_id", "seed_systems"):
assert name in tool.parameters.get("properties", {}), name assert name in tool.parameters.get("properties", {}), name
# Rules left inception with subscriptions (milestone 414).
assert "subscribe_rulebooks" not in tool.parameters.get("properties", {})
+34 -74
View File
@@ -186,30 +186,6 @@ async def test_delete_rule_with_confirmed_soft_deletes():
assert mock_delete.called assert mock_delete.called
@pytest.mark.asyncio
async def test_subscribe_project_to_rulebook_calls_service():
mock = AsyncMock()
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.subscribe_project", mock,
):
from scribe.mcp.tools.rulebooks import subscribe_project_to_rulebook
out = await subscribe_project_to_rulebook(project_id=3, rulebook_id=1)
assert out["subscribed"] is True
assert mock.called
@pytest.mark.asyncio
async def test_unsubscribe_project_from_rulebook_calls_service():
mock = AsyncMock()
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.unsubscribe_project", mock,
):
from scribe.mcp.tools.rulebooks import unsubscribe_project_from_rulebook
out = await unsubscribe_project_from_rulebook(project_id=3, rulebook_id=1)
assert out["subscribed"] is False
assert mock.called
def test_register_attaches_every_tool(): def test_register_attaches_every_tool():
"""Every tool in the module reaches the server. """Every tool in the module reaches the server.
@@ -227,7 +203,10 @@ def test_register_attaches_every_tool():
# (milestone 399). # (milestone 399).
# 28 since milestone 394 took list_always_on_rules and the two # 28 since milestone 394 took list_always_on_rules and the two
# always-on exclusion tools with the tier they served. # always-on exclusion tools with the tier they served.
assert len(mcp.names) == 28 # 22 since milestone 414 retired subscriptions and suppressions: the two
# subscribe tools and the four suppress/unsuppress tools. 23 with move_rule
# (milestone 414 step 3), the way a rule changes home.
assert len(mcp.names) == 23
# spot-check a few names # spot-check a few names
assert "list_rulebooks" in mcp.names assert "list_rulebooks" in mcp.names
assert "create_rule" in mcp.names assert "create_rule" in mcp.names
@@ -237,17 +216,18 @@ def test_register_attaches_every_tool():
# get_preference to look for here. # get_preference to look for here.
assert "create_preference" in mcp.names assert "create_preference" in mcp.names
assert "update_preference" in mcp.names assert "update_preference" in mcp.names
assert "subscribe_project_to_rulebook" in mcp.names
assert "create_project_rule" in mcp.names assert "create_project_rule" in mcp.names
assert "suppress_rule_for_project" in mcp.names assert "move_rule" in mcp.names
# milestone 312: the sweep, and the stamp that answers it # milestone 312: the sweep, and the stamp that answers it
assert "rules_due_for_verification" in mcp.names assert "rules_due_for_verification" in mcp.names
assert "mark_rule_verified" in mcp.names assert "mark_rule_verified" in mcp.names
# milestone 323: what a rule used to say # milestone 323: what a rule used to say
assert "rule_history" in mcp.names assert "rule_history" in mcp.names
assert "unsuppress_rule_for_project" in mcp.names # milestone 414: a rule's home is its scope; nothing subscribes or mutes.
assert "suppress_topic_for_project" in mcp.names for gone in ("subscribe_project_to_rulebook", "unsubscribe_project_from_rulebook",
assert "unsuppress_topic_for_project" in mcp.names "suppress_rule_for_project", "unsuppress_rule_for_project",
"suppress_topic_for_project", "unsuppress_topic_for_project"):
assert gone not in mcp.names
@@ -306,50 +286,6 @@ async def test_create_project_rule_uses_explicit_title_when_given():
assert kwargs["title"] == "no auto-docstrings" assert kwargs["title"] == "no auto-docstrings"
@pytest.mark.asyncio
async def test_suppress_rule_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.suppress_rule_for_project", mock):
from scribe.mcp.tools.rulebooks import suppress_rule_for_project
out = await suppress_rule_for_project(project_id=3, rule_id=17)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
assert out == {"project_id": 3, "rule_id": 17, "suppressed": True}
@pytest.mark.asyncio
async def test_unsuppress_rule_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.unsuppress_rule_for_project", mock):
from scribe.mcp.tools.rulebooks import unsuppress_rule_for_project
out = await unsuppress_rule_for_project(project_id=3, rule_id=17)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
assert out == {"project_id": 3, "rule_id": 17, "suppressed": False}
@pytest.mark.asyncio
async def test_suppress_topic_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.suppress_topic_for_project", mock):
from scribe.mcp.tools.rulebooks import suppress_topic_for_project
out = await suppress_topic_for_project(project_id=3, topic_id=22)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
assert out == {"project_id": 3, "topic_id": 22, "suppressed": True}
@pytest.mark.asyncio
async def test_unsuppress_topic_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.unsuppress_topic_for_project", mock):
from scribe.mcp.tools.rulebooks import unsuppress_topic_for_project
out = await unsuppress_topic_for_project(project_id=3, topic_id=22)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
assert out == {"project_id": 3, "topic_id": 22, "suppressed": False}
# ── Typed edges between rules (milestone 307) ─────────────────────────── # ── Typed edges between rules (milestone 307) ───────────────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -514,3 +450,27 @@ def test_rule_history_docstring_says_what_a_version_HOLDS():
"and, not finding it, is likely to hand-copy the old text back with " "and, not finding it, is likely to hand-copy the old text back with "
"no record of why." "no record of why."
) )
@pytest.mark.asyncio
async def test_move_rule_passes_one_destination_and_returns_the_detail():
"""The tool is a thin door: the service decides what a valid move is, and
the reply is the same rule_detail every other write returns."""
moved = fake_rule(id=94, topic_id=12, project_id=None)
move = AsyncMock(return_value=moved)
detail = AsyncMock(return_value={"id": 94, "topic_id": 12, "project_id": None})
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.move_rule", move), \
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.rule_detail", detail):
from scribe.mcp.tools.rulebooks import move_rule
out = await move_rule(rule_id=94, topic_id=12)
assert move.await_args.args[0] == 94
assert move.await_args.kwargs == {"topic_id": 12, "project_id": 0}
assert out["topic_id"] == 12
@pytest.mark.asyncio
async def test_move_rule_on_someone_elses_rule_is_not_found():
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.move_rule", AsyncMock(return_value=None)):
from scribe.mcp.tools.rulebooks import move_rule
with pytest.raises(ValueError, match="not found"):
await move_rule(rule_id=94, project_id=3)
+19
View File
@@ -87,3 +87,22 @@ async def test_fable_search_limit_is_clamped():
mock_search.reset_mock() mock_search.reset_mock()
await search(q="x", limit=0) await search(q="x", limit=0)
assert mock_search.call_args.kwargs["limit"] == 1 assert mock_search.call_args.kwargs["limit"] == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("project_id, scope", [
(5, {"project_id": 5}),
# No project: the explicit question is asked of the whole rulebook.
(0, {"everywhere": True}),
])
async def test_rule_search_scopes_to_the_project_it_is_given(project_id, scope):
"""With a project: global rules plus that project's (milestone 414).
Without one: every rule, because an unscoped "is there a rule about this"
is asking the whole rulebook — unlike a hook, which speaks unasked."""
_user_id_ctx.set(7)
found = AsyncMock(return_value=[])
with patch("scribe.mcp.tools.search.semantic_search_rules", found):
await search(q="release tagging", content_type="rule", project_id=project_id)
kwargs = found.await_args.kwargs
assert {k: kwargs[k] for k in scope} == scope
assert set(kwargs) & {"project_id", "everywhere"} == set(scope)
+3 -6
View File
@@ -80,8 +80,7 @@ def _task(tid: int, milestone_id: int | None) -> MagicMock:
def _enter_stubs(project, milestones: list[dict], tasks: list, *, rules=None, systems=None, def _enter_stubs(project, milestones: list[dict], tasks: list, *, rules=None, systems=None,
design=None): design=None):
applicable = rules or {"rules": [], "project_rules": [], "truncated": False, applicable = rules or {"rules": [], "project_rules": [], "truncated": False}
"subscribed_rulebooks": []}
return [ return [
patch("scribe.mcp.tools.projects.projects_svc.get_project", patch("scribe.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=project)), AsyncMock(return_value=project)),
@@ -121,8 +120,7 @@ async def test_enter_project_stays_small_however_large_the_project():
"rules": [{"id": i, "title": f"r{i}", "statement": PLAN} for i in range(50)], "rules": [{"id": i, "title": f"r{i}", "statement": PLAN} for i in range(50)],
"project_rules": [{"id": 100 + i, "title": f"pr{i}", "statement": PLAN, "project_rules": [{"id": 100 + i, "title": f"pr{i}", "statement": PLAN,
"when_to_apply": PLAN} for i in range(60)], "when_to_apply": PLAN} for i in range(60)],
"truncated": True, "subscribed_rulebooks": [{"id": 1, "title": "Family"}], "truncated": True,
"suppressed_rules": [], "suppressed_topics": [],
} }
systems = [] systems = []
for i in range(40): for i in range(40):
@@ -181,8 +179,7 @@ async def test_get_project_lists_every_milestone_without_plans():
patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=_history(10))), \ AsyncMock(return_value=_history(10))), \
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False, AsyncMock(return_value={"rules": [], "truncated": False})):
"subscribed_rulebooks": []})):
out = await get_project(project_id=5) out = await get_project(project_id=5)
assert len(out["milestone_summary"]) == 10 assert len(out["milestone_summary"]) == 10
assert all("body" not in m for m in out["milestone_summary"]) assert all("body" not in m for m in out["milestone_summary"])
+21 -29
View File
@@ -48,9 +48,7 @@ def test_service_signatures_require_user_id():
"set_rule_systems", "add_rule_relation", "remove_rule_relation", "set_rule_systems", "add_rule_relation", "remove_rule_relation",
"list_rules", "list_rules",
"get_rule", "update_rule", "delete_rule", "get_rule", "update_rule", "delete_rule",
"subscribe_project", "unsubscribe_project", "get_applicable_rules", "get_applicable_rules",
"suppress_rule_for_project", "unsuppress_rule_for_project",
"suppress_topic_for_project", "unsuppress_topic_for_project",
): ):
sig = inspect.signature(getattr(svc, fn_name)) sig = inspect.signature(getattr(svc, fn_name))
assert "user_id" in sig.parameters, f"{fn_name} missing user_id param" assert "user_id" in sig.parameters, f"{fn_name} missing user_id param"
@@ -70,38 +68,32 @@ def test_create_project_rule_route_exists():
assert callable(getattr(rb_routes, "create_project_rule")) assert callable(getattr(rb_routes, "create_project_rule"))
def test_suppression_route_handlers_exist(): def test_subscriptions_and_suppressions_are_gone():
"""The 4 suppression endpoint handlers are registered as Python callables.""" """Milestone 414: a rule's home is its scope. Nothing subscribes a project
from scribe.routes import rulebooks as rb_routes to a rulebook or mutes a rule for one — not a route, a service function or
for name in ( a table. A partial removal would leave a door that 500s on a missing table.
"suppress_project_rule", "unsuppress_project_rule", """
"suppress_project_topic", "unsuppress_project_topic",
):
assert callable(getattr(rb_routes, name)), f"missing route handler: {name}"
def test_suppression_association_tables_declared():
"""Migration 0060 created two new association tables; the models module
must declare matching Table() objects so the rest of the service layer
can reference them via .c.<column>."""
from scribe.models import rulebook as rb_models from scribe.models import rulebook as rb_models
for tbl_name in ("project_rule_suppressions", "project_topic_suppressions"): from scribe.routes import rulebooks as rb_routes
tbl = getattr(rb_models, tbl_name, None) from scribe.services import rulebooks as svc
assert tbl is not None, f"models.rulebook missing {tbl_name}" for name in ("subscribe_project", "unsubscribe_project",
cols = {c.name for c in tbl.columns} "suppress_project_rule", "unsuppress_project_rule",
assert "project_id" in cols "suppress_project_topic", "unsuppress_project_topic"):
assert "rule_id" in cols or "topic_id" in cols assert not hasattr(rb_routes, name), f"route handler still present: {name}"
for name in ("subscribe_project", "unsubscribe_project",
"suppress_rule_for_project", "unsuppress_rule_for_project",
"suppress_topic_for_project", "unsuppress_topic_for_project"):
assert not hasattr(svc, name), f"service function still present: {name}"
for name in ("project_rulebook_subscriptions", "project_rule_suppressions",
"project_topic_suppressions"):
assert not hasattr(rb_models, name), f"table still declared: {name}"
def test_rule_handlers_callable():
def test_rule_and_subscription_handlers_callable():
from scribe.routes import rulebooks as rb_routes from scribe.routes import rulebooks as rb_routes
for name in ( for name in (
"list_rules", "create_rule", "get_rule", "update_rule", "delete_rule", "list_rules", "create_rule", "get_rule", "update_rule", "delete_rule",
"subscribe_project", "unsubscribe_project", "get_project_rules", "get_project_rules", "move_rule",
# The typed edges — both doors carry them (rule 33). # The typed edges — both doors carry them (rule 33).
"relate_rules", "unrelate_rules", "relate_rules", "unrelate_rules",
): ):
+73 -9
View File
@@ -330,7 +330,6 @@ def test_rules_payload_records_both_the_family_and_project_halves():
"rules": [{"id": 10}, {"id": 11}], "rules": [{"id": 10}, {"id": 11}],
"project_rules": [{"id": 12}], "project_rules": [{"id": 12}],
"truncated": False, "truncated": False,
"subscribed_rulebooks": [],
}, },
user_id=1, user_id=1,
source="enter_project", source="enter_project",
@@ -342,9 +341,9 @@ def test_rules_payload_records_both_the_family_and_project_halves():
def test_brief_rules_payload_lists_titles_and_records_only_what_it_shows(): def test_brief_rules_payload_lists_titles_and_records_only_what_it_shows():
"""The handshake's form (#4045): project rules by id and title, the """The handshake's form (#4045): project rules by id and title, nothing
subscribed rulebooks, nothing else. A subscription-derived rule it doesn't else (subscriptions went in milestone 414). A global rule it doesn't show
show must not count as surfaced.""" must not count as surfaced."""
from scribe.services import rulebooks as svc from scribe.services import rulebooks as svc
rec = MagicMock() rec = MagicMock()
@@ -354,17 +353,13 @@ def test_brief_rules_payload_lists_titles_and_records_only_what_it_shows():
"rules": [{"id": 10, "title": "family", "statement": "s"}], "rules": [{"id": 10, "title": "family", "statement": "s"}],
"project_rules": [{"id": 12, "title": "own", "statement": "s"}], "project_rules": [{"id": 12, "title": "own", "statement": "s"}],
"truncated": False, "truncated": False,
"subscribed_rulebooks": [{"id": 1, "title": "Family"}],
}, },
user_id=1, user_id=1,
source="enter_project", source="enter_project",
brief=True, brief=True,
) )
assert out == { assert out == {"project_rules": [{"id": 12, "title": "own"}]}
"project_rules": [{"id": 12, "title": "own"}],
"subscribed_rulebooks": [{"id": 1, "title": "Family"}],
}
assert rec.call_args.kwargs["rule_ids"] == [12] assert rec.call_args.kwargs["rule_ids"] == [12]
@@ -1625,3 +1620,72 @@ async def test_an_act_arm_reports_the_bar_it_actually_searched_at():
if c.kwargs.get("source") == "pre_tool_rule"] if c.kwargs.get("source") == "pre_tool_rule"]
assert len(rows) == 1 assert len(rows) == 1
assert rows[0].kwargs["threshold"] == search.await_args.kwargs["threshold"] assert rows[0].kwargs["threshold"] == search.await_args.kwargs["threshold"]
# ── scope: a session gets global rules plus its own project's (milestone 414) ──
def test_every_hook_rule_search_says_which_project_it_is_for():
"""Every call site passes `project_id`, walked rather than grepped (rule 167).
semantic_search_rules defaults to GLOBAL rules only, so an arm that forgets
the keyword does not leak another project's rules — it quietly stops
surfacing its own project's. That is the failure this pins, and it is
silent in a session: nothing errors, a project rule just never arrives.
`everywhere` is not an acceptable answer in a hook, which speaks unasked.
"""
sources = {
"src/scribe/services/plugin_context.py": 4,
"src/scribe/services/reply_preferences.py": 1,
}
for path, expected in sources.items():
calls = [
n for n in ast.walk(ast.parse(Path(path).read_text()))
if isinstance(n, ast.Call)
and getattr(n.func, "id", None) == "semantic_search_rules"
]
# The count is what lets this fail: a new arm is a new call site, and
# it must be looked at rather than slip past a guard that only checks
# the calls it already knew about.
assert len(calls) == expected, (
f"{path} has {len(calls)} rule searches, expected {expected} — a new "
f"arm must decide its scope; update this count once it passes project_id"
)
for call in calls:
keywords = {k.arg for k in call.keywords}
assert "project_id" in keywords, (
f"{path}:{call.lineno} searches rules without project_id, so it "
f"gets global rules only and never its own project's"
)
assert "everywhere" not in keywords, (
f"{path}:{call.lineno} searches every project's rules from a hook"
)
@pytest.mark.asyncio
@pytest.mark.parametrize("bound, scope", [(7, 7), (0, None)])
async def test_the_act_arms_scope_their_search_to_the_bound_project(bound, scope):
"""A bound session searches its own project; an unbound one (0) searches
global rules only, which the search spells as `project_id=None`."""
from scribe.services import plugin_context as pc
cfg = {"enabled": True, "threshold": 0.6, "top_k": 3,
"rule_threshold": 0.6, "tool_rule_threshold": 0.6}
tool_search = AsyncMock(return_value=[])
with ExitStack() as stack:
stack.enter_context(patch.object(
pc, "get_writepath_config", AsyncMock(return_value=cfg)))
stack.enter_context(patch.object(pc, "semantic_search_rules", tool_search))
stack.enter_context(patch.object(pc, "record_retrieval", MagicMock()))
stack.enter_context(patch.object(pc, "record_rule_surfaced", MagicMock()))
await pc.build_tool_rule_hint(1, "Bash", "git push origin dev", project_id=bound)
assert tool_search.await_args.kwargs["project_id"] == scope
prompt_search = AsyncMock(return_value=[])
with ExitStack() as stack:
stack.enter_context(patch.object(pc, "get_setting", AsyncMock(return_value="0.6")))
stack.enter_context(patch.object(pc, "semantic_search_rules", prompt_search))
stack.enter_context(patch.object(pc, "record_retrieval", MagicMock()))
stack.enter_context(patch.object(pc, "record_rule_surfaced", MagicMock()))
await pc.build_prompt_rule_hint(1, "please merge to main", project_id=bound)
assert prompt_search.await_args.kwargs["project_id"] == scope
+2 -17
View File
@@ -23,7 +23,7 @@ def test_backup_version_is_current():
(Named for the number it asserted until v10, which is exactly the drift a (Named for the number it asserted until v10, which is exactly the drift a
name-carrying-a-value invites; it now says what it checks.)""" name-carrying-a-value invites; it now says what it checks.)"""
assert backup.BACKUP_VERSION == 14 assert backup.BACKUP_VERSION == 15
def _exportable_note(**over): def _exportable_note(**over):
@@ -249,11 +249,7 @@ def test_the_column_guard_covers_every_table_with_a_row_helper():
# REAL table names, as _BACKED_UP holds them — not the shorter keys the # REAL table names, as _BACKED_UP holds them — not the shorter keys the
# payload uses for the same sections. Getting this wrong is what the guard # payload uses for the same sections. Getting this wrong is what the guard
# caught on its own first run. # caught on its own first run.
join_tables = { join_tables = {"rule_systems"}
"project_rulebook_subscriptions", "project_rule_suppressions",
"project_topic_suppressions",
"rule_systems",
}
covered = set(_column_guard_targets()) | join_tables covered = set(_column_guard_targets()) | join_tables
assert set(backup._BACKED_UP) - covered == set() assert set(backup._BACKED_UP) - covered == set()
# And no stale entries: every declaration must name a real target. # And no stale entries: every declaration must name a real target.
@@ -306,15 +302,6 @@ def test_every_table_is_either_backed_up_or_explicitly_excluded():
) )
def test_join_table_row_helpers_are_pure():
subs = [SimpleNamespace(project_id=1, rulebook_id=2)]
rsup = [SimpleNamespace(project_id=1, rule_id=9)]
tsup = [SimpleNamespace(project_id=1, topic_id=7)]
assert backup._subscription_rows(subs) == [{"project_id": 1, "rulebook_id": 2}]
assert backup._rule_suppression_rows(rsup) == [{"project_id": 1, "rule_id": 9}]
assert backup._topic_suppression_rows(tsup) == [{"project_id": 1, "topic_id": 7}]
class _Result: class _Result:
def scalars(self): def scalars(self):
return self return self
@@ -350,8 +337,6 @@ async def test_export_full_backup_contains_every_declared_section():
# The sections v2 silently dropped, the six v5 added, v6's # The sections v2 silently dropped, the six v5 added, v6's
# note_supersessions, and v7's code_shapes (all empty here). # note_supersessions, and v7's code_shapes (all empty here).
for key in ("rulebooks", "rulebook_topics", "rules", for key in ("rulebooks", "rulebook_topics", "rules",
"rulebook_subscriptions", "rule_suppressions",
"topic_suppressions",
"systems", "record_systems", "design_systems", "systems", "record_systems", "design_systems",
"design_tokens", "note_usage_events", "repo_bindings", "design_tokens", "note_usage_events", "repo_bindings",
"note_supersessions", "code_shapes", "code_shape_events", "note_supersessions", "code_shapes", "code_shape_events",
+3 -2
View File
@@ -13,7 +13,7 @@ async def test_start_planning_creates_milestone_and_returns_rules():
"rules": [{"id": 1, "title": "dev is home", "statement": "...", "rules": [{"id": 1, "title": "dev is home", "statement": "...",
"topic_title": "git-workflow", "rulebook_title": "FabledSword family"}], "topic_title": "git-workflow", "rulebook_title": "FabledSword family"}],
"truncated": False, "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "FabledSword family"}], "project_rules": [{"id": 3, "title": "own", "statement": "..."}],
} }
with patch("scribe.services.planning.milestones_svc.create_milestone", with patch("scribe.services.planning.milestones_svc.create_milestone",
AsyncMock(return_value=fake_milestone)) as mock_create, \ AsyncMock(return_value=fake_milestone)) as mock_create, \
@@ -34,7 +34,8 @@ async def test_start_planning_creates_milestone_and_returns_rules():
# Returned shape # Returned shape
assert out["milestone"]["id"] == 5 assert out["milestone"]["id"] == 5
assert out["applicable_rules"][0]["title"] == "dev is home" assert out["applicable_rules"][0]["title"] == "dev is home"
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "FabledSword family"}] assert out["project_rules"][0]["id"] == 3
assert "subscribed_rulebooks" not in out
assert out["open_task_count"] == 3 assert out["open_task_count"] == 3
+48 -64
View File
@@ -172,23 +172,7 @@ async def test_get_rule_returns_none_when_not_owner():
assert result is None assert result is None
# ── Subscriptions + applicable_rules ──────────────────────────────────── # ── applicable_rules ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_subscribe_project_requires_owned_rulebook():
"""subscribe_project raises if user doesn't own the rulebook."""
mock_session = make_mock_session()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("scribe.services.rulebooks.async_session") as mock_cls:
mock_cls.return_value = mock_session
from scribe.services.rulebooks import subscribe_project
with pytest.raises(ValueError, match="not found"):
await subscribe_project(
project_id=1, rulebook_id=999, user_id=7,
)
def _empty(): def _empty():
"""A MagicMock result whose .all() returns [] (or .scalars().all() returns []).""" """A MagicMock result whose .all() returns [] (or .scalars().all() returns [])."""
@@ -217,13 +201,18 @@ def _no_edges():
) )
def _areas(*canonical_ids):
"""The project-areas query's result: `.scalars().all()` of canonical ids."""
r = MagicMock()
r.scalars.return_value.all.return_value = list(canonical_ids)
return r
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_applicable_rules_returns_shape(): async def test_get_applicable_rules_returns_shape():
"""get_applicable_rules returns the full projection — including the """The listing is a project's own rules plus the global rules tagged to its
new suppression fields and rulebook/topic IDs on each rule.""" areas (milestone 414) — no subscriptions or suppressions in the shape."""
mock_session = make_mock_session() mock_session = make_mock_session()
sub_result = MagicMock()
sub_result.all.return_value = [(1, "FabledSword family")]
rules_result = MagicMock() rules_result = MagicMock()
rules_result.all.return_value = [ rules_result.all.return_value = [
# The query selects the ENTITY plus three labels, so rule_brief stays # The query selects the ENTITY plus three labels, so rule_brief stays
@@ -232,10 +221,9 @@ async def test_get_applicable_rules_returns_shape():
"git-workflow", 1, "FabledSword family") "git-workflow", 1, "FabledSword family")
for i in range(50) for i in range(50)
] ]
# Execute order: sub_q, suppressed_rules_q, suppressed_topics_q, # Execute order: project-areas_q, rules_q (only with areas), proj_rules_q
# project-areas_q (milestone 307), rules_q, proj_rules_q
mock_session.execute = AsyncMock(side_effect=[ mock_session.execute = AsyncMock(side_effect=[
sub_result, _empty(), _empty(), _empty(), rules_result, _empty(), _areas(4), rules_result, _empty(),
]) ])
_p1, _p2, _p3 = _no_edges() _p1, _p2, _p3 = _no_edges()
@@ -244,19 +232,11 @@ async def test_get_applicable_rules_returns_shape():
from scribe.services.rulebooks import get_applicable_rules from scribe.services.rulebooks import get_applicable_rules
result = await get_applicable_rules(project_id=3, user_id=7, limit=50) result = await get_applicable_rules(project_id=3, user_id=7, limit=50)
assert "rules" in result assert set(result) == {"rules", "project_rules", "truncated"}
assert "project_rules" in result
assert "suppressed_rules" in result
assert "suppressed_topics" in result
assert "truncated" in result
assert "subscribed_rulebooks" in result
assert result["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}]
assert len(result["rules"]) == 50 assert len(result["rules"]) == 50
assert result["rules"][0]["topic_id"] == 2 assert result["rules"][0]["topic_id"] == 2
assert result["rules"][0]["rulebook_id"] == 1 assert result["rules"][0]["rulebook_id"] == 1
assert result["project_rules"] == [] assert result["project_rules"] == []
assert result["suppressed_rules"] == []
assert result["suppressed_topics"] == []
assert result["truncated"] is False assert result["truncated"] is False
@@ -264,14 +244,12 @@ async def test_get_applicable_rules_returns_shape():
async def test_get_applicable_rules_truncates_when_over_limit(): async def test_get_applicable_rules_truncates_when_over_limit():
"""When limit+1 rows are returned, truncated=True and only `limit` returned.""" """When limit+1 rows are returned, truncated=True and only `limit` returned."""
mock_session = make_mock_session() mock_session = make_mock_session()
sub_result = MagicMock()
sub_result.all.return_value = []
rules_result = MagicMock() rules_result = MagicMock()
rules_result.all.return_value = [ rules_result.all.return_value = [
(fake_rule(id=i, title=f"r{i}"), "topic", 1, "rb") for i in range(51) (fake_rule(id=i, title=f"r{i}"), "topic", 1, "rb") for i in range(51)
] ]
mock_session.execute = AsyncMock(side_effect=[ mock_session.execute = AsyncMock(side_effect=[
sub_result, _empty(), _empty(), _empty(), rules_result, _empty(), _areas(4), rules_result, _empty(),
]) ])
_p1, _p2, _p3 = _no_edges() _p1, _p2, _p3 = _no_edges()
@@ -284,6 +262,24 @@ async def test_get_applicable_rules_truncates_when_over_limit():
assert len(result["rules"]) == 50 assert len(result["rules"]) == 50
@pytest.mark.asyncio
async def test_a_project_with_no_areas_lists_no_global_rules():
"""Untagged global rules apply everywhere and arrive by retrieval; the
listing only names the ones bound by area, so no areas means none — and
the global-rules query is not run at all."""
mock_session = make_mock_session()
mock_session.execute = AsyncMock(side_effect=[_areas(), _empty()])
_p1, _p2, _p3 = _no_edges()
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3:
mock_cls.return_value = mock_session
from scribe.services.rulebooks import get_applicable_rules
result = await get_applicable_rules(project_id=3, user_id=7)
assert result["rules"] == []
assert mock_session.execute.await_count == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_applicable_rules_includes_project_scoped_rules(): async def test_get_applicable_rules_includes_project_scoped_rules():
"""Project-scoped rules surface in the project_rules field.""" """Project-scoped rules surface in the project_rules field."""
@@ -295,9 +291,7 @@ async def test_get_applicable_rules_includes_project_scoped_rules():
(fake_rule(id=101, topic_id=None, project_id=3, title="PR-bound", (fake_rule(id=101, topic_id=None, project_id=3, title="PR-bound",
statement="Land schema changes in their own PR."),), statement="Land schema changes in their own PR."),),
] ]
mock_session.execute = AsyncMock(side_effect=[ mock_session.execute = AsyncMock(side_effect=[_areas(), proj_rules_result])
_empty(), _empty(), _empty(), _empty(), _empty(), proj_rules_result,
])
_p1, _p2, _p3 = _no_edges() _p1, _p2, _p3 = _no_edges()
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3: with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3:
@@ -311,38 +305,28 @@ async def test_get_applicable_rules_includes_project_scoped_rules():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_applicable_rules_surfaces_suppressed_with_context(): async def test_a_co_surfaces_partner_on_another_project_is_not_dragged_in():
"""Suppressed rules and topics come back with full title + rulebook context """An edge is not a way into a project: a partner that is global or on this
so the UI can render them without an extra round-trip.""" project arrives; one on a different project does not."""
mock_session = make_mock_session() mock_session = make_mock_session()
suppressed_rules_result = MagicMock() proj_rules_result = MagicMock()
suppressed_rules_result.all.return_value = [ proj_rules_result.all.return_value = [(fake_rule(id=100, topic_id=None, project_id=3),)]
# (rule_id, title, topic_id, topic_title, rulebook_id, rulebook_title) mock_session.execute = AsyncMock(side_effect=[_areas(), proj_rules_result])
(17, "Old rule", 5, "old-topic", 1, "FabledSword family"), partners = [
fake_rule(id=200, topic_id=9, project_id=None, title="global partner"),
fake_rule(id=201, topic_id=None, project_id=3, title="same-project partner"),
fake_rule(id=202, topic_id=None, project_id=8, title="other-project partner"),
] ]
suppressed_topics_result = MagicMock()
suppressed_topics_result.all.return_value = [
# (topic_id, topic_title, rulebook_id, rulebook_title)
(22, "design-system", 1, "FabledSword family"),
]
mock_session.execute = AsyncMock(side_effect=[
# sub_q, suppressed_rules_q, suppressed_topics_q, project-areas_q,
# rules_q, proj_rules_q
_empty(), suppressed_rules_result, suppressed_topics_result,
_empty(), _empty(), _empty(),
])
_p1, _p2, _p3 = _no_edges() with patch("scribe.services.rulebooks.async_session") as mock_cls, \
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3: patch("scribe.services.rulebooks.co_surfaced_partners", AsyncMock(return_value=partners)), \
patch("scribe.services.rulebooks.list_rule_relations", AsyncMock(return_value={})), \
patch("scribe.services.rulebooks.list_rule_systems", AsyncMock(return_value={})):
mock_cls.return_value = mock_session mock_cls.return_value = mock_session
from scribe.services.rulebooks import get_applicable_rules from scribe.services.rulebooks import get_applicable_rules
result = await get_applicable_rules(project_id=3, user_id=7) result = await get_applicable_rules(project_id=3, user_id=7)
assert len(result["suppressed_rules"]) == 1 assert [r["id"] for r in result["rules"]] == [200, 201]
assert result["suppressed_rules"][0]["id"] == 17
assert result["suppressed_rules"][0]["rulebook_title"] == "FabledSword family"
assert len(result["suppressed_topics"]) == 1
assert result["suppressed_topics"][0]["title"] == "design-system"
# ── rule_brief (milestone 307) ────────────────────────────────────────── # ── rule_brief (milestone 307) ──────────────────────────────────────────
+4 -5
View File
@@ -44,16 +44,15 @@ async def test_delete_returns_none_when_not_found():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_project_cascades_to_notes_milestones_project_rules_and_suppressions(): async def test_delete_project_cascades_to_notes_milestones_and_project_rules():
session = make_mock_session() session = make_mock_session()
# exists-check + 6 cascade ops: # exists-check + 4 cascade ops:
# notes (soft) → milestones (soft) → project-scoped rules (soft) → # notes (soft) → milestones (soft) → project-scoped rules (soft) →
# project_rule_suppressions (hard DELETE) → project_topic_suppressions (hard DELETE) →
# project (soft) # project (soft)
# The two suppression hard-DELETEs went with their tables (milestone 414).
session.execute = AsyncMock(side_effect=[ session.execute = AsyncMock(side_effect=[
_exists_result(True), _exists_result(True),
MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock(),
MagicMock(), MagicMock(),
MagicMock(), MagicMock(),
]) ])
with patch("scribe.services.trash.async_session") as cls: with patch("scribe.services.trash.async_session") as cls:
@@ -61,7 +60,7 @@ async def test_delete_project_cascades_to_notes_milestones_project_rules_and_sup
from scribe.services.trash import delete from scribe.services.trash import delete
batch = await delete(user_id=1, entity_type="project", entity_id=3) batch = await delete(user_id=1, entity_type="project", entity_id=3)
assert isinstance(batch, str) assert isinstance(batch, str)
assert session.execute.await_count == 7 assert session.execute.await_count == 5
@pytest.mark.asyncio @pytest.mark.asyncio