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
beside the component's own
scoped block; never restate these there (#2903, milestone 299). */
.pane {
@@ -13,3 +15,19 @@
margin: 0 0 0.5rem 0;
}
.form-buttons { display: flex; gap: 0.5rem; }
+
+/* A small marker beside a rule's title. Two of these appeared within one
+ milestone (tier, then verification) and were byte-identical; a third would
+ have drifted. The pane's italic serif title is inherited by anything inside
+ it, so the chip resets family and style explicitly. */
+.rule-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;
+}
diff --git a/frontend/src/components/rules/ProjectRulesTab.vue b/frontend/src/components/rules/ProjectRulesTab.vue
index 8fdd3fb..db69979 100644
--- a/frontend/src/components/rules/ProjectRulesTab.vue
+++ b/frontend/src/components/rules/ProjectRulesTab.vue
@@ -24,7 +24,10 @@ const allRulebooks = ref([]);
const showPicker = ref(false);
const expandedRuleIds = ref>(new Set());
-const ruleDetails = ref>({});
+const ruleDetails = ref>({});
const showProjectRuleForm = ref(false);
const newProjectRule = ref({
@@ -67,6 +70,9 @@ async function toggleRuleExpand(ruleId: number) {
ruleDetails.value[ruleId] = {
why: rule.why || "",
how_to_apply: rule.how_to_apply || "",
+ verify_with: rule.verify_with || "",
+ expires_when: rule.expires_when || "",
+ verified_at: rule.verified_at,
};
}
}
@@ -74,6 +80,11 @@ async function toggleRuleExpand(ruleId: number) {
expandedRuleIds.value = new Set(expandedRuleIds.value);
}
+/** "never run" reads as a stronger claim than an absent date — and it is. */
+function checkAge(verifiedAt: string | null): string {
+ return verifiedAt ? `last passed ${verifiedAt.slice(0, 10)}` : "never run";
+}
+
function openInRulesView(rulebookId: number, ruleId?: number) {
const query: Record = { rb: String(rulebookId) };
if (ruleId) query.rule = String(ruleId);
@@ -279,6 +290,16 @@ watch(() => props.projectId, load);
How to apply: {{ ruleDetails[r.id].how_to_apply }}
@@ -231,6 +302,35 @@ legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary);
.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; }
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 @@
+
+
+
+
+
+
Due for verification
+
+ Rules that assert a fact about something outside your control. Most rules are
+ decisions and never appear here — they have no truth value to go stale.
+
+
+
+
+
+
+
+
+
Loading…
+
+
+
+ Nothing to check.
+ {{ neverOnly || tier ? "No rule matches these filters." : "No rule carries a check yet — add one to a rule that asserts a fact." }}
+
+ Record a result only after actually running the check. “No longer true” stores nothing
+ on purpose — the rule is wrong rather than in a state worth recording, so it keeps its
+ place here until you correct or retire it.
+
+
+
+
+
+
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
+
+
+
-
+
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);