Files
FabledScribe/frontend/src/stores/rulebooks.ts
T
bvandeusenandClaude Opus 5 7038e41ec7
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m32s
CI & Build / Build & push image (push) Successful in 34s
feat(rules): preferences are writable, and their drift arrives (#3895)
Milestone 399 step 5. Steps 1-4 put preferences into the backend: a kind
column, an inverted write path, a third register in the injected block, a
delivery slot. Nothing the operator could touch. Rule 27 forbids leaving it
there, and here it matters more than usual, because the UI is the only
guard against the risk the milestone named up front — an agent misreads one
session, rewrites a preference, and follows the rewritten version forever
while the operator never sees the moment it changed.

Four things ship.

A preference is DISTINGUISHABLE. `kind` reaches the client (the server has
always sent it in rule_brief) and a preference carries a chip. Force is the
one thing a list of instructions must not leave the reader to infer, and a
row that renders identically to a rule teaches the opposite of both facts
about a preference: it does not bind, and a session may rewrite it.

A preference is WRITABLE. The editor gains the kind as a first-class choice
with the test beside it — what happens when someone does not do this — and
says plainly, when preference is chosen, that sessions rewrite these
without asking and every rewrite is kept.

DRIFT ARRIVES. `GET /api/rules/drift` returns one row per rewritten
preference carrying its latest rewrite: what it said, what it says now, and
the record named by `arose_from_id` that taught the change. Both texts ride
along so the list shows the diff without a call per row. The new pane sits
beside the staleness sweep, because drift belongs to no one rulebook, and
it answers a question the operator would not have thought to ask.

REVERSION IS ONE ACTION, and this is the carve-out worth arguing with.
Milestone 323 refused a one-click restore for rules — "a binding
instruction should not be revertible in one click", because a silent revert
erases the only record of why the rewrite happened. That reasoning turns on
the rewrite being the operator's own decision. A preference's is not: the
agent makes it mid-work without asking, so reverting is a veto over someone
else's edit rather than an undo of your own, and a veto costing more than a
shrug is not supervision. The route refuses anything but a preference (409),
and nothing is erased: the restore goes through update_rule, so it snapshots
too and the history GAINS the revert. An integration test pins that, because
it is the whole basis for the exception.

Tested against real Postgres — every claim is about which rows come back
and in what order, which a stand-in session cannot judge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-18 12:39:13 -04:00

269 lines
10 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[]>([]);
const drift = ref<api.PreferenceDrift[]>([]);
// 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,
// Carried, not defaulted: a row written here must render with the same
// force as the same row re-fetched, or a preference the operator just
// created would sit in the list looking like a rule until a reload.
kind: rule.kind,
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;
}
/**
* What Scribe has changed about how it works with the operator.
*
* Preferences only — a rule changes when its author changes it, so
* including them would bury the unreviewed rows under the operator's own
* edits, which is the failure this surface exists to prevent.
*/
async function fetchDrift(limit?: number) {
loading.value = true;
try {
drift.value = await api.listPreferenceDrift(limit);
} catch (e) {
useToastStore().show("Failed to load recent changes", "error");
throw e;
} finally {
loading.value = false;
}
}
/**
* Put a preference back to what a version said — the operator's veto.
*
* The drift list is RE-READ rather than patched, for the reason the sweep
* is: this surface is an ORDER (most recently changed first) and the
* restore is itself an edit, so the row's place in that order has just
* changed. A locally-mutated row would sit in its old position describing
* a rewrite that is no longer the latest one.
*/
async function restoreVersion(ruleId: number, versionId: number) {
const rule = await api.restoreRuleVersion(ruleId, versionId);
if (currentRule.value?.id === ruleId) currentRule.value = rule;
for (const tid of Object.keys(rulesByTopic.value)) {
const list = rulesByTopic.value[Number(tid)];
const idx = list.findIndex((r) => r.id === ruleId);
if (idx >= 0) list[idx] = toHeader(rule);
}
if (drift.value.length) await fetchDrift();
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, drift, lastSweepOpts, loading,
placeMovedRule,
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
createRulebook, updateRulebook, deleteRulebook,
createTopic, updateTopic, deleteTopic,
createRule, updateRule, deleteRule, relateRules, unrelateRules,
fetchRulesDue, verifyRule,
fetchDrift, restoreVersion,
};
});