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

What does this project inherit?

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

Loading…

{{ error }}