diff --git a/alembic/versions/0090_rule_verification.py b/alembic/versions/0090_rule_verification.py new file mode 100644 index 0000000..0ba64a9 --- /dev/null +++ b/alembic/versions/0090_rule_verification.py @@ -0,0 +1,64 @@ +"""a rule can carry its own check — verify_with, expires_when, verified_at +(milestone 312 step 1) + +Revision ID: 0090 +Revises: 0089 +Create Date: 2026-08-27 + +A rulebook holds two kinds of row in one table. A NORM is a decision: it has +no truth value, and it changes only when its author changes it — which they +know they did. A CONSTRAINT is a fact about someone else's software: a +runner's shell, a bot's config, a tool that exists. Nobody is present when +that goes false. + +Milestone 307's rulebook audit found nine stale sites. Every one was a +constraint; not one norm had rotted. One of them had been telling every +session to skip database-backed tests for weeks while the integration lane +sat green in the workflow. + +Three nullable columns, so a rule can say how to check itself: + +- `verify_with` — how to tell whether this is still true. A command, a path, + a URL, a query. Prose is allowed; something runnable is better. +- `expires_when` — the STATE under which it stops being true. Deliberately + not a date: constraints do not expire on a schedule, they expire when the + world underneath them moves. +- `verified_at` — when the check last passed. NULL means never checked, and + sorts FIRST in the sweep: unexamined outranks examined-long-ago. + +All three nullable and all three optional, because most rules should set +none of them. A null `verify_with` is not an omission — it is the honest +marker of "this one is a decision, and there is nothing to go and check." +That signal only works if the field stays empty wherever it belongs empty. + +No CHECK constraint is involved, so rule 36 does not apply here. Nothing is +backfilled: a migration cannot invent a check any more than 0088 could +invent a trigger. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0090" +down_revision = "0089" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("rules", sa.Column("verify_with", sa.Text(), nullable=True)) + op.add_column("rules", sa.Column("expires_when", sa.Text(), nullable=True)) + op.add_column( + "rules", + sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True), + ) + # No index on (verify_with, verified_at). The sweep this exists for reads + # an operator's whole rulebook — hundreds of rows, not millions — and runs + # when a human asks for it, never on a request path. An index here would + # be maintained on every rule write to serve a query that a sequential + # scan answers instantly. + + +def downgrade() -> None: + op.drop_column("rules", "verified_at") + op.drop_column("rules", "expires_when") + op.drop_column("rules", "verify_with") diff --git a/alembic/versions/0091_task_kind_spike.py b/alembic/versions/0091_task_kind_spike.py new file mode 100644 index 0000000..5b03374 --- /dev/null +++ b/alembic/versions/0091_task_kind_spike.py @@ -0,0 +1,66 @@ +"""task_kind gains 'spike' — the investigation, not the change +(milestone 312 step 5) + +Revision ID: 0091 +Revises: 0090 +Create Date: 2026-08-27 + +A spike is a task shape the others cannot hold. `work` ships a change; +`issue` fixes something broken. A spike is time-boxed and its output is +KNOWLEDGE — it succeeds by producing an answer, and nothing ships at the +end of it. "Find out whether the runner can be given a bash shell" is not +work, and filing it as work makes a finished investigation look like an +abandoned change. + +It is the record a failed check asks for. Milestone 312 gave rules a +`verify_with`; when one of those fails, the rule is wrong and the next move +is often to go and find out what replaced it. `notes.arose_from_id` already +exists (0065), so that constraint -> spike link needs no further schema. + +Rule 36: `task_kind` is gated by a CHECK whitelist, so the value and the +widened constraint land in the SAME migration — DROP then ADD, exactly as +0065 did when it introduced 'issue'. Adding the value and constraining it +later leaves a window where the database accepts anything. + +'plan' stays in the list though it is retired (plans are milestones since +0066): historical plan-tasks still carry it, and dropping it from the +whitelist would make old rows unwritable. +""" +from alembic import op + +revision = "0091" +down_revision = "0090" +branch_labels = None +depends_on = None + +# One tuple so the upgrade and the downgrade cannot disagree about what the +# list was on either side of this migration. +_KINDS_AFTER = ("work", "plan", "issue", "spike") +_KINDS_BEFORE = ("work", "plan", "issue") + + +# Restated rather than imported from 0088, which has the same helper. A +# migration is a snapshot: it must keep working when the code around it has +# moved on, so it never imports from live modules or from its siblings. Six +# duplicated lines are the price of that, and the cheap half of the bargain. +def _in_list(values: tuple[str, ...]) -> str: + return "task_kind IN (" + ", ".join(f"'{v}'" for v in values) + ")" + + +def upgrade() -> None: + op.drop_constraint("notes_task_kind_check", "notes", type_="check") + op.create_check_constraint( + "notes_task_kind_check", "notes", _in_list(_KINDS_AFTER), + ) + + +def downgrade() -> None: + # Any row already filed as a spike would violate the narrowed constraint, + # so they are demoted to 'work' first. Lossy and deliberately so: the + # alternative is a downgrade that fails on real data, which is worse than + # a downgrade that says what it did. + op.execute("UPDATE notes SET task_kind = 'work' WHERE task_kind = 'spike'") + op.drop_constraint("notes_task_kind_check", "notes", type_="check") + op.create_check_constraint( + "notes_task_kind_check", "notes", _in_list(_KINDS_BEFORE), + ) diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index 2c3eb8c..f884999 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -55,6 +55,16 @@ export interface Rule { tier: RuleTier; why: string; how_to_apply: string; + /** + * How to check the rule is still true, and the state that ends it. Set + * only on a rule that asserts a fact about something outside the + * operator's control; empty on a rule that is a decision, which is most + * of them. Empty is meaningful, not missing. + */ + verify_with: string; + expires_when: string; + /** When the check last passed. Null means never checked. */ + verified_at: string | null; /** The note or task that caused this rule, if one was recorded. */ arose_from_id: number | null; order_index: number; @@ -80,6 +90,12 @@ export interface RuleHeader { updated_at: string | null; when_to_apply?: string; arose_from_id?: number; + /** + * Present ONLY on a rule that carries a check — the presence of the key + * is itself the signal that this rule asserts a fact that can go false. + * A date (YYYY-MM-DD), or the literal "never". + */ + last_verified?: string; } export interface ApplicableRules { @@ -170,7 +186,14 @@ export async function getRule(id: number): Promise { return apiGet(`/api/rules/${id}`); } -/** The fields both write paths accept. `system_ids` REPLACES a rule's areas. */ +/** + * The fields both write paths accept. `system_ids` REPLACES a rule's areas. + * + * Sending "" for a nullable text field CLEARS it here — the server maps an + * empty string to NULL, so an emptied form input does what it looks like it + * does. (The MCP door reads "" as "leave unchanged" and needs an explicit + * clear_fields list instead; the two idioms reach the same state.) + */ export interface RuleWrite { title: string; statement: string; @@ -181,6 +204,8 @@ export interface RuleWrite { order_index: number; system_ids: number[]; arose_from_id: number | null; + verify_with: string; + expires_when: string; } export async function createRule(topicId: number, data: Partial & { title: string; statement: string }): Promise { @@ -256,3 +281,59 @@ export async function includeAlwaysOnRulebook(projectId: number, rulebookId: num await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`); } + +/** + * One row of the staleness sweep. Unlike RuleHeader this carries the CHECK + * in full — the reader is about to go and run it, so the text is the point + * of the payload rather than the bloat a listing avoids. + */ +export interface RuleVerificationRow { + id: number; + title: string; + statement: string; + tier: RuleTier; + topic_id: number | null; + project_id: number | null; + when_to_apply: string; + verify_with: string; + expires_when: string; + /** A date (YYYY-MM-DD), or the literal "never". */ + last_verified: string | null; + /** Null when never verified — "never" is not zero days ago. */ + days_since_verified: number | null; +} + +/** + * Rules asserting a fact that may have gone false, oldest verification + * first, never-checked at the top. Rules without a check never appear: + * they are decisions, and there is nothing to go and check. + * + * Not filterable by project — a project reaches rules through project + * scope, subscriptions, always-on rulebooks and exclusions, and a filter + * missing one of those paths would under-report. + */ +export async function listRulesDueForVerification(opts: { + olderThanDays?: number; + tier?: RuleTier; + neverOnly?: boolean; +} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> { + const q = new URLSearchParams(); + if (opts.olderThanDays) q.set("older_than_days", String(opts.olderThanDays)); + if (opts.tier) q.set("tier", opts.tier); + if (opts.neverOnly) q.set("never_only", "true"); + const qs = q.toString(); + return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`); +} + +/** + * Record that a rule's check was RUN, and what it said. + * + * `stillTrue: false` writes nothing on purpose — a rule whose check failed + * is not in a recordable state, it is wrong — so it stays at the top of the + * sweep until someone corrects or retires it. + */ +export async function markRuleVerified( + id: number, stillTrue = true, +): Promise { + return apiPost(`/api/rules/${id}/verify`, { still_true: stillTrue }); +} diff --git a/frontend/src/assets/rules-shared.css b/frontend/src/assets/rules-shared.css index 9efc62b..72c1448 100644 --- a/frontend/src/assets/rules-shared.css +++ b/frontend/src/assets/rules-shared.css @@ -1,5 +1,7 @@ -/* Shared by the three rules panes (RulebookListPane, RuleListPane, - RulebookDetailPane): the pane surface and its heading. Load with +/* Shared by the rules panes (RulebookListPane, RuleListPane, + RulebookDetailPane, RuleSweepPane): the pane surface, its heading, and the + title chip. Counting them in this comment went stale the first time a + fourth was added, so it no longer does. Load with diff --git a/frontend/src/components/rules/RuleListPane.vue b/frontend/src/components/rules/RuleListPane.vue index 78ba14a..6c2a662 100644 --- a/frontend/src/components/rules/RuleListPane.vue +++ b/frontend/src/components/rules/RuleListPane.vue @@ -17,7 +17,17 @@ const emit = defineEmits<{ {{ r.title }} - conditional + conditional + + {{ r.last_verified === "never" ? "unverified" : `checked ${r.last_verified}` }}
{{ r.statement }}
@@ -47,16 +57,13 @@ li:hover { background: var(--fs-surface-hover); } .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; } .age { color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; } -.tier-chip { - margin-left: 0.4rem; - font-family: var(--fs-font-body); - font-style: normal; - font-size: 0.62rem; - color: var(--fs-text-secondary); - background: var(--fs-surface-raised); - border-radius: var(--fs-radius-pill); - padding: 0.05rem 0.4rem; - vertical-align: middle; -} +/* Only the departures from .rule-chip (rules-shared.css) live here. */ +.check-chip { font-variant-numeric: tabular-nums; } +/* No age-graded colour on purpose. The sweep is already ordered by urgency, so + a red/amber ramp would restate the ordering AND require an invented "stale + after N days" threshold — a magic number nobody could defend and the first + thing to go out of date. Only "never" is marked, because it is categorically + different from a date rather than a worse one. */ +.check-chip.unchecked { font-style: italic; color: var(--fs-text-tertiary); } .new-rule { cursor: pointer; } diff --git a/frontend/src/components/rules/RuleSweepPane.vue b/frontend/src/components/rules/RuleSweepPane.vue new file mode 100644 index 0000000..f072500 --- /dev/null +++ b/frontend/src/components/rules/RuleSweepPane.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/frontend/src/components/rules/RulebookListPane.vue b/frontend/src/components/rules/RulebookListPane.vue index b808c3d..f76effe 100644 --- a/frontend/src/components/rules/RulebookListPane.vue +++ b/frontend/src/components/rules/RulebookListPane.vue @@ -3,8 +3,8 @@ import { ref } from "vue"; import { useRulebooksStore } from "@/stores/rulebooks"; import type { Rulebook } from "@/api/rulebooks"; -defineProps<{ rulebooks: Rulebook[]; selectedId: number | null }>(); -const emit = defineEmits<{ select: [id: number] }>(); +defineProps<{ rulebooks: Rulebook[]; selectedId: number | null; sweepActive: boolean }>(); +const emit = defineEmits<{ select: [id: number]; "select-sweep": [] }>(); const store = useRulebooksStore(); const isCreating = ref(false); @@ -34,6 +34,18 @@ async function submitNew() { always on + + +
@@ -63,6 +75,15 @@ li:hover { background: var(--fs-surface-hover); } color: var(--fs-text-on-action); margin-left: auto; } +.sweep-entry { + display: block; width: 100%; text-align: left; + margin-top: var(--fs-space-3); + padding: 0.5rem; border-radius: 6px; + background: none; border: 1px dashed var(--fs-border-color); + color: var(--fs-text-secondary); font: inherit; cursor: pointer; +} +.sweep-entry:hover { background: var(--fs-surface-hover); } +.sweep-entry.active { background: var(--fs-accent-soft); color: var(--fs-text-primary); } .new-rulebook { margin-top: 1rem; } .new-rulebook input { width: 100%; margin-bottom: 0.5rem; diff --git a/frontend/src/stores/rulebooks.ts b/frontend/src/stores/rulebooks.ts index cfaa8ea..a02be56 100644 --- a/frontend/src/stores/rulebooks.ts +++ b/frontend/src/stores/rulebooks.ts @@ -9,6 +9,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => { const topicsByRulebook = ref>({}); const rulesByTopic = ref>({}); const currentRule = ref(null); + const rulesDue = ref([]); + // 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. + const lastSweepOpts = ref<{ olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean }>({}); const loading = ref(false); async function fetchRulebooks() { @@ -111,6 +116,13 @@ export const useRulebooksStore = defineStore("rulebooks", () => { updated_at: rule.updated_at, when_to_apply: rule.when_to_apply || undefined, arose_from_id: rule.arose_from_id ?? undefined, + // Mirrors services.rulebooks.last_verified_label: present ONLY when the + // rule carries a check, and "never" rather than absent when it has one + // nobody has run. Computed here so a row just written looks identical to + // the same row re-fetched, instead of losing its chip until a reload. + last_verified: rule.verify_with + ? (rule.verified_at ? rule.verified_at.slice(0, 10) : "never") + : undefined, }; } @@ -148,6 +160,41 @@ export const useRulebooksStore = defineStore("rulebooks", () => { await fetchRule(refreshRuleId); } + /** The staleness sweep: rules asserting a fact, oldest verification first. */ + async function fetchRulesDue(opts: { + olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean; + } = {}) { + loading.value = true; + lastSweepOpts.value = opts; + try { + const data = await api.listRulesDueForVerification(opts); + rulesDue.value = data.rules; + } finally { + loading.value = false; + } + } + + /** + * Record that a rule's check was RUN, and what it said. + * + * A pass re-sorts the row to the back of the sweep, so the list is re-read + * rather than patched: the whole point of this surface is an ORDER, and a + * locally-mutated row would sit in its old position claiming a new date. + * A failure writes nothing server-side and the row keeps its place — also + * correct, and also what a re-read shows. + */ + async function verifyRule(id: number, stillTrue: boolean) { + const rule = await api.markRuleVerified(id, stillTrue); + if (currentRule.value?.id === id) currentRule.value = rule; + for (const tid of Object.keys(rulesByTopic.value)) { + const list = rulesByTopic.value[Number(tid)]; + const idx = list.findIndex((r) => r.id === id); + if (idx >= 0) list[idx] = toHeader(rule); + } + if (rulesDue.value.length) await fetchRulesDue(lastSweepOpts.value); + return rule; + } + async function deleteRule(id: number) { await api.deleteRule(id); if (currentRule.value?.id === id) currentRule.value = null; @@ -157,10 +204,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => { } return { - rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading, + rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading, fetchRulebooks, fetchTopics, fetchRules, fetchRule, createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook, createTopic, updateTopic, deleteTopic, createRule, updateRule, deleteRule, relateRules, unrelateRules, + fetchRulesDue, verifyRule, }; }); diff --git a/frontend/src/types/note.ts b/frontend/src/types/note.ts index 64105e4..c11a9d3 100644 --- a/frontend/src/types/note.ts +++ b/frontend/src/types/note.ts @@ -2,7 +2,16 @@ import type { System } from "@/api/systems"; export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled"; export type TaskPriority = "none" | "low" | "medium" | "high"; -export type TaskKind = "work" | "plan" | "issue"; +/** + * What KIND of work a task is, not how it is going. + * work — ships a change (default) + * issue — corrective; something was broken + * spike — time-boxed, output is knowledge; it succeeds by producing an + * answer and nothing ships at the end of it + * plan — retired (plans are milestones); kept so historical plan-tasks + * still render their kind + */ +export type TaskKind = "work" | "plan" | "issue" | "spike"; export type NoteType = "note" | "process" | "snippet"; export interface Note { diff --git a/frontend/src/views/RulesView.vue b/frontend/src/views/RulesView.vue index 1236267..c201e2e 100644 --- a/frontend/src/views/RulesView.vue +++ b/frontend/src/views/RulesView.vue @@ -6,6 +6,7 @@ import RulebookListPane from "@/components/rules/RulebookListPane.vue"; 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"; const store = useRulebooksStore(); const route = useRoute(); @@ -15,6 +16,7 @@ const selectedRulebookId = ref(null); const selectedTopicId = ref(null); const editingRuleId = ref(null); const creatingRuleForTopic = ref(null); +const sweepActive = ref(false); function syncFromRoute() { const rb = route.query.rb ? Number(route.query.rb) : null; @@ -23,9 +25,20 @@ function syncFromRoute() { selectedRulebookId.value = rb; selectedTopicId.value = topic; editingRuleId.value = rule; + sweepActive.value = route.query.view === "due"; +} + +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. + const { rb, topic, ...rest } = route.query; + void rb; void topic; + router.replace({ query: { ...rest, view: "due" } }); } function selectRulebook(id: number) { + sweepActive.value = false; selectedRulebookId.value = id; selectedTopicId.value = null; router.replace({ query: { rb: String(id) } }); @@ -70,10 +83,13 @@ watch(() => route.query, syncFromRoute); + route.query, syncFromRoute);

Select a rulebook to view its topics.

-
+

Select a topic to view its rules.

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. */ +.sweep-span { grid-column: 2 / -1; } .pane.empty { background: var(--fs-surface-hover); padding: 1rem; diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue index f19115f..4212ca5 100644 --- a/frontend/src/views/TaskEditorView.vue +++ b/frontend/src/views/TaskEditorView.vue @@ -578,6 +578,7 @@ useEditorGuards(dirty, save);