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
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:
@@ -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<Rule> {
|
||||
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<RuleVersion>(`/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<Rule> {
|
||||
return apiPost<Rule>(`/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<PreferenceDrift[]> {
|
||||
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<void> {
|
||||
return apiDelete(`/api/rules/${id}`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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<RuleKind>("rule");
|
||||
const systemIds = ref<number[]>([]);
|
||||
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);
|
||||
<div class="backdrop" @click="save">
|
||||
<aside class="slide-over" @click.stop>
|
||||
<header>
|
||||
<h2>{{ isCreating ? "New rule" : "Edit rule" }}</h2>
|
||||
<h2>{{ `${isCreating ? "New" : "Edit"} ${kind === "preference" ? "preference" : "rule"}` }}</h2>
|
||||
<button v-if="!isCreating" class="trash" @click="remove" aria-label="Delete">🗑</button>
|
||||
<button class="close" @click="save" aria-label="Close">×</button>
|
||||
</header>
|
||||
@@ -148,6 +156,34 @@ watch(() => props.ruleId, load);
|
||||
Title
|
||||
<input v-model="title" placeholder="e.g. dev is home" />
|
||||
</label>
|
||||
<fieldset class="kind">
|
||||
<legend>What kind of instruction is this?</legend>
|
||||
<label class="kind-opt">
|
||||
<input v-model="kind" type="radio" value="rule" />
|
||||
<span>
|
||||
<strong>Rule</strong> — must be followed. Ignoring it breaks
|
||||
something or crosses a boundary. It changes when you change it.
|
||||
</span>
|
||||
</label>
|
||||
<label class="kind-opt">
|
||||
<input v-model="kind" type="radio" value="preference" />
|
||||
<span>
|
||||
<strong>Preference</strong> — how you want work done. Ignoring it
|
||||
costs consistency, not correctness, and <em>Scribe updates it as
|
||||
the work teaches it</em>.
|
||||
</span>
|
||||
</label>
|
||||
<p class="field-note">
|
||||
The test is what happens when someone does not do it. Something
|
||||
breaks — a rule. The tenth time goes differently from the ninth — a
|
||||
preference.
|
||||
</p>
|
||||
</fieldset>
|
||||
<p v-if="kind === 'preference'" class="kind-drift">
|
||||
Sessions rewrite preferences without asking, which is what makes them
|
||||
stay current. Every rewrite is kept, and
|
||||
<strong>Recent changes</strong> lists them with what taught each one.
|
||||
</p>
|
||||
<label>
|
||||
Statement <span class="required">*</span>
|
||||
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||||
@@ -267,6 +303,17 @@ watch(() => props.ruleId, load);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kind { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: var(--fs-space-3); margin: var(--fs-space-3) 0; }
|
||||
.kind legend { font-size: var(--fs-size-tiny); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); color: var(--fs-text-tertiary); padding: 0 var(--fs-space-2); }
|
||||
.kind-opt { display: flex; align-items: flex-start; gap: var(--fs-space-2); margin-bottom: var(--fs-space-2); font-size: var(--fs-size-body-sm); line-height: var(--fs-leading-body); }
|
||||
.kind-opt input { margin-top: 0.2rem; accent-color: var(--fs-accent); flex: none; }
|
||||
.kind-drift {
|
||||
margin: 0 0 var(--fs-space-3); padding: var(--fs-space-2);
|
||||
font-size: var(--fs-size-tiny); line-height: var(--fs-leading-body);
|
||||
color: var(--fs-text-secondary);
|
||||
background: var(--fs-accent-soft); border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
|
||||
@@ -24,8 +24,18 @@ const emit = defineEmits<{
|
||||
<header><h2>Rules</h2></header>
|
||||
<ul>
|
||||
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
||||
<div class="title">
|
||||
<div class="title" :class="{ 'is-preference': r.kind === 'preference' }">
|
||||
{{ r.title }}
|
||||
<!-- Force is stated, never inferred. A preference does not bind and
|
||||
is the one kind a session rewrites on its own, so an unmarked
|
||||
row would teach the opposite of both. Rules carry no chip:
|
||||
they are the default reading of a rulebook, and marking every
|
||||
row marks nothing. -->
|
||||
<span
|
||||
v-if="r.kind === 'preference'"
|
||||
class="rule-chip rule-chip-preference"
|
||||
title="How you want work done. It does not bind, and Scribe updates it as the work teaches it."
|
||||
>preference</span>
|
||||
<!-- Marked only when something is WRONG: every rule arrives by
|
||||
retrieval now, so "conditional" stopped distinguishing anything.
|
||||
A missing trigger does — it means nothing can retrieve this. -->
|
||||
@@ -70,6 +80,12 @@ li {
|
||||
}
|
||||
li:hover { background: var(--fs-surface-hover); }
|
||||
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
||||
/* The chip says which kind; this says it again at a glance, for scanning a
|
||||
long topic rather than reading one row. Weight, not colour — the chip
|
||||
already carries the accent, and a second coloured thing would compete
|
||||
with it for the same job. */
|
||||
.title.is-preference { font-weight: 500; }
|
||||
|
||||
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
||||
.meta { display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.35rem; font-size: 0.75em; }
|
||||
.trigger { flex: 1; min-width: 0; color: var(--fs-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
@@ -3,8 +3,17 @@ import { ref } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import type { Rulebook } from "@/api/rulebooks";
|
||||
|
||||
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null; sweepActive: boolean }>();
|
||||
const emit = defineEmits<{ select: [id: number]; "select-sweep": [] }>();
|
||||
defineProps<{
|
||||
rulebooks: Rulebook[];
|
||||
selectedId: number | null;
|
||||
sweepActive: boolean;
|
||||
driftActive: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
select: [id: number];
|
||||
"select-sweep": [];
|
||||
"select-drift": [];
|
||||
}>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const isCreating = ref(false);
|
||||
@@ -44,6 +53,17 @@ async function submitNew() {
|
||||
>
|
||||
Due for verification
|
||||
</button>
|
||||
<!-- The other cross-cutting view, and the one the operator would not think
|
||||
to ask for: a preference is rewritten by the agent rather than by them,
|
||||
so "what changed" has no rulebook to look in and no reason to be
|
||||
suspected in the first place. -->
|
||||
<button
|
||||
class="sweep-entry"
|
||||
:class="{ active: driftActive }"
|
||||
@click="emit('select-drift')"
|
||||
>
|
||||
Recent changes
|
||||
</button>
|
||||
|
||||
<div class="new-rulebook">
|
||||
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
|
||||
|
||||
@@ -10,6 +10,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
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.
|
||||
@@ -106,6 +107,10 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
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,
|
||||
@@ -202,6 +207,46 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
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;
|
||||
@@ -211,12 +256,13 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
}
|
||||
|
||||
return {
|
||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import RulebookDetailPane from "@/components/rules/RulebookDetailPane.vue";
|
||||
import RuleListPane from "@/components/rules/RuleListPane.vue";
|
||||
import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue";
|
||||
import RuleSweepPane from "@/components/rules/RuleSweepPane.vue";
|
||||
import PreferenceDriftPane from "@/components/rules/PreferenceDriftPane.vue";
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const route = useRoute();
|
||||
@@ -17,6 +18,7 @@ const selectedTopicId = ref<number | null>(null);
|
||||
const editingRuleId = ref<number | null>(null);
|
||||
const creatingRuleForTopic = ref<number | null>(null);
|
||||
const sweepActive = ref(false);
|
||||
const driftActive = ref(false);
|
||||
|
||||
function syncFromRoute() {
|
||||
const rb = route.query.rb ? Number(route.query.rb) : null;
|
||||
@@ -26,19 +28,23 @@ function syncFromRoute() {
|
||||
selectedTopicId.value = topic;
|
||||
editingRuleId.value = rule;
|
||||
sweepActive.value = route.query.view === "due";
|
||||
driftActive.value = route.query.view === "drift";
|
||||
}
|
||||
|
||||
function selectSweep() {
|
||||
sweepActive.value = true;
|
||||
// Keeps ?rule=… so the editor survives the mode switch, and drops the
|
||||
// rulebook/topic selection the sweep does not use.
|
||||
/** The two cross-cutting views share one `view` query key, so entering either
|
||||
* leaves the other — and keeps `?rule=…` so an open editor survives the
|
||||
* switch, the way the sweep already did. */
|
||||
function selectCrossCutting(view: "due" | "drift") {
|
||||
sweepActive.value = view === "due";
|
||||
driftActive.value = view === "drift";
|
||||
const { rb, topic, ...rest } = route.query;
|
||||
void rb; void topic;
|
||||
router.replace({ query: { ...rest, view: "due" } });
|
||||
router.replace({ query: { ...rest, view } });
|
||||
}
|
||||
|
||||
function selectRulebook(id: number) {
|
||||
sweepActive.value = false;
|
||||
driftActive.value = false;
|
||||
selectedRulebookId.value = id;
|
||||
selectedTopicId.value = null;
|
||||
router.replace({ query: { rb: String(id) } });
|
||||
@@ -84,10 +90,13 @@ watch(() => route.query, syncFromRoute);
|
||||
:rulebooks="store.rulebooks"
|
||||
:selected-id="selectedRulebookId"
|
||||
:sweep-active="sweepActive"
|
||||
:drift-active="driftActive"
|
||||
@select="selectRulebook"
|
||||
@select-sweep="selectSweep"
|
||||
@select-sweep="selectCrossCutting('due')"
|
||||
@select-drift="selectCrossCutting('drift')"
|
||||
/>
|
||||
<RuleSweepPane v-if="sweepActive" class="sweep-span" @open-rule="openRule" />
|
||||
<PreferenceDriftPane v-else-if="driftActive" class="sweep-span" @open-rule="openRule" />
|
||||
<RulebookDetailPane
|
||||
v-else-if="selectedRulebookId !== null"
|
||||
:rulebook-id="selectedRulebookId"
|
||||
@@ -99,13 +108,13 @@ watch(() => route.query, syncFromRoute);
|
||||
<p>Select a rulebook to view its topics.</p>
|
||||
</div>
|
||||
<RuleListPane
|
||||
v-if="!sweepActive && selectedTopicId !== null"
|
||||
v-if="!sweepActive && !driftActive && selectedTopicId !== null"
|
||||
:topic-id="selectedTopicId"
|
||||
:rules="store.rulesByTopic[selectedTopicId] || []"
|
||||
@open-rule="openRule"
|
||||
@create-rule="startCreatingRule"
|
||||
/>
|
||||
<div v-else-if="!sweepActive" class="pane empty">
|
||||
<div v-else-if="!sweepActive && !driftActive" class="pane empty">
|
||||
<p>Select a topic to view its rules.</p>
|
||||
</div>
|
||||
<RuleEditorSlideOver
|
||||
@@ -125,8 +134,9 @@ watch(() => route.query, syncFromRoute);
|
||||
gap: 1px;
|
||||
background: var(--fs-border-color);
|
||||
}
|
||||
/* The sweep is cross-cutting, so it takes the width the rulebook + topic
|
||||
panes would have used rather than being squeezed into one column. */
|
||||
/* A cross-cutting pane — the sweep, or recent changes — takes the width the
|
||||
rulebook + topic panes would have used rather than being squeezed into one
|
||||
column. */
|
||||
.sweep-span { grid-column: 2 / -1; }
|
||||
.pane.empty {
|
||||
background: var(--fs-surface-hover);
|
||||
|
||||
@@ -258,11 +258,57 @@ async def get_rule_version(rule_id: int, version_id: int):
|
||||
return jsonify(version.to_dict(include_text=True))
|
||||
|
||||
|
||||
# NO restore route, deliberately (milestone 323). A note version can be
|
||||
# restored; a binding instruction should not be revertible in one click.
|
||||
# Putting a rewrite back goes through update_rule, which takes its own
|
||||
# NO restore route FOR A RULE, deliberately (milestone 323). A note version
|
||||
# can be restored; a binding instruction should not be revertible in one
|
||||
# click. Putting a rewrite back goes through update_rule, which takes its own
|
||||
# snapshot and leaves the undo in the history like any other edit — a silent
|
||||
# revert would erase the only record of why the rewrite happened.
|
||||
#
|
||||
# A PREFERENCE IS THE EXCEPTION, and the route below refuses anything else.
|
||||
# 323's reasoning turns on the rewrite being the operator's own decision.
|
||||
# A preference's rewrite is not: the agent makes it mid-work without asking,
|
||||
# which is what the kind is for. Reverting one is a veto over someone else's
|
||||
# edit rather than an undo of your own, and milestone 399 named the cost of
|
||||
# making that veto expensive — drift supervised in name only. The safeguard
|
||||
# 323 actually wanted survives intact, because the restore goes through
|
||||
# update_rule too: the rewrite stays in the history with the revert recorded
|
||||
# after it, so the history gains an entry rather than losing one.
|
||||
|
||||
|
||||
@rulebooks_bp.get("/rules/drift")
|
||||
@login_required
|
||||
async def preference_drift():
|
||||
"""Preferences that have been rewritten, most recently changed first.
|
||||
|
||||
One row per preference carrying its latest rewrite, what it said before,
|
||||
and the record that taught the change. `?limit=` caps the list.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
try:
|
||||
limit = int(request.args.get("limit", 20))
|
||||
except (TypeError, ValueError):
|
||||
limit = 20
|
||||
rows = await rulebooks_svc.recent_preference_drift(uid, limit=limit)
|
||||
return jsonify({"drift": rows})
|
||||
|
||||
|
||||
@rulebooks_bp.post("/rules/<int:rule_id>/versions/<int:version_id>/restore")
|
||||
@login_required
|
||||
async def restore_rule_version(rule_id: int, version_id: int):
|
||||
"""Put a preference back to what that version said. Preferences only.
|
||||
|
||||
409 rather than 400 on a rule: the request is well-formed and the caller
|
||||
is not wrong to have asked — this rule is simply in a state where the
|
||||
action does not apply, and the message says which state and why.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
try:
|
||||
rule = await rulebooks_svc.restore_rule_version(rule_id, version_id, uid)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 409
|
||||
if rule is None:
|
||||
return jsonify({"error": "rule or version not found"}), 404
|
||||
return jsonify(await rulebooks_svc.rule_detail(uid, rule))
|
||||
|
||||
|
||||
@rulebooks_bp.post("/rules/<int:rule_id>/relations")
|
||||
|
||||
@@ -11,9 +11,10 @@ import logging
|
||||
from collections.abc import Iterable
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import and_, delete as sql_delete, insert, or_, select
|
||||
from sqlalchemy import and_, delete as sql_delete, func, insert, or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.base import iso
|
||||
from scribe.models.system import System
|
||||
from scribe.models.rulebook import Rulebook
|
||||
from scribe.services.verification import (
|
||||
@@ -846,6 +847,213 @@ async def get_rule_version(rule_id: int, version_id: int, user_id: int):
|
||||
)).scalar_one_or_none()
|
||||
|
||||
|
||||
# ── Drift: what Scribe changed about how it works with you (#3895) ─────
|
||||
#
|
||||
# A preference is the one record kind the AGENT rewrites in the ordinary
|
||||
# course of working, which is the property that makes it useful and the
|
||||
# property that makes it dangerous. Milestone 399 named the risk 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 — a confident wrong answer wearing the operator's own authority.
|
||||
#
|
||||
# `rule_versions` already records every rewrite. What it does not do is
|
||||
# ARRIVE: a history you have to open one rule at a time, having first
|
||||
# suspected that rule, is not oversight. These two functions are the push
|
||||
# half — one read that answers "what changed lately", and one write that
|
||||
# puts it back.
|
||||
|
||||
_DRIFT_LIMIT_MAX = 50
|
||||
|
||||
|
||||
def _owned_rules_clause(user_id: int):
|
||||
"""Rules whose rulebook or project this user owns — the LISTING form of
|
||||
`_fetch_owned_rule`.
|
||||
|
||||
Deliberately the same two paths in the same order, because a listing that
|
||||
admits a rule the per-row fetch would refuse is a leak, and one that
|
||||
refuses a rule the fetch admits is a row the operator cannot act on. The
|
||||
per-row version stays the authority: everything reached through this is
|
||||
re-checked by `_fetch_owned_rule` before it is written to.
|
||||
"""
|
||||
from scribe.models.project import Project
|
||||
|
||||
via_rulebook = (
|
||||
select(RulebookTopic.id)
|
||||
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||
.where(
|
||||
Rulebook.owner_user_id == user_id,
|
||||
RulebookTopic.deleted_at.is_(None),
|
||||
Rulebook.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
via_project = select(Project.id).where(
|
||||
Project.user_id == user_id, Project.deleted_at.is_(None),
|
||||
)
|
||||
return and_(
|
||||
Rule.deleted_at.is_(None),
|
||||
or_(Rule.topic_id.in_(via_rulebook), Rule.project_id.in_(via_project)),
|
||||
)
|
||||
|
||||
|
||||
async def recent_preference_drift(user_id: int, limit: int = 20) -> list[dict]:
|
||||
"""Preferences that have been rewritten, most recently changed first.
|
||||
|
||||
ONE ROW PER PREFERENCE, carrying its LATEST rewrite — not every version of
|
||||
every preference. The question this answers is "what has Scribe changed
|
||||
about how it works with me lately", and a preference rewritten eight times
|
||||
this week is one answer to that, not eight. The full history of any one
|
||||
preference is still a click away in its editor, which is where "how did
|
||||
this get here" belongs.
|
||||
|
||||
Each row carries what it said before, what it says now, and `taught_by` —
|
||||
the record named by `arose_from_id`, which step 2 made required on every
|
||||
agent-written preference. That is the provenance: not who typed it (the
|
||||
agent acts as the operator's own user, so the actor column cannot tell
|
||||
them apart) but what the change was learned from.
|
||||
|
||||
Rules are excluded, and not as a filter that could be relaxed. A rule
|
||||
changes when its author changes it, so "what changed without me" is not a
|
||||
question about rules — including them would bury the few rows that are
|
||||
actually unreviewed under every edit the operator made themselves.
|
||||
"""
|
||||
limit = max(1, min(int(limit), _DRIFT_LIMIT_MAX))
|
||||
# The newest version per rule, by id rather than by created_at: a version
|
||||
# is written once and never updated, so id order IS time order, and two
|
||||
# versions written in the same clock tick still have a defined winner.
|
||||
newest = (
|
||||
select(
|
||||
RuleVersion.rule_id.label("rule_id"),
|
||||
func.max(RuleVersion.id).label("version_id"),
|
||||
)
|
||||
.group_by(RuleVersion.rule_id)
|
||||
.subquery()
|
||||
)
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(Rule, RuleVersion)
|
||||
.join(newest, newest.c.rule_id == Rule.id)
|
||||
.join(RuleVersion, RuleVersion.id == newest.c.version_id)
|
||||
.where(Rule.kind == "preference", _owned_rules_clause(user_id))
|
||||
.order_by(RuleVersion.created_at.desc(), RuleVersion.id.desc())
|
||||
.limit(limit)
|
||||
)).all()
|
||||
|
||||
# One query for every provenance record, not one per row. These are
|
||||
# titles for rows the caller can already see, so they are read without
|
||||
# a further ownership filter — the same reasoning as
|
||||
# milestones.titles_for, and blanking them would leave the operator a
|
||||
# bare id where the whole point is naming the record.
|
||||
taught_ids = {r.arose_from_id for r, _v in rows if r.arose_from_id}
|
||||
titles: dict[int, str] = {}
|
||||
if taught_ids:
|
||||
from scribe.models.note import Note
|
||||
|
||||
titles = dict((await session.execute(
|
||||
select(Note.id, Note.title).where(
|
||||
Note.id.in_(taught_ids), Note.deleted_at.is_(None),
|
||||
)
|
||||
)).all())
|
||||
|
||||
out: list[dict] = []
|
||||
for rule, version in rows:
|
||||
row: dict = {
|
||||
# rule_brief, not a hand-written dict: the drift pane links
|
||||
# straight into the editor, so the row has to be the same shape
|
||||
# every other rule listing is (#3313's three-copies lesson).
|
||||
"rule": rule_brief(rule),
|
||||
# What it said BEFORE this rewrite. `statement` and
|
||||
# `when_to_apply` only — those are the two fields that change how
|
||||
# a session behaves, and a diff of `why` is reading rather than
|
||||
# reviewing.
|
||||
"previous": {
|
||||
"id": version.id,
|
||||
"created_at": iso(version.created_at),
|
||||
"title": version.title or "",
|
||||
"statement": version.statement or "",
|
||||
"when_to_apply": version.when_to_apply or "",
|
||||
},
|
||||
# The text it changed TO, beside the text it changed from, so the
|
||||
# client can render the diff without a second call per row. A
|
||||
# listing that needs N follow-ups to say what it means is one
|
||||
# nobody scrolls.
|
||||
"current": {
|
||||
"title": rule.title,
|
||||
"statement": rule.statement or "",
|
||||
"when_to_apply": rule.when_to_apply or "",
|
||||
},
|
||||
}
|
||||
if rule.arose_from_id and rule.arose_from_id in titles:
|
||||
row["taught_by"] = {
|
||||
"id": rule.arose_from_id, "title": titles[rule.arose_from_id],
|
||||
}
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
async def restore_rule_version(
|
||||
rule_id: int, version_id: int, user_id: int,
|
||||
) -> Optional[Rule]:
|
||||
"""Put a preference back to what it said. None when it is not readable.
|
||||
|
||||
MILESTONE 323 DECIDED THE OPPOSITE FOR RULES, and that decision stands —
|
||||
`routes/rulebooks.py` still has no restore for them. Its reasoning:
|
||||
"a binding instruction should not be revertible in one click", because a
|
||||
silent revert erases the only record of why the rewrite happened.
|
||||
|
||||
A preference inverts both halves of that. The rewrite was not the
|
||||
operator's — the agent makes it mid-work, without asking, which is the
|
||||
whole design — so reverting is not undoing their own decision but
|
||||
exercising a veto over someone else's. And the veto has to be cheaper
|
||||
than shrugging, or drift is only nominally supervised.
|
||||
|
||||
What makes it safe is that nothing is erased. This goes through
|
||||
`update_rule` like any other edit, so the restore takes its own snapshot:
|
||||
the rewrite stays in the history, with the revert recorded after it. The
|
||||
history gains an entry rather than losing one.
|
||||
|
||||
Raises ValueError on a rule, so the kind check cannot be forgotten by a
|
||||
caller that reaches this directly.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
||||
if rule is None:
|
||||
return None
|
||||
if (rule.kind or "rule") != "preference":
|
||||
raise ValueError(
|
||||
"only a preference can be restored in one action. A rule is "
|
||||
"the operator's own decision and reverting it goes through an "
|
||||
"ordinary edit, so the reason for the rewrite stays visible "
|
||||
"(milestone 323)."
|
||||
)
|
||||
version = (await session.execute(
|
||||
select(RuleVersion).where(
|
||||
RuleVersion.id == version_id, RuleVersion.rule_id == rule_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if version is None:
|
||||
return None
|
||||
fields = {
|
||||
"title": version.title,
|
||||
"statement": version.statement,
|
||||
"when_to_apply": version.when_to_apply,
|
||||
"why": version.why,
|
||||
"how_to_apply": version.how_to_apply,
|
||||
}
|
||||
|
||||
# Outside the session above, because update_rule opens its own — and
|
||||
# through it rather than beside it, so the snapshot, the trigger guard and
|
||||
# the embedding refresh all happen exactly as they do for a hand edit.
|
||||
# A `None` field in the snapshot means the preference had nothing there,
|
||||
# so it is CLEARED rather than left at today's value; passing None to
|
||||
# update_rule means "leave alone", which would half-restore it.
|
||||
clear = [k for k, v in fields.items() if not (v or "").strip()]
|
||||
return await update_rule(
|
||||
rule_id, user_id,
|
||||
clear=[k for k in clear if k != "statement"],
|
||||
**{k: v for k, v in fields.items() if v},
|
||||
)
|
||||
|
||||
|
||||
# ── Canon tags + typed edges (milestone 307) ───────────────────────────
|
||||
|
||||
async def set_rule_systems(
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Real-Postgres tests for the drift surface (milestone 399 step 5, #3895).
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
A preference is the one record kind the AGENT rewrites, mid-work, without
|
||||
asking. That is what keeps it current and it is also 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 — a confident wrong answer wearing the operator's own
|
||||
authority.
|
||||
|
||||
`rule_versions` already recorded every rewrite. What it did not do is arrive.
|
||||
`recent_preference_drift` is the read that makes drift PUSHED rather than
|
||||
pulled, and `restore_rule_version` is the veto.
|
||||
|
||||
WHY NOT MOCKS
|
||||
|
||||
Every claim here is about which rows come back and in what order — a grouped
|
||||
subquery picking the newest version per rule, an ownership clause spanning two
|
||||
paths, and a write that has to leave the history longer than it found it. A
|
||||
stand-in session returns whatever the test handed it, so it could confirm none
|
||||
of those. `test_the_restore_is_recorded_as_an_edit` is the one that matters
|
||||
most: it is the whole basis for allowing a one-click revert here when
|
||||
milestone 323 refused one for rules, and if it were ever to pass while the
|
||||
restore silently overwrote history, the carve-out would be indefensible.
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.rulebook import Rulebook
|
||||
from scribe.models.rule_version import RuleVersion
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
|
||||
OWNER_USERNAME = "drift_owner"
|
||||
STRANGER_USERNAME = "drift_stranger"
|
||||
|
||||
# The moment a preference names, in the words a session actually produces.
|
||||
# Required on a preference, so every fixture below carries one.
|
||||
TRIGGER = "the operator pasted a stack trace and said it is still broken"
|
||||
|
||||
|
||||
async def _purge(uid: int) -> None:
|
||||
"""Clear this user's rulebooks. At SETUP, never teardown.
|
||||
|
||||
`update_rule` fires a detached embedding refresh that opens its own
|
||||
connection and UPDATEs the rule row. A teardown delete would race it into
|
||||
a deadlock — the same reasoning test_integration_rule_versions records.
|
||||
"""
|
||||
async with async_session() as s:
|
||||
for book in (await s.execute(
|
||||
select(Rulebook).where(Rulebook.owner_user_id == uid)
|
||||
)).scalars().all():
|
||||
await s.delete(book)
|
||||
await s.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def world():
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, OWNER_USERNAME)
|
||||
stranger = await ensure_user(s, STRANGER_USERNAME)
|
||||
await s.commit()
|
||||
uid, sid = owner.id, stranger.id
|
||||
|
||||
# The record a preference points at as what taught it. A plain note:
|
||||
# `arose_from_id` is a FK to notes, and the drift row names it so the
|
||||
# operator reads a title rather than a bare number.
|
||||
taught = Note(user_id=uid, title="Paced the debugging one step at a time")
|
||||
s.add(taught)
|
||||
await s.commit()
|
||||
await s.refresh(taught)
|
||||
taught_id = taught.id
|
||||
|
||||
await _purge(uid)
|
||||
await _purge(sid)
|
||||
|
||||
book = await rulebooks_svc.create_rulebook(uid, "Drift fixtures")
|
||||
topic = await rulebooks_svc.create_topic(book.id, uid, "collaboration")
|
||||
|
||||
pref = await rulebooks_svc.create_rule(
|
||||
topic.id, uid, "Pace hard debugging",
|
||||
"Change one thing, then look.",
|
||||
when_to_apply=TRIGGER, kind="preference", arose_from_id=taught_id,
|
||||
)
|
||||
rule = await rulebooks_svc.create_rule(
|
||||
topic.id, uid, "dev is home",
|
||||
"Ordinary work lands on dev.",
|
||||
when_to_apply="about to commit", kind="rule",
|
||||
)
|
||||
return {
|
||||
"uid": uid, "stranger_id": sid, "topic_id": topic.id,
|
||||
"pref_id": pref.id, "rule_id": rule.id, "taught_id": taught_id,
|
||||
}
|
||||
|
||||
|
||||
async def test_a_preference_nobody_rewrote_is_not_drift(world):
|
||||
"""The empty state is a REAL state, not a not-yet-loaded one.
|
||||
|
||||
A preference that has never been touched has nothing for the operator to
|
||||
review, and listing it would bury the rows that do under the ones that
|
||||
don't — which is how a supervision surface stops being read.
|
||||
"""
|
||||
rows = await rulebooks_svc.recent_preference_drift(world["uid"])
|
||||
assert rows == []
|
||||
|
||||
|
||||
async def test_a_rewritten_preference_carries_both_wordings_and_what_taught_it(world):
|
||||
await rulebooks_svc.update_rule(
|
||||
world["pref_id"], world["uid"],
|
||||
statement="Change one thing, then look. Say what you expect first.",
|
||||
)
|
||||
|
||||
[row] = await rulebooks_svc.recent_preference_drift(world["uid"])
|
||||
assert row["rule"]["id"] == world["pref_id"]
|
||||
# Both texts ride along, so the list can show the diff without a
|
||||
# follow-up call per row.
|
||||
assert row["previous"]["statement"] == "Change one thing, then look."
|
||||
assert row["current"]["statement"].endswith("Say what you expect first.")
|
||||
# The provenance is NAMED, not numbered: a bare id reads as complete to
|
||||
# the writer and as homework to the reader.
|
||||
assert row["taught_by"] == {
|
||||
"id": world["taught_id"],
|
||||
"title": "Paced the debugging one step at a time",
|
||||
}
|
||||
|
||||
|
||||
async def test_a_rewritten_rule_is_not_listed(world):
|
||||
"""Not a filter that could be relaxed — the question is what changed
|
||||
WITHOUT the operator, and a rule changes when they change it. Including
|
||||
rules would bury the few unreviewed rows under every edit they made."""
|
||||
await rulebooks_svc.update_rule(
|
||||
world["rule_id"], world["uid"], statement="Ordinary work lands on dev, always.",
|
||||
)
|
||||
assert await rulebooks_svc.recent_preference_drift(world["uid"]) == []
|
||||
|
||||
|
||||
async def test_one_row_per_preference_carrying_its_LATEST_rewrite(world):
|
||||
"""A preference rewritten three times this week is one answer to "what
|
||||
changed lately", not three. The full history stays in its editor."""
|
||||
for text in ("second wording.", "third wording.", "fourth wording."):
|
||||
await rulebooks_svc.update_rule(
|
||||
world["pref_id"], world["uid"], statement=text,
|
||||
)
|
||||
|
||||
rows = await rulebooks_svc.recent_preference_drift(world["uid"])
|
||||
assert len(rows) == 1
|
||||
# The newest version holds what the LAST edit replaced.
|
||||
assert rows[0]["previous"]["statement"] == "third wording."
|
||||
assert rows[0]["current"]["statement"] == "fourth wording."
|
||||
|
||||
|
||||
async def test_most_recently_changed_first(world):
|
||||
"""The ORDER is the answer: the top of this list is what changed last."""
|
||||
second = await rulebooks_svc.create_rule(
|
||||
world["topic_id"], world["uid"], "Name the record",
|
||||
"Say the title beside the id.",
|
||||
when_to_apply="about to cite a record by number", kind="preference",
|
||||
)
|
||||
await rulebooks_svc.update_rule(
|
||||
world["pref_id"], world["uid"], statement="edited first.",
|
||||
)
|
||||
await rulebooks_svc.update_rule(
|
||||
second.id, world["uid"], statement="edited second.",
|
||||
)
|
||||
|
||||
rows = await rulebooks_svc.recent_preference_drift(world["uid"])
|
||||
assert [r["rule"]["id"] for r in rows] == [second.id, world["pref_id"]]
|
||||
|
||||
|
||||
async def test_another_users_preferences_are_not_listed(world):
|
||||
"""The listing clause has to agree with the per-row fetch. A drift pane
|
||||
that reached across owners would leak the wording of someone else's
|
||||
preference — and the wording is the whole payload."""
|
||||
await rulebooks_svc.update_rule(
|
||||
world["pref_id"], world["uid"], statement="mine, rewritten.",
|
||||
)
|
||||
assert await rulebooks_svc.recent_preference_drift(world["stranger_id"]) == []
|
||||
|
||||
|
||||
async def test_the_limit_bounds_the_list_and_keeps_the_newest(world):
|
||||
"""A cap that dropped the wrong end would hide the change just made.
|
||||
|
||||
Bounded because the payload is two full statements per row — so the list
|
||||
has to stay short — and the end it keeps has to be the recent one, which
|
||||
is the only end this surface is about.
|
||||
"""
|
||||
second = await rulebooks_svc.create_rule(
|
||||
world["topic_id"], world["uid"], "Name the record",
|
||||
"Say the title beside the id.",
|
||||
when_to_apply="about to cite a record by number", kind="preference",
|
||||
)
|
||||
await rulebooks_svc.update_rule(
|
||||
world["pref_id"], world["uid"], statement="edited first.",
|
||||
)
|
||||
await rulebooks_svc.update_rule(
|
||||
second.id, world["uid"], statement="edited second.",
|
||||
)
|
||||
|
||||
rows = await rulebooks_svc.recent_preference_drift(world["uid"], limit=1)
|
||||
assert [r["rule"]["id"] for r in rows] == [second.id]
|
||||
# A caller asking for everything gets a page, not the table — and the
|
||||
# clamp is silent rather than an error, because "too many" is a request
|
||||
# to serve, not a mistake to report.
|
||||
assert len(await rulebooks_svc.recent_preference_drift(
|
||||
world["uid"], limit=10_000,
|
||||
)) == 2
|
||||
|
||||
|
||||
async def test_restoring_puts_the_old_wording_back(world):
|
||||
await rulebooks_svc.update_rule(
|
||||
world["pref_id"], world["uid"], statement="the agent's rewrite.",
|
||||
)
|
||||
[row] = await rulebooks_svc.recent_preference_drift(world["uid"])
|
||||
|
||||
restored = await rulebooks_svc.restore_rule_version(
|
||||
world["pref_id"], row["previous"]["id"], world["uid"],
|
||||
)
|
||||
assert restored is not None
|
||||
assert restored.statement == "Change one thing, then look."
|
||||
|
||||
|
||||
async def test_the_restore_is_recorded_as_an_edit(world):
|
||||
"""THE ONE THAT MATTERS MOST — it is why this carve-out is defensible.
|
||||
|
||||
Milestone 323 refused a one-click restore for rules because "a silent
|
||||
revert would erase the only record of why the rewrite happened". The
|
||||
preference carve-out survives that objection only while nothing is
|
||||
erased: the restore goes through `update_rule`, so it takes its own
|
||||
snapshot and the history GAINS an entry.
|
||||
|
||||
A restore implemented as a direct write would satisfy the test above and
|
||||
fail this one, which is the point of having both.
|
||||
"""
|
||||
await rulebooks_svc.update_rule(
|
||||
world["pref_id"], world["uid"], statement="the agent's rewrite.",
|
||||
)
|
||||
async with async_session() as s:
|
||||
before = len((await s.execute(
|
||||
select(RuleVersion).where(RuleVersion.rule_id == world["pref_id"])
|
||||
)).scalars().all())
|
||||
|
||||
[row] = await rulebooks_svc.recent_preference_drift(world["uid"])
|
||||
await rulebooks_svc.restore_rule_version(
|
||||
world["pref_id"], row["previous"]["id"], world["uid"],
|
||||
)
|
||||
|
||||
async with async_session() as s:
|
||||
versions = list((await s.execute(
|
||||
select(RuleVersion).where(RuleVersion.rule_id == world["pref_id"])
|
||||
.order_by(RuleVersion.id)
|
||||
)).scalars().all())
|
||||
assert len(versions) == before + 1
|
||||
# The rewrite is still there, and the newest entry is what the restore
|
||||
# replaced — the rewrite itself. Nothing was overwritten.
|
||||
assert versions[-1].statement == "the agent's rewrite."
|
||||
|
||||
|
||||
async def test_a_rule_refuses_the_one_click_restore(world):
|
||||
"""Milestone 323 stands for rules, and the kind check is in the service
|
||||
rather than only in the route — so a caller reaching this directly cannot
|
||||
forget it."""
|
||||
await rulebooks_svc.update_rule(
|
||||
world["rule_id"], world["uid"], statement="rewritten by hand.",
|
||||
)
|
||||
async with async_session() as s:
|
||||
version = (await s.execute(
|
||||
select(RuleVersion).where(RuleVersion.rule_id == world["rule_id"])
|
||||
)).scalars().first()
|
||||
|
||||
with pytest.raises(ValueError, match="preference"):
|
||||
await rulebooks_svc.restore_rule_version(
|
||||
world["rule_id"], version.id, world["uid"],
|
||||
)
|
||||
|
||||
|
||||
async def test_a_stranger_cannot_restore_a_preference_they_cannot_read(world):
|
||||
"""None, not a raise: "not yours" and "not a preference" are different
|
||||
answers, and the route turns them into 404 and 409 respectively."""
|
||||
await rulebooks_svc.update_rule(
|
||||
world["pref_id"], world["uid"], statement="mine, rewritten.",
|
||||
)
|
||||
[row] = await rulebooks_svc.recent_preference_drift(world["uid"])
|
||||
assert await rulebooks_svc.restore_rule_version(
|
||||
world["pref_id"], row["previous"]["id"], world["stranger_id"],
|
||||
) is None
|
||||
Reference in New Issue
Block a user