feat(rules): preferences are writable, and their drift arrives (#3895)
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

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
This commit is contained in:
2026-09-18 12:39:13 -04:00
co-authored by Claude Opus 5
parent 94ecb633a0
commit 7038e41ec7
11 changed files with 1008 additions and 21 deletions
@@ -0,0 +1,223 @@
<script setup lang="ts">
/**
* What Scribe has changed about how it works with you.
*
* THE RISK THIS EXISTS FOR (milestone 399). A preference is the one record
* kind the agent rewrites on its own, mid-work, without asking — which is
* what keeps it current and what makes it dangerous. An agent misreads one
* session, rewrites a preference, and follows the rewritten version forever
* while the operator never sees the moment it changed. That is worse than
* having no preference at all: a confident wrong answer wearing the
* operator's own authority.
*
* `rule_versions` already recorded every rewrite. What it could not do is
* ARRIVE. A history you open one rule at a time, having first suspected that
* rule, is not oversight — so this pane is the PUSH half, and it sits beside
* the staleness sweep for the same reason that does: drift belongs to no one
* rulebook.
*
* Cross-cutting, and deliberately not a filter on the per-topic rule list —
* that list shows one topic of one rulebook, so filtering it would silently
* under-report, which is the exact failure this surface exists to catch.
*/
import { onMounted, ref } from "vue";
import DiffView from "@/components/DiffView.vue";
import { computeDiff } from "@/utils/diff";
import { useRulebooksStore } from "@/stores/rulebooks";
import { useToastStore } from "@/stores/toast";
import type { PreferenceDrift } from "@/api/rulebooks";
const emit = defineEmits<{ "open-rule": [id: number] }>();
const store = useRulebooksStore();
const toast = useToastStore();
const openId = ref<number | null>(null);
const busyId = ref<number | null>(null);
/** Old on the left, new on the right — the direction a reader expects of
* "what changed", and the opposite of the rule history panel, which is
* answering "what did it used to say" from the current text backwards. */
function diffFor(row: PreferenceDrift) {
return computeDiff(row.previous.statement, row.current.statement);
}
function triggerChanged(row: PreferenceDrift): boolean {
return row.previous.when_to_apply !== row.current.when_to_apply;
}
function stamp(iso: string | null): string {
return iso ? iso.slice(0, 10) : "";
}
function toggle(row: PreferenceDrift) {
openId.value = openId.value === row.rule.id ? null : row.rule.id;
}
/** The veto. One action, because a veto that costs more than shrugging is
* not really supervision — and nothing is lost either way: the restore is
* itself an edit, so the rewrite stays in the preference's history with the
* revert recorded after it. */
async function restore(row: PreferenceDrift) {
busyId.value = row.rule.id;
try {
await store.restoreVersion(row.rule.id, row.previous.id);
toast.show(`Put “${row.previous.title}” back`, "success");
openId.value = null;
} catch {
toast.show("Could not put that wording back", "error");
} finally {
busyId.value = null;
}
}
onMounted(() => store.fetchDrift());
</script>
<template>
<section class="pane drift">
<header>
<h2>Recent changes</h2>
<p class="lede">
Preferences Scribe rewrote while working, most recently changed first. A
preference is how you want work done, so sessions keep it current
without asking this is where you see what they decided. Rules are not
here: those change when you change them.
</p>
</header>
<p v-if="store.loading" class="state">Loading</p>
<!-- Nothing changed is the ordinary state and must not read as a fault. -->
<p v-else-if="!store.drift.length" class="state empty">
Nothing has been rewritten. A preference appears here the first time a
session changes one until then there is nothing to review.
</p>
<ol v-else class="rows">
<li v-for="row in store.drift" :key="row.rule.id" class="row">
<div class="row-head">
<button class="row-title" @click="emit('open-rule', row.rule.id)">
{{ row.rule.title }}
</button>
<span class="when">{{ stamp(row.previous.created_at) }}</span>
</div>
<!-- The provenance, named rather than numbered: a bare id reads as
complete to the writer and as homework to the reader. -->
<p v-if="row.taught_by" class="taught">
Learned from <em>{{ row.taught_by.title }}</em>
<span class="taught-id">#{{ row.taught_by.id }}</span>
</p>
<p v-else class="taught untaught">
Nothing recorded what taught this change.
</p>
<button class="expand" :aria-expanded="openId === row.rule.id" @click="toggle(row)">
{{ openId === row.rule.id ? "Hide what changed" : "See what changed" }}
</button>
<div v-if="openId === row.rule.id" class="detail">
<p v-if="triggerChanged(row)" class="trigger-moved">
Its trigger changed too, so it now arrives at a different moment.
<span class="was">Was:</span> {{ row.previous.when_to_apply || "nothing" }}
</p>
<DiffView v-if="diffFor(row).length" :diff="diffFor(row)" />
<p v-else class="state">
The statement is unchanged this edit moved another field.
</p>
<div class="actions">
<button
:disabled="busyId === row.rule.id"
@click="restore(row)"
>Put the old wording back</button>
<span class="actions-note">
Kept, not erased: this is recorded as another edit, so both
wordings stay in the preference's history.
</span>
</div>
</div>
</li>
</ol>
<p v-if="store.drift.length" class="footnote">
One row per preference, carrying its latest rewrite. A preference changed
several times shows the most recent one its full history is in its
editor, under <strong>Edit history</strong>.
</p>
</section>
</template>
<style src="@/assets/rules-shared.css" />
<style scoped>
.drift { display: flex; flex-direction: column; gap: var(--fs-space-3); }
.lede {
margin: 0; max-width: 62ch; font-size: var(--fs-size-body-sm);
color: var(--fs-text-secondary); line-height: var(--fs-leading-body);
}
.state { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
.state.empty { color: var(--fs-text-tertiary); }
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-3); }
.row {
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-3);
}
.row-head { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
.row-title {
background: none; border: none; padding: 0; cursor: pointer;
font-family: Fraunces, serif; font-style: italic; font-size: 1.02rem;
color: var(--fs-text-primary); text-align: left;
}
.row-title:hover { text-decoration: underline; }
.when {
margin-left: auto; font-size: var(--fs-size-tiny);
color: var(--fs-text-secondary); font-variant-numeric: tabular-nums;
}
.taught {
margin: var(--fs-space-2) 0 0; font-size: var(--fs-size-tiny);
color: var(--fs-text-secondary); line-height: var(--fs-leading-body);
}
.taught em { font-style: italic; color: var(--fs-text-primary); }
.taught-id { margin-left: 0.35rem; color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; }
/* Not a warning: every preference written before provenance was required has
none, and marking those as faults would cry wolf on the whole backlog. */
.taught.untaught { color: var(--fs-text-tertiary); font-style: italic; }
.expand {
align-self: flex-start; margin-top: var(--fs-space-2);
background: none; border: none; padding: 0; cursor: pointer;
font: inherit; font-size: var(--fs-size-tiny); color: var(--fs-text-secondary);
}
.expand:hover { color: var(--fs-text-primary); text-decoration: underline; }
.detail { margin-top: var(--fs-space-2); display: flex; flex-direction: column; gap: var(--fs-space-2); }
/* A TINT, not the solid token — `--fs-warning-fg` is defined as warning text
ON a warning tint, and painting it over solid `--fs-warning` is the
same-hue contrast failure #3141 records. */
.trigger-moved {
margin: 0; font-size: var(--fs-size-tiny); line-height: var(--fs-leading-body);
color: var(--fs-warning-fg);
background: color-mix(in srgb, var(--fs-warning) 12%, transparent);
border-radius: var(--fs-radius-sm); padding: var(--fs-space-2);
}
.was { color: var(--fs-text-tertiary); }
.actions { display: flex; align-items: baseline; gap: var(--fs-space-3); flex-wrap: wrap; }
.actions button {
cursor: pointer; font: inherit; font-size: 0.78rem;
background: var(--fs-surface-page); color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
padding: 0.25rem 0.6rem;
}
.actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
.actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
.actions-note {
flex: 1; min-width: 18ch; font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary); line-height: var(--fs-leading-body);
}
.footnote { margin: 0; max-width: 62ch; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); }
</style>