Files
FabledScribe/frontend/src/components/rules/RuleEditorSlideOver.vue
T
bvandeusenandClaude Opus 5 0e10f6bb8a
CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Failing after 12s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Failing after 35s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
feat(rules)!: retire the always-on tier — every rule arrives by retrieval (#394)
Milestone 394, steps 5-8. Operator: "remove the always on rule functionality
as the goal was to not have it at all since it didn't seem to work as
expected."

Unconditional preload had three failures the retrieval arms do not. It could
not be MEASURED — a resident rule is in the context whether or not it
mattered, so nothing distinguished "this governed the act" from "this was
scenery", and it was the one surface structurally exempt from the scoreboard
judging every other. It was SUMMARISED AWAY by compaction while the session
went on believing it held the rules. And it CROWDED OUT the few rules that
applied with the thirty that did not.

WHAT GOES

Schema (0100): rules.tier + ck_rules_tier, rule_versions.tier,
rulebooks.always_on, and project_rulebook_exclusions — a table recording a
project's opt-out of something that no longer binds it unasked.

Tools: list_always_on_rules, exclude_always_on_rulebook,
include_always_on_rulebook. Service: the same three plus rules_etag_for,
_valid_tier and the whole etag family. The SessionStart preload and the
write-path staleness arm go with them: nothing is resident, so nothing can
have drifted since a session loaded it.

THREE CALLS WORTH REVIEWING

enter_project got NARROWER, not wider. Its filter was `always_on OR
area-tagged`; dropping the tier arm leaves the deterministic half, so a
project with no canonical-tagged Systems gets no bulk rules and reaches them
by retrieval instead. Dropping the whole clause would have made that payload
bigger than the preload this milestone deletes.

Backups import tolerantly. A pre-394 archive carries tier, always_on and the
retired inception choice; none is read, and the exclusion key is DROPPED
rather than remapped, because restoring it would write data that
validate_inception now rejects as unknown.

The migration is irreversible in the way that matters and says so: downgrade
recreates the columns at their defaults and cannot restore which rules were
always-on. A value invented to fill a hole is not a measurement.

THE INSTRUCTION SURFACES SAY THE HARDER THING

Deleting "call list_always_on_rules()" is easy; replacing it is not, because
the new model asks a session to trust something it cannot see. All three
surfaces now say a session holds nothing, that rules arrive when work matches
them, and — the half that got dangerous — that "no rule arrived" means
"nothing matched", never "there is no rule". Under residency an empty session
was rare and suspicious; it is now the ordinary state of most turns, so
reading it as permission is wrong on nearly every turn rather than
occasionally. That is #3720's defect at session scale.

test_instruction_surfaces_agree is repointed rather than retired: its two
halves collapsed into one instruction, and it gains a guard that every
surface states what absence means. _INSTRUCTIONS is back at 1999/2000 —
the inception clause paid for the longer HOW line.

UI (rule 27, and the opportunity step 8 named)

The tier selector is gone, and what replaces it is the point: `when_to_apply`
is now the field that decides whether a rule is ever seen, so the editor
marks it required, warns while it is empty, and both rule lists badge a
trigger-less rule "never surfaces". A rule without one is not quiet, it is
unreachable.

TESTS

Two files deleted outright — test_rules_etag.py and test_inception_rules.py
tested subsystems that no longer exist. Elsewhere obsolete cases were removed
and the rest repointed. One deserves naming: the wiring test asserted the act
arms pass no `tier`, which had become an assertion that could not fail. It is
repointed onto `kind`, which does still exist and where the same claim is
live — a preference must reach a write exactly as a rule does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
2026-09-11 16:22:17 -04:00

332 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch, onMounted } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
const emit = defineEmits<{ close: [] }>();
const store = useRulebooksStore();
const canon = useCanonicalSystemsStore();
const title = ref("");
const statement = ref("");
const whenToApply = ref("");
const systemIds = ref<number[]>([]);
const why = ref("");
const howToApply = ref("");
const verifyWith = ref("");
const expiresWhen = ref("");
const relations = computed(() => store.currentRule?.relations ?? []);
// The label a reader needs to judge an edge, not the stored token.
const RELATION_LABEL: Record<string, { outgoing: string; incoming: string }> = {
co_surfaces: { outgoing: "arrives with", incoming: "arrives with" },
overrides: { outgoing: "overrides", incoming: "is overridden by" },
elaborates: { outgoing: "elaborates", incoming: "is elaborated by" },
};
function relationLabel(kind: string, direction: "outgoing" | "incoming") {
return RELATION_LABEL[kind]?.[direction] ?? kind;
}
function toggleSystem(id: number) {
const at = systemIds.value.indexOf(id);
if (at >= 0) systemIds.value.splice(at, 1);
else systemIds.value.push(id);
}
const isCreating = ref(props.ruleId === null);
// The stored stamp, not the draft: it describes the check that was RUN, and
// an unsaved edit to the textarea has not been run against anything.
const verifiedAt = computed(() => store.currentRule?.verified_at ?? null);
const savedCheck = computed(() => store.currentRule?.verify_with ?? "");
// Built here rather than in the template: same shape as the server's
// last_verified_label, and it keeps the null-narrowing in TypeScript's reach.
const stampLabel = computed(() =>
verifiedAt.value ? `Last checked ${verifiedAt.value.slice(0, 10)}` : "Never checked",
);
const verifying = ref(false);
async function verify(stillTrue: boolean) {
if (props.ruleId === null) return;
verifying.value = true;
try {
await store.verifyRule(props.ruleId, stillTrue);
} finally {
verifying.value = false;
}
}
async function load() {
if (props.ruleId !== null) {
await store.fetchRule(props.ruleId);
const r = store.currentRule;
if (r) {
title.value = r.title;
statement.value = r.statement;
whenToApply.value = r.when_to_apply || "";
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
why.value = r.why || "";
howToApply.value = r.how_to_apply || "";
verifyWith.value = r.verify_with || "";
expiresWhen.value = r.expires_when || "";
}
} else {
title.value = "";
statement.value = "";
whenToApply.value = "";
systemIds.value = [];
why.value = "";
howToApply.value = "";
verifyWith.value = "";
expiresWhen.value = "";
}
await canon.fetchCatalog();
}
async function save() {
if (!title.value.trim() || !statement.value.trim()) {
emit("close");
return;
}
const fields = {
title: title.value,
statement: statement.value,
when_to_apply: whenToApply.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,
why: why.value,
how_to_apply: howToApply.value,
// Always sent, including empty. The REST door maps "" to NULL, so
// clearing a field here actually clears it — the MCP door's "" means
// "leave unchanged" and needs an explicit clear_fields list instead.
verify_with: verifyWith.value,
expires_when: expiresWhen.value,
};
if (isCreating.value && props.topicId !== null) {
await store.createRule(props.topicId, fields);
} else if (props.ruleId !== null) {
await store.updateRule(props.ruleId, fields);
}
emit("close");
}
async function remove() {
if (props.ruleId === null) return;
if (!confirm("Delete this rule? This cannot be undone.")) return;
await store.deleteRule(props.ruleId);
emit("close");
}
onMounted(load);
watch(() => props.ruleId, load);
</script>
<template>
<div class="backdrop" @click="save">
<aside class="slide-over" @click.stop>
<header>
<h2>{{ isCreating ? "New rule" : "Edit rule" }}</h2>
<button v-if="!isCreating" class="trash" @click="remove" aria-label="Delete">🗑</button>
<button class="close" @click="save" aria-label="Close">×</button>
</header>
<label>
Title
<input v-model="title" placeholder="e.g. dev is home" />
</label>
<label>
Statement <span class="required">*</span>
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
</label>
<label :class="{ 'trigger-missing': !whenToApply.trim() }">
When to apply <span class="required">*</span>
<textarea
v-model="whenToApply"
rows="3"
placeholder="The moment, in the words a session actually produces — “about to run git push with an earlier CI run unread”, not “when pacing actions”."
/>
</label>
<p v-if="!whenToApply.trim()" class="trigger-warning">
<strong>Without this, the rule will never reach a session.</strong>
Nothing is preloaded: a rule arrives when what someone is doing matches
its trigger, so an empty trigger leaves the rule findable by nobody.
</p>
<fieldset v-if="canon.catalog.length" class="areas">
<legend>Areas this rule is about</legend>
<label v-for="entry in canon.catalog" :key="entry.id" class="area-opt">
<input
type="checkbox"
:checked="systemIds.includes(entry.id)"
@change="toggleSystem(entry.id)"
/>
<span>{{ entry.name }}</span>
</label>
<p class="field-note">
What lets this rule reach a project working in that area.
</p>
</fieldset>
<fieldset class="check">
<legend>Can this rule go stale?</legend>
<p class="field-note intro">
Most rules are <em>decisions</em> they have no truth value and change only when you
change them. Leave this empty for those. Fill it in when the rule asserts a
<em>fact</em> about something outside your control, because those go false quietly.
</p>
<label>
How to check it is still true
<textarea
v-model="verifyWith"
rows="2"
placeholder="A command, a path, a query — something runnable beats prose."
/>
</label>
<label>
What would end it
<textarea
v-model="expiresWhen"
rows="2"
placeholder="A state, not a date — “when the runner can be given a bash shell”."
/>
</label>
<div v-if="savedCheck" class="stamp">
<span class="stamp-age" :class="{ unchecked: !verifiedAt }">{{ stampLabel }}</span>
<span class="stamp-actions">
<button type="button" :disabled="verifying" @click="verify(true)">Still true</button>
<button type="button" :disabled="verifying" @click="verify(false)">No longer true</button>
</span>
</div>
<p v-if="savedCheck" class="field-note">
Record this after actually running the check, never on the strength of the rule
sounding plausible. No longer true deliberately stores nothing the rule is wrong,
not in a state worth recording, so it stays at the top of the sweep until you fix or
retire it.
</p>
</fieldset>
<section v-if="relations.length" class="relations">
<h3>Related rules</h3>
<ul>
<li v-for="rel in relations" :key="rel.id" class="relation">
<span class="relation-kind">{{ relationLabel(rel.kind, rel.direction) }}</span>
<span class="relation-target">rule #{{ rel.rule_id }}</span>
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
</li>
</ul>
<p class="field-note">
Rules that <em>fail together</em> are linked, never merged a merged rule cannot be
cited, surfaced or suppressed a clause at a time.
</p>
</section>
<label>
Why
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
</label>
<label>
How to apply
<textarea v-model="howToApply" rows="4" placeholder="When / where this kicks in." />
</label>
<!-- Only on an existing rule: a rule being created has no past, and an
"Edit history — none" line on a blank form reads as a broken panel.
Keyed on ruleId so switching rules reloads rather than showing the
previous rule's history under the new one's text. -->
<RuleHistoryPanel
v-if="!isCreating && ruleId !== null"
:key="ruleId"
:rule-id="ruleId"
:current="store.currentRule"
/>
</aside>
</div>
</template>
<style scoped>
.backdrop {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 100;
}
.slide-over {
position: fixed; top: 0; right: 0; bottom: 0;
width: min(520px, 90vw);
background: var(--fs-surface-hover);
border-left: 2px solid var(--fs-accent);
padding: 1.5rem;
overflow-y: auto;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
}
header { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
header h2 {
flex: 1; margin: 0;
font-family: Fraunces, serif; font-style: italic;
}
label { display: block; margin-bottom: 1rem; }
.required { color: var(--fs-accent); }
input, textarea {
width: 100%; margin-top: 0.25rem;
background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.5rem; font: inherit;
font-family: inherit;
}
fieldset { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: 0.75rem; margin-bottom: 1rem; }
legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary); }
.area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
.area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
.trigger-missing textarea { border-color: var(--fs-warning); }
/* --fs-warning-fg, not --fs-warning: the token set draws the distinction
between the warning HUE and warning text, and this is text. */
.trigger-warning {
margin: -0.35rem 0 0.6rem; font-size: 0.78rem; line-height: 1.45;
color: var(--fs-warning-fg);
}
.field-note { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
.relations h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
.relations ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
.relation { display: flex; align-items: baseline; gap: 0.4rem; flex-wrap: wrap; font-size: 0.85rem; }
.relation-kind { color: var(--fs-accent); }
.relation-target { color: var(--fs-text-primary); }
.relation-note { width: 100%; font-size: 0.78rem; color: var(--fs-text-tertiary); }
/* A real base rule, not just descendants: the dangling-style check reads a
class that only ever appears as an ancestor as a half-deleted rule, and it
is right to — an element whose appearance comes only from its tag is one
`fieldset {}` edit away from being unstyled. */
.check { margin-bottom: 1rem; }
.check .intro { margin-top: 0; margin-bottom: 0.75rem; }
.check label { margin-bottom: 0.75rem; }
.stamp {
display: flex; align-items: center; gap: var(--fs-space-2);
flex-wrap: wrap;
margin-top: 0.25rem;
}
.stamp-age { font-size: 0.8rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
/* Never-checked is INFORMATION, not an error: it is the ordinary starting
state of every constraint anyone has just written. --fs-overdue (error red)
is reserved for a broken promise like a missed due date; a verification age
is not one, and colouring it that way would make a brand-new rule look
broken. Secondary text, weighted normally. */
.stamp-age.unchecked { color: var(--fs-text-tertiary); font-style: italic; }
.stamp-actions { display: flex; gap: var(--fs-space-2); margin-left: auto; }
.stamp-actions button {
cursor: pointer; font: inherit; font-size: 0.78rem;
background: var(--fs-surface-raised); color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-sm);
padding: 0.2rem 0.55rem;
}
.stamp-actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
.stamp-actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
.trash:hover, .close:hover { opacity: 1; }
</style>