feat(rules)!: retire rulebook subscriptions and per-project suppressions (#4052)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / Python tests (push) Failing after 1m3s
CI & Build / Build & push image (push) Skipped

A rule's home is its scope now: a rule in a rulebook topic is global, a rule on
a project applies to that project, and retrieval reads that directly (#4074).
A subscription had stopped changing anything a session received; a suppression
muted rules from a subscription. Operator, 2026-09-15: "we have global and
project scoped rules, we don't need the subscriptions now."

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

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

Milestone 414 step 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-15 12:20:57 -04:00
co-authored by Claude Opus 5
parent 188e78bbcd
commit 0bcd4b5540
43 changed files with 579 additions and 1610 deletions
@@ -1,12 +1,11 @@
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { ref } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import { apiGet } from "@/api/client";
import {
subscribeProject, unsubscribeProject, getProjectApplicableRules,
} from "@/api/rulebooks";
import type { RulebookTopic } from "@/api/rulebooks";
// A rulebook's rules are global — they apply to every project — so this pane
// lists topics and nothing else. It carried a "Subscribers" checklist of
// projects until milestone 414 retired subscriptions.
const props = defineProps<{
rulebookId: number;
topics: RulebookTopic[];
@@ -18,42 +17,6 @@ const store = useRulebooksStore();
const isCreating = ref(false);
const newTitle = ref("");
interface ProjectLite { id: number; title: string }
const projects = ref<ProjectLite[]>([]);
// Map<project_id, Set<rulebook_id>>
const subscribedRulebookIds = ref<Map<number, Set<number>>>(new Map());
async function loadProjects() {
const data = await apiGet<{ projects: ProjectLite[] }>("/api/projects");
projects.value = data.projects;
for (const p of projects.value) {
const result = await getProjectApplicableRules(p.id);
subscribedRulebookIds.value.set(
p.id,
new Set(result.subscribed_rulebooks.map((rb) => rb.id)),
);
}
}
function isSubscribed(projectId: number): boolean {
return subscribedRulebookIds.value.get(projectId)?.has(props.rulebookId) ?? false;
}
async function toggleSubscription(projectId: number, checked: boolean) {
if (checked) {
await subscribeProject(projectId, props.rulebookId);
const set = subscribedRulebookIds.value.get(projectId) || new Set<number>();
set.add(props.rulebookId);
subscribedRulebookIds.value.set(projectId, set);
} else {
await unsubscribeProject(projectId, props.rulebookId);
subscribedRulebookIds.value.get(projectId)?.delete(props.rulebookId);
}
// trigger reactivity on Map mutation
subscribedRulebookIds.value = new Map(subscribedRulebookIds.value);
}
async function submitNew() {
const title = newTitle.value.trim();
if (!title) return;
@@ -63,8 +26,6 @@ async function submitNew() {
emit("select-topic", topic.id);
}
onMounted(loadProjects);
watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing map */});
</script>
<template>
@@ -92,21 +53,6 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
</div>
</form>
</div>
<div class="subscriptions">
<h3>Subscribers</h3>
<ul class="sub-list">
<li v-for="p in projects" :key="p.id">
<label>
<input
type="checkbox"
:checked="isSubscribed(p.id)"
@change="toggleSubscription(p.id, ($event.target as HTMLInputElement).checked)"
/>
{{ p.title }}
</label>
</li>
</ul>
</div>
</section>
</template>
@@ -117,24 +63,13 @@ ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li.active { background: var(--fs-accent-soft); }
li:hover { background: var(--fs-surface-hover); }
/* `.new-topic` and `.sub-list` are deliberately bare (#2444). The first wraps a
button-or-form whose children style themselves; the second is a `<ul>`, and
the bare `ul` rule above already gives it list-style, padding and margin —
a base a class-name check cannot see, since it comes from an element
selector. Both namespace descendant rules and assume nothing about layout. */
/* `.new-topic` is deliberately bare (#2444): it wraps a button-or-form whose
children style themselves, and namespaces the descendant rule below. */
.new-topic input {
width: 100%; margin-bottom: 0.5rem;
background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.5rem;
}
.subscriptions {
margin-top: 2rem;
border-top: 1px solid var(--fs-border-color);
padding-top: 1rem;
}
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
.sub-list li { cursor: default; }
.sub-list label { display: flex; gap: 0.5rem; align-items: center; cursor: pointer; }
button { cursor: pointer; }
</style>