Plugin reply shape, preferences UI, cited-record status, and the lesson kind #166
@@ -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