From 7038e41ec71ec14be85a907a041862027f64cb16 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 18 Sep 2026 12:39:13 -0400 Subject: [PATCH] feat(rules): preferences are writable, and their drift arrives (#3895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy --- frontend/src/api/rulebooks.ts | 69 ++++- frontend/src/assets/rules-shared.css | 13 + .../components/rules/PreferenceDriftPane.vue | 223 ++++++++++++++ .../components/rules/RuleEditorSlideOver.vue | 51 ++- .../src/components/rules/RuleListPane.vue | 18 +- .../src/components/rules/RulebookListPane.vue | 24 +- frontend/src/stores/rulebooks.ts | 48 ++- frontend/src/views/RulesView.vue | 30 +- src/scribe/routes/rulebooks.py | 52 +++- src/scribe/services/rulebooks.py | 210 ++++++++++++- tests/test_integration_preference_drift.py | 291 ++++++++++++++++++ 11 files changed, 1008 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/rules/PreferenceDriftPane.vue create mode 100644 tests/test_integration_preference_drift.py 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);