Files
FabledScribe/frontend/src/stores/rulebooks.ts
T
bvandeusenandClaude Opus 5 4e4020c040
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 54s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 33s
feat(rules): move a rule between global and project scope, keeping its id, history, areas and edges (#4063)
A rule's home is its reach: a rulebook topic makes it global, a project makes it
that project's. There was no way to change one, so a project rule decided to be
global could only be recreated and the original trashed — losing the id every
record cites, its edit history, its area tags and its relations.

- services.rulebooks.move_rule(rule_id, user_id, topic_id= | project_id=):
  exactly one destination (the model's CHECK), owned by the caller, not the
  rule's current home. A topic already holding a live rule with the same title
  is refused with a message naming that rule, instead of uq_rule_per_topic
  failing the commit. Someone else's rule reads as not found.
- Deliberately NOT done, and said in the docstring: no version (a version is
  what a rule said, milestone 323 decision 4), no duplicate gate (nothing new
  enters the corpus), no re-embed (retrieval reads the home at query time).
- Both doors: MCP move_rule, REST POST /api/rules/<id>/move (rule 33).
- UI: RuleHomePicker, one component in the rule editor (a global rule) and a
  project's rules tab (a project rule), so the two cannot drift on what a
  destination is.
- using-scribe names move_rule under "Where a new rule goes". Plugin
  2026.09.15.1626.

Milestone 414 step 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-15 12:26:39 -04:00

223 lines
8.2 KiB
TypeScript

import { ref } from "vue";
import { defineStore } from "pinia";
import * as api from "@/api/rulebooks";
import type { Rulebook, RulebookTopic, Rule, RuleHeader } from "@/api/rulebooks";
import { useToastStore } from "@/stores/toast";
export const useRulebooksStore = defineStore("rulebooks", () => {
const rulebooks = ref<Rulebook[]>([]);
const topicsByRulebook = ref<Record<number, RulebookTopic[]>>({});
const rulesByTopic = ref<Record<number, RuleHeader[]>>({});
const currentRule = ref<Rule | null>(null);
const rulesDue = ref<api.RuleVerificationRow[]>([]);
// Kept so a verify re-reads the sweep with the SAME filters the operator is
// looking at — re-fetching unfiltered would silently widen the list under
// them at the moment they acted on it.
const lastSweepOpts = ref<{ olderThanDays?: number; neverOnly?: boolean }>({});
const loading = ref(false);
async function fetchRulebooks() {
loading.value = true;
try {
rulebooks.value = await api.listRulebooks();
} catch (e) {
useToastStore().show("Failed to load rulebooks", "error");
throw e;
} finally {
loading.value = false;
}
}
async function fetchTopics(rulebookId: number) {
try {
topicsByRulebook.value[rulebookId] = await api.listTopics(rulebookId);
} catch (e) {
useToastStore().show("Failed to load topics", "error");
throw e;
}
}
async function fetchRules(topicId: number) {
try {
const rules = await api.listRules({ topic_id: topicId });
rulesByTopic.value[topicId] = rules.map(toHeader);
} catch (e) {
useToastStore().show("Failed to load rules", "error");
throw e;
}
}
async function fetchRule(id: number) {
currentRule.value = await api.getRule(id);
}
async function createRulebook(data: { title: string; description?: string }) {
const rb = await api.createRulebook(data);
rulebooks.value.push(rb);
return rb;
}
async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description">>) {
const rb = await api.updateRulebook(id, data);
const idx = rulebooks.value.findIndex((r) => r.id === id);
if (idx >= 0) rulebooks.value[idx] = rb;
return rb;
}
async function deleteRulebook(id: number) {
await api.deleteRulebook(id);
rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
delete topicsByRulebook.value[id];
}
async function createTopic(rulebookId: number, data: { title: string; description?: string }) {
const topic = await api.createTopic(rulebookId, data);
if (!topicsByRulebook.value[rulebookId]) topicsByRulebook.value[rulebookId] = [];
topicsByRulebook.value[rulebookId].push(topic);
return topic;
}
async function updateTopic(id: number, data: Partial<Pick<RulebookTopic, "title" | "description" | "order_index">>) {
const topic = await api.updateTopic(id, data);
for (const rbId of Object.keys(topicsByRulebook.value)) {
const list = topicsByRulebook.value[Number(rbId)];
const idx = list.findIndex((t) => t.id === id);
if (idx >= 0) list[idx] = topic;
}
return topic;
}
async function deleteTopic(id: number) {
await api.deleteTopic(id);
for (const rbId of Object.keys(topicsByRulebook.value)) {
topicsByRulebook.value[Number(rbId)] = topicsByRulebook.value[Number(rbId)].filter((t) => t.id !== id);
}
delete rulesByTopic.value[id];
}
/**
* A list row built from a full rule. The row shape is the server's
* rule_brief, so every field it carries has to be mirrored here or the two
* disagree the moment a list is patched locally instead of re-fetched.
*/
function toHeader(rule: Rule): api.RuleHeader {
return {
id: rule.id,
title: rule.title,
statement: rule.statement,
topic_id: rule.topic_id,
updated_at: rule.updated_at,
when_to_apply: rule.when_to_apply || undefined,
arose_from_id: rule.arose_from_id ?? undefined,
// Mirrors services.rulebooks.last_verified_label: present ONLY when the
// rule carries a check, and "never" rather than absent when it has one
// nobody has run. Computed here so a row just written looks identical to
// the same row re-fetched, instead of losing its chip until a reload.
last_verified: rule.verify_with
? (rule.verified_at ? rule.verified_at.slice(0, 10) : "never")
: undefined,
};
}
async function createRule(topicId: number, data: Partial<api.RuleWrite> & { title: string; statement: string }) {
const rule = await api.createRule(topicId, data);
if (!rulesByTopic.value[topicId]) rulesByTopic.value[topicId] = [];
rulesByTopic.value[topicId].push(toHeader(rule));
return rule;
}
async function updateRule(id: number, data: Partial<api.RuleWrite>) {
const rule = await api.updateRule(id, data);
if (currentRule.value?.id === id) currentRule.value = rule;
for (const tid of Object.keys(rulesByTopic.value)) {
const list = rulesByTopic.value[Number(tid)];
const idx = list.findIndex((r) => r.id === id);
if (idx >= 0) list[idx] = toHeader(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(
fromRuleId: number,
data: { to_rule_id: number; kind: api.RuleRelationKind; note?: string },
) {
await api.relateRules(fromRuleId, data);
// Re-read rather than patching locally: the edge reads from BOTH ends, so
// the far rule's relations changed too and a local splice would show only
// half of what just happened.
await fetchRule(fromRuleId);
}
async function unrelateRules(relationId: number, refreshRuleId: number) {
await api.unrelateRules(relationId);
await fetchRule(refreshRuleId);
}
/** The staleness sweep: rules asserting a fact, oldest verification first. */
async function fetchRulesDue(opts: {
olderThanDays?: number; neverOnly?: boolean;
} = {}) {
loading.value = true;
lastSweepOpts.value = opts;
try {
const data = await api.listRulesDueForVerification(opts);
rulesDue.value = data.rules;
} finally {
loading.value = false;
}
}
/**
* Record that a rule's check was RUN, and what it said.
*
* A pass re-sorts the row to the back of the sweep, so the list is re-read
* rather than patched: the whole point of this surface is an ORDER, and a
* locally-mutated row would sit in its old position claiming a new date.
* A failure writes nothing server-side and the row keeps its place — also
* correct, and also what a re-read shows.
*/
async function verifyRule(id: number, stillTrue: boolean) {
const rule = await api.markRuleVerified(id, stillTrue);
if (currentRule.value?.id === id) currentRule.value = rule;
for (const tid of Object.keys(rulesByTopic.value)) {
const list = rulesByTopic.value[Number(tid)];
const idx = list.findIndex((r) => r.id === id);
if (idx >= 0) list[idx] = toHeader(rule);
}
if (rulesDue.value.length) await fetchRulesDue(lastSweepOpts.value);
return rule;
}
async function deleteRule(id: number) {
await api.deleteRule(id);
if (currentRule.value?.id === id) currentRule.value = null;
for (const tid of Object.keys(rulesByTopic.value)) {
rulesByTopic.value[Number(tid)] = rulesByTopic.value[Number(tid)].filter((r) => r.id !== id);
}
}
return {
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
placeMovedRule,
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
createRulebook, updateRulebook, deleteRulebook,
createTopic, updateTopic, deleteTopic,
createRule, updateRule, deleteRule, relateRules, unrelateRules,
fetchRulesDue, verifyRule,
};
});