feat(rules): move a rule between global and project scope, keeping its id, history, areas and edges (#4063)
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

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
This commit is contained in:
2026-09-15 12:26:39 -04:00
co-authored by Claude Opus 5
parent b6751e4214
commit 4e4020c040
14 changed files with 471 additions and 6 deletions
+8
View File
@@ -198,6 +198,14 @@ export async function updateRule(id: number, data: Partial<RuleWrite>): Promise<
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. */
export async function relateRules(
fromRuleId: number,
@@ -8,6 +8,7 @@ import {
deleteRule,
} 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:
@@ -206,6 +207,12 @@ watch(() => props.projectId, load);
<div v-if="ruleDetails[r.id].expires_when">
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
</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>
</div>
</li>
@@ -3,6 +3,8 @@ import { computed, ref, watch, onMounted } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
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 emit = defineEmits<{ close: [] }>();
@@ -115,6 +117,14 @@ async function save() {
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() {
if (props.ruleId === null) return;
if (!confirm("Delete this rule? This cannot be undone.")) return;
@@ -210,6 +220,14 @@ watch(() => props.ruleId, load);
</p>
</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">
<h3>Related rules</h3>
<ul>
@@ -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>
+15
View File
@@ -137,6 +137,20 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
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 },
@@ -198,6 +212,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
return {
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
placeMovedRule,
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
createRulebook, updateRulebook, deleteRulebook,
createTopic, updateTopic, deleteTopic,