diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index 5681571..331bd34 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -39,12 +39,27 @@ export interface RulebookTopic { updated_at: string | null; } +/** + * What kind of instruction this is, and it is about FORCE, not importance. + * + * A `rule` must be FOLLOWED — ignoring it breaks something. It is the + * operator's decision, so it changes when they change it. A `preference` is + * how they want work DONE — ignoring it costs consistency, not correctness — + * and the agent rewrites it in the ordinary course of working, which is what + * makes the drift surface necessary. + * + * Always sent by the server, never inferred from an absent key: "no kind + * field" and "kind is rule" must not be the same payload. + */ +export type RuleKind = "rule" | "preference"; + export interface Rule { id: number; topic_id: number | null; project_id: number | null; title: string; statement: string; + kind: RuleKind; /** WHEN this rule fires — the trigger, not the instruction. */ when_to_apply: string; why: string; @@ -79,6 +94,8 @@ export interface RuleHeader { title: string; statement: string; topic_id: number | null; + /** Unconditional on the wire (services.rulebooks.rule_brief). */ + kind: RuleKind; /** A date (YYYY-MM-DD), not a timestamp. */ updated_at: string | null; when_to_apply?: string; @@ -180,6 +197,9 @@ export async function getRule(id: number): Promise { export interface RuleWrite { title: string; statement: string; + /** Writable from the editor: a preference is not a lesser rule, it is a + * different force, and the person writing it is the one who knows which. */ + kind: RuleKind; when_to_apply: string; why: string; how_to_apply: string; @@ -256,9 +276,56 @@ export async function getRuleVersion( return apiGet(`/api/rules/${ruleId}/versions/${versionId}`); } -// No restoreRuleVersion, deliberately (milestone 323). Putting an old wording +// No restore FOR A RULE, deliberately (milestone 323). Putting an old wording // back goes through updateRule, which snapshots what it replaces — so the // undo stays visible in the history like any other edit. +// +// A preference is the exception and the server refuses anything else (409). +// 323's reasoning is that a rewrite is the operator's own decision; a +// preference's rewrite is the agent's, made mid-work without asking, so +// putting it back is a veto rather than an undo — and a veto that costs more +// than shrugging is not really supervision. Nothing is erased either way: +// the restore snapshots too, so the history GAINS the revert. +export async function restoreRuleVersion( + ruleId: number, versionId: number, +): Promise { + return apiPost(`/api/rules/${ruleId}/versions/${versionId}/restore`, {}); +} + +/** + * One preference that has been rewritten: what it said, what it says now, and + * what taught the change. + * + * Both texts ride along so the list can show the diff without a follow-up call + * per row — a listing that needs N round-trips to say what it means is one + * nobody scrolls, which would leave the drift as unsupervised as before. + */ +export interface PreferenceDrift { + rule: RuleHeader; + /** What it said BEFORE the latest rewrite. */ + previous: { + id: number; + created_at: string | null; + title: string; + statement: string; + when_to_apply: string; + }; + current: { title: string; statement: string; when_to_apply: string }; + /** + * The record named by `arose_from_id` — what the change was learned from. + * Absent when the preference carries none, which is every one written + * before that field was required. + */ + taught_by?: { id: number; title: string }; +} + +export async function listPreferenceDrift( + limit?: number, +): Promise { + const qs = limit ? `?limit=${limit}` : ""; + const data = await apiGet<{ drift: PreferenceDrift[] }>(`/api/rules/drift${qs}`); + return data.drift; +} export async function deleteRule(id: number): Promise { return apiDelete(`/api/rules/${id}`); diff --git a/frontend/src/assets/rules-shared.css b/frontend/src/assets/rules-shared.css index f2345f3..f521439 100644 --- a/frontend/src/assets/rules-shared.css +++ b/frontend/src/assets/rules-shared.css @@ -40,3 +40,16 @@ padding: 0.05rem 0.4rem; vertical-align: middle; } + +/* A PREFERENCE, marked because force is the one thing a list of instructions + must not leave the reader to infer. A preference does not bind — ignoring it + costs consistency, not correctness — and it is the one kind the agent + rewrites on its own, so a row that renders identically to a rule teaches the + opposite of both facts. + + The accent, not the warning colour: nothing is wrong with a preference. It + is a different KIND, and the marker says which. */ +.rule-chip-preference { + color: var(--fs-accent); + background: var(--fs-accent-soft); +} diff --git a/frontend/src/components/rules/PreferenceDriftPane.vue b/frontend/src/components/rules/PreferenceDriftPane.vue new file mode 100644 index 0000000..187e0cf --- /dev/null +++ b/frontend/src/components/rules/PreferenceDriftPane.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/frontend/src/components/rules/RuleEditorSlideOver.vue b/frontend/src/components/rules/RuleEditorSlideOver.vue index 955892c..19ebc16 100644 --- a/frontend/src/components/rules/RuleEditorSlideOver.vue +++ b/frontend/src/components/rules/RuleEditorSlideOver.vue @@ -4,7 +4,7 @@ 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"; +import type { Rule, RuleKind } from "@/api/rulebooks"; const props = defineProps<{ ruleId: number | null; topicId: number | null }>(); const emit = defineEmits<{ close: [] }>(); @@ -14,6 +14,11 @@ const canon = useCanonicalSystemsStore(); const title = ref(""); const statement = ref(""); const whenToApply = ref(""); +// Defaults to `rule`, matching the server's column default. The safe +// direction is the one that binds: a preference mislabelled as a rule is +// followed too faithfully, where a rule mislabelled as a preference is one a +// session may quietly rewrite. +const kind = ref("rule"); const systemIds = ref([]); const why = ref(""); const howToApply = ref(""); @@ -70,6 +75,7 @@ async function load() { title.value = r.title; statement.value = r.statement; whenToApply.value = r.when_to_apply || ""; + kind.value = r.kind; systemIds.value = (r.systems ?? []).map((sys) => sys.id); why.value = r.why || ""; howToApply.value = r.how_to_apply || ""; @@ -80,6 +86,7 @@ async function load() { title.value = ""; statement.value = ""; whenToApply.value = ""; + kind.value = "rule"; systemIds.value = []; why.value = ""; howToApply.value = ""; @@ -98,6 +105,7 @@ async function save() { title: title.value, statement: statement.value, when_to_apply: whenToApply.value, + kind: kind.value, // Always sent, so clearing the last area actually clears it — the server // reads a list as "these ARE the areas now". system_ids: systemIds.value, @@ -140,7 +148,7 @@ watch(() => props.ruleId, load);