Rules that can go stale say so — verify_with, expires_when, the sweep, and task_kind='spike' (milestone 312, steps 1–5)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 17s
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 17s
This commit was merged in pull request #132.
This commit is contained in:
@@ -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")
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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<Rule> {
|
||||
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<RuleWrite> & { title: string; statement: string }): Promise<Rule> {
|
||||
@@ -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<Rule & { verified: boolean }> {
|
||||
return apiPost(`/api/rules/${id}/verify`, { still_true: stillTrue });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<style src="@/assets/rules-shared.css" /> 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;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ const allRulebooks = ref<Rulebook[]>([]);
|
||||
const showPicker = ref(false);
|
||||
const expandedRuleIds = ref<Set<number>>(new Set());
|
||||
|
||||
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
||||
const ruleDetails = ref<Record<number, {
|
||||
why: string; how_to_apply: string;
|
||||
verify_with: string; expires_when: string; verified_at: string | null;
|
||||
}>>({});
|
||||
|
||||
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<string, string> = { rb: String(rulebookId) };
|
||||
if (ruleId) query.rule = String(ruleId);
|
||||
@@ -279,6 +290,16 @@ watch(() => props.projectId, load);
|
||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||
</div>
|
||||
<!-- Shown only when the rule carries a check. Read-only here: this
|
||||
tab is the project's view of what binds it, and editing a rule
|
||||
belongs on the rulebook surface that owns it. -->
|
||||
<div v-if="ruleDetails[r.id].verify_with">
|
||||
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
|
||||
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
|
||||
</div>
|
||||
<div v-if="ruleDetails[r.id].expires_when">
|
||||
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
|
||||
</div>
|
||||
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
@@ -332,6 +353,13 @@ watch(() => props.projectId, load);
|
||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||
</div>
|
||||
<div v-if="ruleDetails[r.id].verify_with">
|
||||
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
|
||||
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
|
||||
</div>
|
||||
<div v-if="ruleDetails[r.id].expires_when">
|
||||
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
|
||||
</div>
|
||||
<button
|
||||
class="edit-link"
|
||||
@click="openInRulesView(r.rulebook_id, r.id)"
|
||||
@@ -428,6 +456,11 @@ ul { list-style: none; padding: 0; margin: 0; }
|
||||
}
|
||||
.rule-head { cursor: pointer; }
|
||||
.rule-title { font-weight: 500; }
|
||||
.rule-check-age {
|
||||
margin-left: var(--fs-space-2);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
||||
.rule-detail {
|
||||
margin-top: 0.5rem; padding: 0.5rem;
|
||||
|
||||
@@ -16,6 +16,8 @@ const tier = ref<RuleTier>("always_on");
|
||||
const systemIds = ref<number[]>([]);
|
||||
const why = ref("");
|
||||
const howToApply = ref("");
|
||||
const verifyWith = ref("");
|
||||
const expiresWhen = ref("");
|
||||
|
||||
const relations = computed(() => store.currentRule?.relations ?? []);
|
||||
|
||||
@@ -38,6 +40,27 @@ function toggleSystem(id: number) {
|
||||
|
||||
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);
|
||||
@@ -50,6 +73,8 @@ async function load() {
|
||||
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 = "";
|
||||
@@ -59,6 +84,8 @@ async function load() {
|
||||
systemIds.value = [];
|
||||
why.value = "";
|
||||
howToApply.value = "";
|
||||
verifyWith.value = "";
|
||||
expiresWhen.value = "";
|
||||
}
|
||||
await canon.fetchCatalog();
|
||||
}
|
||||
@@ -78,6 +105,11 @@ async function save() {
|
||||
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);
|
||||
@@ -162,6 +194,45 @@ watch(() => props.ruleId, load);
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="check">
|
||||
<legend>Can this rule go stale?</legend>
|
||||
<p class="tier-test 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="tier-test">
|
||||
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>
|
||||
@@ -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; }
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,17 @@ const emit = defineEmits<{
|
||||
{{ r.title }}
|
||||
<!-- Only conditional is marked: always-on is the default and
|
||||
badging every row would say nothing. -->
|
||||
<span v-if="r.tier === 'conditional'" class="tier-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
|
||||
<span v-if="r.tier === 'conditional'" class="rule-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
|
||||
<!-- Present only on a rule carrying a check, so the chip's very
|
||||
presence says "this one asserts a fact that can go false". -->
|
||||
<span
|
||||
v-if="r.last_verified"
|
||||
class="rule-chip check-chip"
|
||||
:class="{ unchecked: r.last_verified === 'never' }"
|
||||
:title="r.last_verified === 'never'
|
||||
? 'Asserts a fact nobody has confirmed yet'
|
||||
: `Check last passed ${r.last_verified}`"
|
||||
>{{ r.last_verified === "never" ? "unverified" : `checked ${r.last_verified}` }}</span>
|
||||
</div>
|
||||
<div class="statement">{{ r.statement }}</div>
|
||||
<div v-if="r.when_to_apply || r.updated_at" class="meta">
|
||||
@@ -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; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The staleness sweep: rules that assert a FACT, oldest verification first.
|
||||
*
|
||||
* Cross-cutting by nature — a rule that has gone false does not care which
|
||||
* rulebook it sits in — so this is its own pane rather than a filter on the
|
||||
* per-topic rule list. That list can only ever show one topic of one
|
||||
* rulebook, so filtering it would quietly under-report, which is the exact
|
||||
* failure this surface exists to catch.
|
||||
*/
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import type { RuleTier } from "@/api/rulebooks";
|
||||
|
||||
const emit = defineEmits<{ "open-rule": [id: number] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const neverOnly = ref(false);
|
||||
const tier = ref<RuleTier | "">("");
|
||||
const busyId = ref<number | null>(null);
|
||||
|
||||
function reload() {
|
||||
return store.fetchRulesDue({
|
||||
neverOnly: neverOnly.value || undefined,
|
||||
tier: tier.value || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function verify(id: number, stillTrue: boolean) {
|
||||
busyId.value = id;
|
||||
try {
|
||||
await store.verifyRule(id, stillTrue);
|
||||
} finally {
|
||||
busyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reload);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pane sweep">
|
||||
<header>
|
||||
<h2>Due for verification</h2>
|
||||
<p class="lede">
|
||||
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.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="filters">
|
||||
<label class="filter">
|
||||
<input v-model="neverOnly" type="checkbox" @change="reload" />
|
||||
<span>Never checked only</span>
|
||||
</label>
|
||||
<label class="filter">
|
||||
<span>Tier</span>
|
||||
<select v-model="tier" @change="reload">
|
||||
<option value="">any</option>
|
||||
<option value="always_on">always on</option>
|
||||
<option value="conditional">conditional</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="store.loading" class="state">Loading…</p>
|
||||
|
||||
<!-- An empty sweep is GOOD NEWS, and must not read like a broken page. -->
|
||||
<p v-else-if="!store.rulesDue.length" class="state empty">
|
||||
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." }}
|
||||
</p>
|
||||
|
||||
<ol v-else class="rows">
|
||||
<li v-for="r in store.rulesDue" :key="r.id" class="row">
|
||||
<div class="row-head">
|
||||
<button class="row-title" @click="emit('open-rule', r.id)">{{ r.title }}</button>
|
||||
<span v-if="r.tier === 'always_on'" class="rule-chip" title="Loaded into every session — a wrong one is wrong everywhere at once">always on</span>
|
||||
<span class="age" :class="{ unchecked: r.days_since_verified === null }">
|
||||
{{ r.days_since_verified === null
|
||||
? "never checked"
|
||||
: `${r.days_since_verified}d ago` }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="statement">{{ r.statement }}</p>
|
||||
|
||||
<dl class="check">
|
||||
<dt>Check</dt>
|
||||
<dd><code>{{ r.verify_with }}</code></dd>
|
||||
<template v-if="r.expires_when">
|
||||
<dt>Ends when</dt>
|
||||
<dd>{{ r.expires_when }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<div class="actions">
|
||||
<button :disabled="busyId === r.id" @click="verify(r.id, true)">Still true</button>
|
||||
<button :disabled="busyId === r.id" @click="verify(r.id, false)">No longer true</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-if="store.rulesDue.length" class="footnote">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
.lede {
|
||||
margin: 0;
|
||||
max-width: 62ch;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.filters { display: flex; gap: var(--fs-space-5); align-items: center; flex-wrap: wrap; }
|
||||
.filter { display: flex; align-items: center; gap: var(--fs-space-2); font-size: 0.82rem; color: var(--fs-text-secondary); }
|
||||
.filter input[type="checkbox"] { accent-color: var(--fs-accent); }
|
||||
.filter select {
|
||||
font: inherit; font-size: 0.82rem;
|
||||
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.2rem 0.4rem;
|
||||
}
|
||||
|
||||
.state { margin: 0; font-size: 0.9rem; 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; }
|
||||
/* The ORDER carries urgency — the top of this list is the most overdue thing
|
||||
in the rulebook. No red/amber ramp: it would restate the ordering and force
|
||||
an invented "stale after N days" threshold. "Never" is marked because it is
|
||||
categorically different from a date, not a worse one. */
|
||||
.age { margin-left: auto; font-size: 0.78rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
|
||||
.age.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
|
||||
|
||||
.statement { margin: 0.35rem 0 0; font-size: 0.88rem; color: var(--fs-text-secondary); }
|
||||
|
||||
.check { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: var(--fs-space-3) 0 0; }
|
||||
.check dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fs-text-tertiary); }
|
||||
.check dd { margin: 0; font-size: 0.82rem; color: var(--fs-text-primary); min-width: 0; }
|
||||
.check code {
|
||||
font-family: var(--fs-font-mono);
|
||||
background: var(--fs-surface-code-inline);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.05rem 0.3rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.actions { display: flex; gap: var(--fs-space-2); margin-top: var(--fs-space-3); }
|
||||
.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; }
|
||||
|
||||
.footnote { margin: 0; max-width: 62ch; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||||
</style>
|
||||
@@ -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() {
|
||||
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Not a rulebook, and deliberately below them: a cross-cutting view over
|
||||
every rule the operator owns. It lives here because this is where you
|
||||
come to look at rules, and a rule that has gone false belongs to no
|
||||
one rulebook. -->
|
||||
<button
|
||||
class="sweep-entry"
|
||||
:class="{ active: sweepActive }"
|
||||
@click="emit('select-sweep')"
|
||||
>
|
||||
Due for verification
|
||||
</button>
|
||||
|
||||
<div class="new-rulebook">
|
||||
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
|
||||
<form v-else @submit.prevent="submitNew">
|
||||
@@ -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;
|
||||
|
||||
@@ -9,6 +9,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
const topicsByRulebook = ref<Record<number, RulebookTopic[]>>({});
|
||||
const rulesByTopic = ref<Record<number, RuleHeader[]>>({});
|
||||
const currentRule = ref<Rule | null>(null);
|
||||
const rulesDue = ref<api.RuleVerificationRow[]>([]);
|
||||
// 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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const selectedTopicId = ref<number | null>(null);
|
||||
const editingRuleId = ref<number | null>(null);
|
||||
const creatingRuleForTopic = ref<number | null>(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);
|
||||
<RulebookListPane
|
||||
:rulebooks="store.rulebooks"
|
||||
:selected-id="selectedRulebookId"
|
||||
:sweep-active="sweepActive"
|
||||
@select="selectRulebook"
|
||||
@select-sweep="selectSweep"
|
||||
/>
|
||||
<RuleSweepPane v-if="sweepActive" class="sweep-span" @open-rule="openRule" />
|
||||
<RulebookDetailPane
|
||||
v-if="selectedRulebookId !== null"
|
||||
v-else-if="selectedRulebookId !== null"
|
||||
:rulebook-id="selectedRulebookId"
|
||||
:topics="store.topicsByRulebook[selectedRulebookId] || []"
|
||||
:selected-topic-id="selectedTopicId"
|
||||
@@ -83,13 +99,13 @@ watch(() => route.query, syncFromRoute);
|
||||
<p>Select a rulebook to view its topics.</p>
|
||||
</div>
|
||||
<RuleListPane
|
||||
v-if="selectedTopicId !== null"
|
||||
v-if="!sweepActive && selectedTopicId !== null"
|
||||
:topic-id="selectedTopicId"
|
||||
:rules="store.rulesByTopic[selectedTopicId] || []"
|
||||
@open-rule="openRule"
|
||||
@create-rule="startCreatingRule"
|
||||
/>
|
||||
<div v-else class="pane empty">
|
||||
<div v-else-if="!sweepActive" class="pane empty">
|
||||
<p>Select a topic to view its rules.</p>
|
||||
</div>
|
||||
<RuleEditorSlideOver
|
||||
@@ -109,6 +125,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. */
|
||||
.sweep-span { grid-column: 2 / -1; }
|
||||
.pane.empty {
|
||||
background: var(--fs-surface-hover);
|
||||
padding: 1rem;
|
||||
|
||||
@@ -578,6 +578,7 @@ useEditorGuards(dirty, save);
|
||||
<select v-model="kind" @change="markDirty" class="sb-select">
|
||||
<option value="work">Work</option>
|
||||
<option value="issue">Issue</option>
|
||||
<option value="spike">Spike</option>
|
||||
<!-- 'plan' is retired (plans are milestones via start_planning);
|
||||
offered only so legacy plan-tasks display their kind. -->
|
||||
<option v-if="kind === 'plan'" value="plan">Plan (legacy)</option>
|
||||
|
||||
@@ -246,6 +246,14 @@ async def list_always_on_rules(project_id: int = 0) -> dict:
|
||||
Pair with get_project(id).applicable_rules when working on a specific
|
||||
project to also load that project's subscription-derived rules.
|
||||
|
||||
A rule carrying `last_verified` asserts a FACT about something outside the
|
||||
operator's control — a runner's shell, a tool's existence, a setting
|
||||
somewhere. It is still binding; the field says how long ago anyone
|
||||
confirmed it, and "never" means nobody has. Follow the rule, and if you
|
||||
are already standing where the check could be made, make it: get_rule
|
||||
gives you its `verify_with`. Most rules have no such field, which means
|
||||
they are decisions and there is nothing to check.
|
||||
|
||||
Args:
|
||||
project_id: 0 (default) = the user-wide set. Inside a project, pass
|
||||
its id: an always-on rulebook the project EXCLUDED at inception
|
||||
@@ -276,7 +284,8 @@ async def create_rule(
|
||||
topic_id: int, title: str, statement: str, when_to_apply: str = "",
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
tier: str = "always_on", system_ids: list[int] | None = None,
|
||||
arose_from_id: int = 0, force: bool = False,
|
||||
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
"""Create a new rule in a rulebook (a SHARED rule — keep it general).
|
||||
|
||||
@@ -288,6 +297,13 @@ async def create_rule(
|
||||
rulebook+topic ceremony). If it's a standard a CATEGORY of projects shares,
|
||||
put it in a themed subscribed rulebook, not the always-on one.
|
||||
|
||||
Write it general WITHOUT hedging for the exceptions. A project that needs
|
||||
to strengthen, narrow or replace this rule writes its own and links it
|
||||
with relate_rules(kind="overrides"), and one that adds local specifics
|
||||
uses "elaborates" — so the general form does not have to anticipate every
|
||||
project it will ever reach. A rulebook rule padded with "unless…" clauses
|
||||
for two projects is two project rules that were never written.
|
||||
|
||||
Before writing a rule at all, check whether another entity already models
|
||||
the thing. A rule is prose an agent must remember and apply; the others
|
||||
are structure a tool can resolve, render and check. Visual standards are a
|
||||
@@ -314,6 +330,15 @@ async def create_rule(
|
||||
optional: it decides the tier below, it is how the rule is found
|
||||
when it matters, and a rule nobody can place is a rule nobody
|
||||
applies.
|
||||
This field is also the rule's RETRIEVAL SURFACE — it and the
|
||||
statement are what a search is matched against, so it should
|
||||
carry the SYMPTOM, not just the situation: the words someone
|
||||
would actually type while stuck. Measured (note 3078): a rule
|
||||
whose trigger named only its situation did not surface at all
|
||||
for the problem it solves; adding the symptom to the same field
|
||||
brought it back as the top hit. Where a rule prevents a specific
|
||||
failure, put that failure's vocabulary here — the error text,
|
||||
the wrong behaviour, the dead end.
|
||||
tier: "always_on" (default) or "conditional".
|
||||
The test: can you name the trigger WITHOUT naming a system, an
|
||||
artifact type or a moment? If the honest answer is "whenever you
|
||||
@@ -328,6 +353,23 @@ async def create_rule(
|
||||
cannot be followed and does not survive a rewording.
|
||||
why: Optional rationale — the reason the rule exists.
|
||||
how_to_apply: Optional operationalization — when / where it kicks in.
|
||||
verify_with: How to CHECK this rule is still true. Set it only when
|
||||
the rule asserts a fact about something outside your control — a
|
||||
runner's shell, a bot's config, whether a tool exists. Those go
|
||||
false silently, with nobody present. Give a command, a path, a
|
||||
URL or a query; something runnable beats prose, because prose
|
||||
has to be re-interpreted by whoever finds it.
|
||||
LEAVE IT EMPTY for a rule that is a DECISION — a preference, a
|
||||
standard, a way of working. A decision has no truth value: it
|
||||
changes when you change it, and you know that you did. An empty
|
||||
verify_with is not a gap, it is the marker for "there is nothing
|
||||
to go and check," and the whole signal is worthless the moment
|
||||
it is filled in out of tidiness.
|
||||
expires_when: The STATE under which this rule stops being true —
|
||||
"when the runner can be given a bash shell", "when the dashboard
|
||||
approval setting is turned off". Deliberately not a date: a
|
||||
constraint expires when the ground under it moves, not on a
|
||||
schedule. Pairs with verify_with; both empty is the normal case.
|
||||
order_index: Display order within the topic (default 0).
|
||||
force: Bypass the near-duplicate gate. By default, a title-identical rule
|
||||
already in this topic BLOCKS creation and returns its id so you update
|
||||
@@ -343,6 +385,7 @@ async def create_rule(
|
||||
title=title, statement=statement, when_to_apply=when_to_apply,
|
||||
tier=tier, arose_from_id=arose_from_id,
|
||||
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
||||
verify_with=verify_with, expires_when=expires_when,
|
||||
)
|
||||
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
||||
|
||||
@@ -351,7 +394,8 @@ async def create_project_rule(
|
||||
project_id: int, statement: str, title: str = "", when_to_apply: str = "",
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
tier: str = "always_on", system_ids: list[int] | None = None,
|
||||
arose_from_id: int = 0, force: bool = False,
|
||||
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
"""Create a rule scoped to a single project (no rulebook needed).
|
||||
|
||||
@@ -375,14 +419,45 @@ async def create_project_rule(
|
||||
title: Short imperative title. If empty, derived from the first ~50
|
||||
characters of statement.
|
||||
when_to_apply: WHEN this rule fires — the trigger, not the
|
||||
instruction. See create_rule; it decides the tier and it is how
|
||||
the rule is found at the moment it matters.
|
||||
tier: "always_on" (default) or "conditional" — see create_rule.
|
||||
instruction, and the rule's retrieval surface: name the SYMPTOM,
|
||||
the words someone would type while stuck. See create_rule for the
|
||||
full argument. It informs the tier below rather than deciding it,
|
||||
since a project rule's tier turns on area-scope, not on whether
|
||||
the trigger can be named.
|
||||
tier: "always_on" (default) or "conditional". The SAME two values as
|
||||
create_rule, judged against a different cost — do not import that
|
||||
tool's test wholesale. There, always_on means every session in
|
||||
every project, so the bar is high: the trigger must be nameless
|
||||
("whenever you are working"). Here the rule is already scoped to
|
||||
one project by construction, so always_on costs only that
|
||||
project's sessions and the bar is correspondingly lower. A
|
||||
project rule that names something specific is still ordinarily
|
||||
always_on — being specific is what project rules are FOR.
|
||||
Reach for conditional when the rule is about one AREA of a large
|
||||
project — a CI quirk, a migration gotcha, one subsystem's
|
||||
convention — so it arrives with that area instead of resident in
|
||||
every session. The failure to avoid is local: forty always-on
|
||||
rules on one project reproduces, inside that project, exactly the
|
||||
preload bloat that made every rule compete for the same budget.
|
||||
system_ids: Ids from list_canonical_systems — the global AREAS this
|
||||
rule is about.
|
||||
arose_from_id: The note or task that CAUSED this rule.
|
||||
rule is about. Worth setting even on a project rule: it is what
|
||||
lets a conditional one surface when the project is working in
|
||||
that area.
|
||||
arose_from_id: The note or task that CAUSED this rule. Reach for it
|
||||
harder here than on a rulebook rule — a project rule usually
|
||||
comes from one traceable incident in this repo, where a family
|
||||
rule is more often a standing preference with no single origin.
|
||||
The link is what lets a later reader judge whether the incident
|
||||
still describes the project.
|
||||
why: Optional rationale — the reason the rule exists.
|
||||
how_to_apply: Optional operationalization — when / where it kicks in.
|
||||
verify_with: How to check this rule is still true — see create_rule.
|
||||
Set it when the rule asserts a fact about someone else's software;
|
||||
leave it empty when the rule is a decision. Project rules are the
|
||||
likelier home for a real check: they name this project's files,
|
||||
paths and quirks, which is exactly the kind of claim that rots.
|
||||
expires_when: The state under which the rule stops being true — see
|
||||
create_rule. A state, not a date.
|
||||
order_index: Display order within the project's rule list (default 0).
|
||||
force: Bypass the near-duplicate gate. By default, a title-identical rule
|
||||
already on this project BLOCKS creation and returns its id so you
|
||||
@@ -399,6 +474,7 @@ async def create_project_rule(
|
||||
title=derived_title, statement=statement, when_to_apply=when_to_apply,
|
||||
tier=tier, arose_from_id=arose_from_id,
|
||||
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
||||
verify_with=verify_with, expires_when=expires_when,
|
||||
)
|
||||
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
||||
|
||||
@@ -407,12 +483,33 @@ async def update_rule(
|
||||
rule_id: int, title: str = "", statement: str = "", when_to_apply: str = "",
|
||||
why: str = "", how_to_apply: str = "", order_index: int = -1,
|
||||
tier: str = "", system_ids: list[int] | None = None, arose_from_id: int = 0,
|
||||
verify_with: str = "", expires_when: str = "",
|
||||
clear_fields: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Update a rule. Empty strings / order_index=-1 leave fields unchanged.
|
||||
|
||||
Adding `when_to_apply` and a `tier` to an existing rule is the ordinary way
|
||||
a rule stops being preloaded into every session and starts arriving when it
|
||||
is relevant. `system_ids` REPLACES the rule's areas (pass [] to clear).
|
||||
|
||||
TO EMPTY A FIELD, NAME IT: clear_fields=["verify_with"]. Passing "" cannot
|
||||
do it — "" means "leave this alone" here, which is what lets you update
|
||||
two fields without wiping the other six. Clearable: why, how_to_apply,
|
||||
when_to_apply, verify_with, expires_when, arose_from_id. Clearing and
|
||||
setting the same field in one call clears it first, so the new value wins.
|
||||
|
||||
Editing `verify_with` DROPS the rule's verification stamp. The stamp
|
||||
certifies a check, not a rule; once the check is reworded the old stamp
|
||||
vouches for something that no longer exists, so the rule re-enters the
|
||||
staleness sweep as never-verified.
|
||||
|
||||
Args:
|
||||
verify_with: How to check the rule is still true — set it when the
|
||||
rule asserts a fact about someone else's software, leave it empty
|
||||
when the rule is a decision. See create_rule.
|
||||
expires_when: The state under which the rule stops being true. A
|
||||
state, not a date. See create_rule.
|
||||
clear_fields: Names of fields to empty, as above.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
@@ -430,9 +527,15 @@ async def update_rule(
|
||||
fields["why"] = why
|
||||
if how_to_apply:
|
||||
fields["how_to_apply"] = how_to_apply
|
||||
if verify_with:
|
||||
fields["verify_with"] = verify_with
|
||||
if expires_when:
|
||||
fields["expires_when"] = expires_when
|
||||
if order_index >= 0:
|
||||
fields["order_index"] = order_index
|
||||
rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
|
||||
rule = await rulebooks_svc.update_rule(
|
||||
rule_id, uid, clear=clear_fields or (), **fields,
|
||||
)
|
||||
if rule is None:
|
||||
raise ValueError(f"rule {rule_id} not found")
|
||||
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
||||
@@ -619,6 +722,97 @@ async def unrelate_rules(relation_id: int) -> dict:
|
||||
raise ValueError(f"relation {relation_id} not found")
|
||||
return {"deleted": relation_id}
|
||||
|
||||
# ── The staleness sweep (milestone 312) ────────────────────────────────
|
||||
|
||||
async def rules_due_for_verification(
|
||||
older_than_days: int = 0, tier: str = "", never_only: bool = False,
|
||||
) -> dict:
|
||||
"""Which standing rules assert a FACT that nobody has confirmed lately.
|
||||
|
||||
A rulebook holds two kinds of thing. Most rules are DECISIONS — how the
|
||||
operator wants to work. They have no truth value and cannot rot. A few
|
||||
assert a fact about someone else's software: what a CI runner does, which
|
||||
tools exist, what a setting is currently set to. Those go false silently,
|
||||
with nobody present, and they keep being handed to every session as
|
||||
binding instructions long after they stopped being true.
|
||||
|
||||
This lists the second kind, oldest verification first, never-checked at
|
||||
the top. Each row carries the rule's `verify_with` in full — you are
|
||||
about to go and run it — plus `expires_when`, and `days_since_verified`.
|
||||
|
||||
Reach for it when you are curating the rulebook, when a rule's advice
|
||||
just contradicted what you observed, or periodically. Then, for each row:
|
||||
run the check, and call mark_rule_verified with what you found.
|
||||
|
||||
Rules with no `verify_with` never appear here. That is correct: they are
|
||||
decisions, and there is nothing to go and check. Do not "fix" their
|
||||
absence by giving them checks — the list is only worth reading while
|
||||
everything on it genuinely can go false.
|
||||
|
||||
Args:
|
||||
older_than_days: only rules last verified longer ago than this.
|
||||
Never-checked rules always qualify. 0 = no age filter.
|
||||
tier: "always_on" or "conditional" to narrow. An always-on constraint
|
||||
that has gone false is the expensive kind — it is preloaded into
|
||||
every session, so a wrong one is wrong everywhere at once.
|
||||
never_only: only rules nobody has ever verified.
|
||||
|
||||
NOT filterable by project, deliberately: a project reaches rules through
|
||||
project scope, subscriptions, always-on rulebooks and exclusions, and a
|
||||
filter that missed one of those paths would UNDER-report — which is the
|
||||
exact failure this whole surface exists to prevent. Read the whole list.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
rules = await rulebooks_svc.rules_due_for_verification(
|
||||
uid, older_than_days=older_than_days, tier=tier, never_only=never_only,
|
||||
)
|
||||
return {
|
||||
"rules": [rulebooks_svc.verification_row(r) for r in rules],
|
||||
"total": len(rules),
|
||||
}
|
||||
|
||||
|
||||
async def mark_rule_verified(rule_id: int, still_true: bool = True) -> dict:
|
||||
"""Record that you ran a rule's check — and what it said.
|
||||
|
||||
Call this AFTER actually running the rule's `verify_with`, never on the
|
||||
strength of the rule sounding plausible. A stamp nobody earned is worse
|
||||
than no stamp: it moves the rule to the bottom of the sweep and buys it
|
||||
another long silence.
|
||||
|
||||
`still_true=False` writes NOTHING. A rule whose check failed is not in a
|
||||
special state to be recorded — it is WRONG, and the only honest next
|
||||
moves are to correct it, retire it, or find out why. So it stays at the
|
||||
top of the sweep until someone deals with it, and the response tells you
|
||||
what the rule said would end it.
|
||||
|
||||
Args:
|
||||
rule_id: the rule whose check you ran.
|
||||
still_true: True if the check passed. False if the fact it asserts is
|
||||
no longer true — say so, that is the outcome worth having.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
rule = await rulebooks_svc.mark_rule_verified(rule_id, uid, still_true)
|
||||
if rule is None:
|
||||
raise ValueError(
|
||||
f"rule {rule_id} not found, or carries no verify_with "
|
||||
f"(nothing to verify is not the same as verified)"
|
||||
)
|
||||
data = await rulebooks_svc.rule_detail(uid, rule)
|
||||
if still_true:
|
||||
data["verified"] = True
|
||||
return data
|
||||
data["verified"] = False
|
||||
data["next"] = (
|
||||
"This rule is no longer true and is still binding on every session "
|
||||
"that loads it. Correct it with update_rule, retire it with "
|
||||
"delete_rule, or open a task to work out what replaced it. Its "
|
||||
"verified_at is deliberately untouched, so it stays at the top of "
|
||||
"rules_due_for_verification until one of those happens."
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
||||
@@ -630,5 +824,6 @@ def register(mcp) -> None:
|
||||
suppress_rule_for_project, unsuppress_rule_for_project,
|
||||
suppress_topic_for_project, unsuppress_topic_for_project,
|
||||
exclude_always_on_rulebook, include_always_on_rulebook,
|
||||
rules_due_for_verification, mark_rule_verified,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -14,6 +14,7 @@ from scribe.services.access import owner_names_for
|
||||
from scribe.services.embeddings import (
|
||||
DEFAULT_SIMILARITY_THRESHOLD, semantic_search_notes, semantic_search_rules,
|
||||
)
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summary
|
||||
|
||||
|
||||
@@ -23,7 +24,10 @@ async def _search_rules(uid: int, q: str, limit: int) -> dict:
|
||||
A rule hit carries `why` and `how_to_apply`: they are the operational half
|
||||
of a rule and the session-start payload never includes them, so a caller
|
||||
who went looking should get the whole thing rather than a summary they then
|
||||
have to re-fetch.
|
||||
have to re-fetch. It also carries the rule's check (`verify_with`,
|
||||
`expires_when`, `last_verified`) when it has one — a search hit is exactly
|
||||
the moment someone is about to act on a rule, and "this asserts a fact
|
||||
nobody has confirmed" is part of what the rule says.
|
||||
|
||||
Rules are not project-scoped the way notes are (a family rule belongs to no
|
||||
project), so `project_id` and `system_id` do not apply here.
|
||||
@@ -39,6 +43,14 @@ async def _search_rules(uid: int, q: str, limit: int) -> dict:
|
||||
"tier": rule.tier,
|
||||
"why": rule.why or "",
|
||||
"how_to_apply": rule.how_to_apply or "",
|
||||
"verify_with": rule.verify_with or "",
|
||||
"expires_when": rule.expires_when or "",
|
||||
# Only on a rule that carries a check; its absence means the
|
||||
# rule is a decision, not that nobody has looked.
|
||||
**(
|
||||
{"last_verified": rulebooks_svc.last_verified_label(rule)}
|
||||
if rule.verify_with else {}
|
||||
),
|
||||
"topic_id": rule.topic_id,
|
||||
"project_id": rule.project_id,
|
||||
"similarity": float(score),
|
||||
|
||||
@@ -336,7 +336,8 @@ async def list_system_records(
|
||||
slice, search(system_id=...) filters semantic search to this association.
|
||||
|
||||
Args:
|
||||
kind: filter by task_kind — 'issue', 'work', or 'plan'. Omit for all.
|
||||
kind: filter by task_kind — 'issue', 'work', 'spike' (or the retired
|
||||
'plan'). Omit for all.
|
||||
open_only: limit to tasks not done/cancelled (e.g. open issues only).
|
||||
"""
|
||||
uid = current_user_id()
|
||||
|
||||
@@ -46,7 +46,8 @@ async def list_tasks(
|
||||
whenever a project is in scope so you list that project's tasks, not
|
||||
every project's. 0 = no filter (all projects — use only for a
|
||||
deliberate cross-project view).
|
||||
kind: Filter by task kind — 'work', 'plan', or 'issue'. Omit (empty) for all kinds.
|
||||
kind: Filter by task kind — 'work', 'issue', 'spike' (or the retired
|
||||
'plan'). Omit (empty) for all kinds.
|
||||
|
||||
Results are ordered by last-updated descending.
|
||||
"""
|
||||
@@ -138,14 +139,24 @@ async def create_task(
|
||||
milestone_id: Place within a project milestone (0 = no milestone).
|
||||
parent_id: Make this a sub-task of another task (0 = top-level).
|
||||
tags: List of plain-string tags without # prefix.
|
||||
kind: 'work' (default) or 'issue'. An issue is corrective work — a
|
||||
problem you fixed or are fixing; record symptom → root cause → fix
|
||||
in the body. (Plans are milestones now — call start_planning to begin
|
||||
a plan; 'plan' is not a valid kind here.)
|
||||
kind: 'work' (default), 'issue', or 'spike'.
|
||||
An ISSUE is corrective work — a problem you fixed or are fixing;
|
||||
record symptom → root cause → fix in the body.
|
||||
A SPIKE is time-boxed and its output is KNOWLEDGE rather than a
|
||||
change: "find out whether the runner can be given a bash shell",
|
||||
"work out why the index is not used". It succeeds by producing an
|
||||
answer, so nothing ships at the end of it — which is why filing
|
||||
one as `work` makes a finished investigation look like an
|
||||
abandoned change. Reach for it when the honest deliverable is a
|
||||
finding, and say in the body what would close the box: a time, or
|
||||
the question being answered well enough to act on.
|
||||
(Plans are milestones now — call start_planning to begin a plan;
|
||||
'plan' is not a valid kind here.)
|
||||
system_ids: Ids of the project's Systems (reusable subsystem/area
|
||||
objects; see list_systems / create_system) to associate this task with.
|
||||
arose_from_id: For an issue, the id of the task/feature it arose from
|
||||
(provenance). 0 = none.
|
||||
arose_from_id: For an issue, the id of the task/feature it arose from;
|
||||
for a spike, the record that raised the question — including a
|
||||
standing rule whose check just failed. 0 = none.
|
||||
force: Bypass the near-duplicate gate. By default, if a title- or
|
||||
meaning-similar task already exists in the same project, creation is
|
||||
BLOCKED and the existing task's id is returned so you update it
|
||||
|
||||
@@ -61,10 +61,16 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
||||
# Note type — 'note' (default) or 'process' (a stored process). Task-ness is
|
||||
# tracked by `status`, not here. (person/place/list entity types removed 2026-07.)
|
||||
note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note")
|
||||
# Task sub-kind — 'work' (default), 'plan', or 'issue' (corrective work).
|
||||
# Task sub-kind — what KIND of work this is, not how it is going:
|
||||
# work (default) — ships a change
|
||||
# issue — corrective; something was broken (0065)
|
||||
# spike — time-boxed, and its output is KNOWLEDGE rather than a change;
|
||||
# it succeeds by producing an answer, and nothing ships (0091)
|
||||
# plan — retired since 0066 (plans are milestones), kept in the CHECK
|
||||
# so historical plan-tasks stay writable
|
||||
# Only meaningful when the note is a task (status is not None); ordinary
|
||||
# notes keep the 'work' default and ignore it. Orthogonal to note_type
|
||||
# (which is the note/entity axis).
|
||||
# (which is the note/entity axis). CHECK notes_task_kind_check (rule 36).
|
||||
task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work")
|
||||
# Queryable structured fields for typed records — currently snippets, whose
|
||||
# name/language/signature/locations live here so they can be INDEXED. The
|
||||
|
||||
@@ -107,6 +107,26 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
||||
tier: Mapped[str] = mapped_column(Text, default="always_on", server_default="always_on")
|
||||
why: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# The three fields that tell a CONSTRAINT apart from a NORM (milestone
|
||||
# 312). A norm is a decision — no truth value, changes only when its
|
||||
# author changes it. A constraint asserts a fact about someone else's
|
||||
# software, and goes false with nobody watching: every stale rule the
|
||||
# 307 audit found was one, and no norm had rotted.
|
||||
#
|
||||
# `verify_with` is how to check the rule is still true; `expires_when` is
|
||||
# the STATE that ends it, deliberately not a date — constraints expire
|
||||
# when the ground moves, not on a schedule. `verified_at` NULL means
|
||||
# never checked, and sorts FIRST in the sweep: unexamined outranks
|
||||
# examined-long-ago.
|
||||
#
|
||||
# Most rules should leave all three empty. A null `verify_with` is not a
|
||||
# gap — it is the marker for "this is a decision, there is nothing to go
|
||||
# and check," and the signal is only worth reading while that stays true.
|
||||
verify_with: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
expires_when: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
verified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# The record that caused this rule — the edge notes and tasks already
|
||||
# have. Rule 46's `why` names note 2813 in prose; this is that link as a
|
||||
# field, so it survives a rewording of the paragraph.
|
||||
@@ -126,6 +146,9 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"tier": self.tier,
|
||||
"why": self.why or "",
|
||||
"how_to_apply": self.how_to_apply or "",
|
||||
"verify_with": self.verify_with or "",
|
||||
"expires_when": self.expires_when or "",
|
||||
"verified_at": iso(self.verified_at),
|
||||
"arose_from_id": self.arose_from_id,
|
||||
"order_index": self.order_index,
|
||||
"created_at": iso(self.created_at),
|
||||
|
||||
@@ -165,6 +165,8 @@ async def create_rule(topic_id: int):
|
||||
when_to_apply=data.get("when_to_apply", ""),
|
||||
tier=data.get("tier", "always_on"),
|
||||
arose_from_id=data.get("arose_from_id", 0) or 0,
|
||||
verify_with=data.get("verify_with", ""),
|
||||
expires_when=data.get("expires_when", ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
@@ -191,8 +193,13 @@ async def update_rule(rule_id: int):
|
||||
fields = {
|
||||
k: v for k, v in data.items()
|
||||
if k in ("title", "statement", "why", "how_to_apply", "order_index",
|
||||
"when_to_apply", "tier", "arose_from_id")
|
||||
"when_to_apply", "tier", "arose_from_id",
|
||||
"verify_with", "expires_when")
|
||||
}
|
||||
# No clear_fields here: a form sends "" for an emptied input, and the
|
||||
# service normalises "" to NULL for every nullable text column. The MCP
|
||||
# door needs the explicit list only because "" already means "unchanged"
|
||||
# there — two idioms, one outcome.
|
||||
rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
|
||||
if rule is None:
|
||||
return jsonify({"error": "rule not found"}), 404
|
||||
@@ -375,9 +382,64 @@ async def create_project_rule(project_id: int):
|
||||
when_to_apply=data.get("when_to_apply", ""),
|
||||
tier=data.get("tier", "always_on"),
|
||||
arose_from_id=data.get("arose_from_id", 0) or 0,
|
||||
verify_with=data.get("verify_with", ""),
|
||||
expires_when=data.get("expires_when", ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return jsonify(await rulebooks_svc.rule_detail(
|
||||
get_current_user_id(), rule, data.get("system_ids"),
|
||||
)), 201
|
||||
|
||||
|
||||
# ── The staleness sweep (milestone 312) ────────────────────────────────
|
||||
|
||||
@rulebooks_bp.get("/rules-due-for-verification")
|
||||
@login_required
|
||||
async def rules_due_for_verification():
|
||||
"""Rules that carry a check, oldest verification first, never-checked top.
|
||||
|
||||
Query params: older_than_days, tier, never_only. A rule with no
|
||||
`verify_with` never appears — it is a decision, not a fact.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
args = request.args
|
||||
try:
|
||||
older = int(args.get("older_than_days", 0) or 0)
|
||||
except ValueError:
|
||||
return jsonify({"error": "older_than_days must be an integer"}), 400
|
||||
try:
|
||||
rules = await rulebooks_svc.rules_due_for_verification(
|
||||
uid,
|
||||
older_than_days=older,
|
||||
tier=args.get("tier", ""),
|
||||
never_only=args.get("never_only", "").lower() in ("1", "true", "yes"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
# An unrecognised tier is a 400, not a silently narrowed result set:
|
||||
# a filter that quietly answers a different question is the failure
|
||||
# this whole surface exists to catch.
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify({
|
||||
"rules": [rulebooks_svc.verification_row(r) for r in rules],
|
||||
"total": len(rules),
|
||||
})
|
||||
|
||||
|
||||
@rulebooks_bp.post("/rules/<int:rule_id>/verify")
|
||||
@login_required
|
||||
async def mark_rule_verified(rule_id: int):
|
||||
"""Record that the rule's check was run. Body: {"still_true": bool}.
|
||||
|
||||
`still_true: false` writes nothing — a rule whose check failed is wrong,
|
||||
not in a recordable state — so it stays at the top of the sweep.
|
||||
"""
|
||||
data = await request.get_json() or {}
|
||||
uid = get_current_user_id()
|
||||
still_true = data.get("still_true", True)
|
||||
rule = await rulebooks_svc.mark_rule_verified(rule_id, uid, bool(still_true))
|
||||
if rule is None:
|
||||
return jsonify({"error": "rule not found, or carries no verify_with"}), 404
|
||||
payload = await rulebooks_svc.rule_detail(uid, rule)
|
||||
payload["verified"] = bool(still_true)
|
||||
return jsonify(payload)
|
||||
|
||||
@@ -112,6 +112,18 @@ def _dt(val: str | None) -> datetime:
|
||||
return datetime.fromisoformat(val) if val else datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _dt_or_none(val: str | None) -> datetime | None:
|
||||
"""Like _dt, but keeps an absent timestamp absent.
|
||||
|
||||
_dt substitutes now() because created_at/updated_at must not be null.
|
||||
For a nullable column that MEANS something by being empty, that default
|
||||
is a lie: a rule nobody ever verified would restore looking verified at
|
||||
the moment of the restore, and drop straight to the bottom of the sweep
|
||||
it should have topped.
|
||||
"""
|
||||
return datetime.fromisoformat(val) if val else None
|
||||
|
||||
|
||||
def _d(val: str | None) -> date | None:
|
||||
return date.fromisoformat(val) if val else None
|
||||
|
||||
@@ -385,6 +397,8 @@ def _rule_rows(rows) -> list[dict]:
|
||||
"title": r.title, "statement": r.statement, "why": r.why,
|
||||
"how_to_apply": r.how_to_apply, "order_index": r.order_index,
|
||||
"when_to_apply": r.when_to_apply, "tier": r.tier,
|
||||
"verify_with": r.verify_with, "expires_when": r.expires_when,
|
||||
"verified_at": r.verified_at.isoformat() if r.verified_at else None,
|
||||
"arose_from_id": r.arose_from_id,
|
||||
"created_at": r.created_at.isoformat(),
|
||||
"updated_at": r.updated_at.isoformat(),
|
||||
@@ -1007,6 +1021,19 @@ async def _restore_v2(data: dict) -> dict:
|
||||
# is the pre-0088 behaviour, so an old backup restores rules
|
||||
# that bind exactly as they did when it was taken.
|
||||
tier=r_data.get("tier") or "always_on",
|
||||
verify_with=r_data.get("verify_with") or None,
|
||||
expires_when=r_data.get("expires_when") or None,
|
||||
# Restored as-is, NOT reset to null. `verified_at` records
|
||||
# when someone last ran the check; a restore does not make
|
||||
# that untrue, and clearing it would put every constraint at
|
||||
# the top of the sweep with nothing having actually changed.
|
||||
verified_at=_dt_or_none(r_data.get("verified_at")),
|
||||
# Remapped through note_id_map like every other note edge.
|
||||
# Exported since 0088 but dropped on the way back in until
|
||||
# milestone 312 — a restore silently lost every rule's
|
||||
# provenance link. SET NULL semantics apply here too: a
|
||||
# source note that didn't restore leaves the rule intact.
|
||||
arose_from_id=note_id_map.get(r_data.get("arose_from_id") or 0),
|
||||
order_index=r_data.get("order_index", 0),
|
||||
created_at=_dt(r_data.get("created_at")),
|
||||
updated_at=_dt(r_data.get("updated_at")),
|
||||
|
||||
@@ -8,9 +8,10 @@ depending on the caller's needs (mirroring services/events.py pattern).
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import delete as sql_delete, insert, or_, select
|
||||
from sqlalchemy import and_, delete as sql_delete, insert, or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.system import System
|
||||
@@ -288,6 +289,17 @@ TIERS = ("always_on", "conditional")
|
||||
RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
|
||||
|
||||
|
||||
# The rule columns that are nullable, and therefore the ones where EMPTY has
|
||||
# to mean empty. A write that stores "" leaves a column that is not NULL and
|
||||
# not content — `verify_with IS NOT NULL` would then be true for a rule with
|
||||
# no check, and the staleness sweep would list rules it should never see.
|
||||
# Normalising here, at the one service seam, is what makes "unset" a single
|
||||
# state instead of two that read alike through to_dict's `or ""`.
|
||||
NULLABLE_RULE_TEXT = (
|
||||
"why", "how_to_apply", "when_to_apply", "verify_with", "expires_when",
|
||||
)
|
||||
|
||||
|
||||
def _valid_tier(tier: str) -> str:
|
||||
"""An unrecognised tier falls back to always_on — the SAFE direction.
|
||||
|
||||
@@ -299,6 +311,21 @@ def _valid_tier(tier: str) -> str:
|
||||
return tier if tier in TIERS else "always_on"
|
||||
|
||||
|
||||
def last_verified_label(rule: Rule) -> str | None:
|
||||
"""How long ago the rule's check passed — None when it carries no check.
|
||||
|
||||
One helper because two surfaces need the same answer and the brief-dict
|
||||
lesson in rule_brief's docstring is what happens otherwise: three copies
|
||||
that had already drifted. `None` means "this rule is a decision, the
|
||||
question does not apply"; "never" means "it is a fact and nobody has
|
||||
confirmed it" — a distinction worth keeping, because the second is the
|
||||
one worth acting on.
|
||||
"""
|
||||
if not rule.verify_with:
|
||||
return None
|
||||
return rule.verified_at.date().isoformat() if rule.verified_at else "never"
|
||||
|
||||
|
||||
def rule_brief(rule: Rule, **extra) -> dict:
|
||||
"""The shape a rule takes when it is SURFACED rather than opened.
|
||||
|
||||
@@ -331,6 +358,16 @@ def rule_brief(rule: Rule, **extra) -> dict:
|
||||
out["when_to_apply"] = rule.when_to_apply
|
||||
if rule.arose_from_id:
|
||||
out["arose_from_id"] = rule.arose_from_id
|
||||
# Present ONLY on a rule that carries a check — its presence is the
|
||||
# signal, and it says two things at once: this rule asserts a fact that
|
||||
# can go false, and here is how long ago anyone confirmed it. The check
|
||||
# text itself stays in get_rule; a listing needs to know WHICH rules can
|
||||
# rot, not how to test them. "never" rather than null, per #2483: a key
|
||||
# that reads as an unused capability is a different claim from a rule
|
||||
# nobody has ever verified.
|
||||
stamp = last_verified_label(rule)
|
||||
if stamp:
|
||||
out["last_verified"] = stamp
|
||||
out.update({k: v for k, v in extra.items() if v is not None})
|
||||
return out
|
||||
|
||||
@@ -436,6 +473,7 @@ async def create_rule(
|
||||
topic_id: int, user_id: int, title: str, statement: str,
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
|
||||
verify_with: str = "", expires_when: str = "",
|
||||
) -> Rule:
|
||||
async with async_session() as session:
|
||||
await _assert_topic_owned(session, topic_id, user_id)
|
||||
@@ -447,6 +485,8 @@ async def create_rule(
|
||||
tier=_valid_tier(tier),
|
||||
why=why or None,
|
||||
how_to_apply=how_to_apply or None,
|
||||
verify_with=verify_with or None,
|
||||
expires_when=expires_when or None,
|
||||
arose_from_id=arose_from_id or None,
|
||||
order_index=order_index,
|
||||
)
|
||||
@@ -461,6 +501,7 @@ async def create_project_rule(
|
||||
project_id: int, user_id: int, title: str, statement: str,
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
|
||||
verify_with: str = "", expires_when: str = "",
|
||||
) -> Rule:
|
||||
"""Create a rule scoped to a single project (no rulebook ceremony).
|
||||
|
||||
@@ -478,6 +519,8 @@ async def create_project_rule(
|
||||
tier=_valid_tier(tier),
|
||||
why=why or None,
|
||||
how_to_apply=how_to_apply or None,
|
||||
verify_with=verify_with or None,
|
||||
expires_when=expires_when or None,
|
||||
arose_from_id=arose_from_id or None,
|
||||
order_index=order_index,
|
||||
)
|
||||
@@ -681,7 +724,23 @@ async def get_rule(rule_id: int, user_id: int) -> Optional[Rule]:
|
||||
return await _fetch_owned_rule(session, rule_id, user_id)
|
||||
|
||||
|
||||
async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
|
||||
async def update_rule(
|
||||
rule_id: int, user_id: int, clear: Iterable[str] = (), **fields,
|
||||
) -> Optional[Rule]:
|
||||
"""Patch a rule. `clear` names fields to unset; **fields carries new values.
|
||||
|
||||
Clearing is EXPLICIT and separate because a nullable field cannot be
|
||||
emptied by passing it. The MCP door reads "" as "leave this alone" — an
|
||||
agent filling three fields must not wipe the other five — so a caller
|
||||
there has no value that means "remove it", and a rule that stops being a
|
||||
constraint genuinely needs its check removed. Naming the field is the one
|
||||
form that cannot happen by accident.
|
||||
|
||||
Callers that DO have a meaningful empty value (the REST door, where a
|
||||
cleared form input arrives as "") get the same outcome through
|
||||
NULLABLE_RULE_TEXT normalisation below, so the two doors keep their own
|
||||
idiom and agree about the result.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
||||
if rule is None:
|
||||
@@ -689,10 +748,32 @@ async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
|
||||
allowed = {
|
||||
"title", "statement", "why", "how_to_apply", "order_index",
|
||||
"when_to_apply", "tier", "arose_from_id",
|
||||
"verify_with", "expires_when",
|
||||
}
|
||||
check_before = rule.verify_with
|
||||
for key in clear:
|
||||
if key in allowed and key in NULLABLE_RULE_TEXT:
|
||||
setattr(rule, key, None)
|
||||
elif key == "arose_from_id":
|
||||
setattr(rule, key, None)
|
||||
for key, value in fields.items():
|
||||
if key in allowed and value is not None:
|
||||
setattr(rule, key, _valid_tier(value) if key == "tier" else value)
|
||||
if key not in allowed or value is None:
|
||||
continue
|
||||
if key == "tier":
|
||||
value = _valid_tier(value)
|
||||
elif key in NULLABLE_RULE_TEXT:
|
||||
value = value or None
|
||||
elif key == "arose_from_id":
|
||||
value = value or None
|
||||
setattr(rule, key, value)
|
||||
# A verification stamp certifies A CHECK, not a rule. Rewrite or
|
||||
# remove the check and the old stamp certifies something that no
|
||||
# longer exists — so it is dropped, and the rule re-enters the sweep.
|
||||
# The safe direction, for the same reason _valid_tier falls back to
|
||||
# always_on: a rule wrongly listed as due costs one look, a rule
|
||||
# wrongly vouched for costs the thing the sweep exists to catch.
|
||||
if rule.verify_with != check_before:
|
||||
rule.verified_at = None
|
||||
await session.commit()
|
||||
await session.refresh(rule)
|
||||
_refresh_rule_embedding(rule)
|
||||
@@ -1280,3 +1361,150 @@ def rules_payload(applicable: dict) -> dict:
|
||||
"suppressed_topics": applicable.get("suppressed_topics", []),
|
||||
"excluded_always_on": applicable.get("excluded_always_on", []),
|
||||
}
|
||||
|
||||
|
||||
# ── The staleness sweep (milestone 312) ────────────────────────────────
|
||||
|
||||
async def rules_due_for_verification(
|
||||
user_id: int,
|
||||
older_than_days: int = 0,
|
||||
tier: str = "",
|
||||
never_only: bool = False,
|
||||
) -> list[Rule]:
|
||||
"""Rules that carry a check, oldest verification first, never-checked top.
|
||||
|
||||
THE QUERY THIS MILESTONE EXISTS FOR. `verify_with` and `expires_when` are
|
||||
storage; this is what turns them into something that gets acted on. The
|
||||
307 audit cost a session and found four broken rules by luck — this makes
|
||||
the same question a list, and staleness measurable by age instead of
|
||||
discoverable by accident.
|
||||
|
||||
Ordered `verified_at` ASC NULLS FIRST: never-checked outranks
|
||||
checked-long-ago, because a rule nobody has ever confirmed is a claim
|
||||
with no evidence behind it at all.
|
||||
|
||||
Rules with no `verify_with` never appear. That is not an omission — they
|
||||
are decisions, there is nothing to go and check, and listing them would
|
||||
dilute the result until nobody reads it.
|
||||
|
||||
Ownership-scoped exactly like list_rules: a rule reached through an owned
|
||||
rulebook, or scoped to an owned project. Rules have no sharing ACL in this
|
||||
schema — no rule_shares, no rulebook_shares — so there is no wider set to
|
||||
consult here, unlike notes and projects.
|
||||
|
||||
Args:
|
||||
user_id: whose rules.
|
||||
older_than_days: only rules last verified longer ago than this.
|
||||
Never-checked rules always qualify — they are the most overdue
|
||||
thing there is. 0 = no age filter.
|
||||
tier: "always_on" or "conditional" to narrow. Raises on anything else
|
||||
rather than falling back: _valid_tier's silent always_on default
|
||||
is right for a WRITE (the safe direction is to keep binding), and
|
||||
wrong for a FILTER, where it would quietly answer a different
|
||||
question than the one asked.
|
||||
never_only: only rules that have never been verified.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from scribe.models.project import Project
|
||||
|
||||
if tier and tier not in TIERS:
|
||||
raise ValueError(f"tier must be one of {TIERS}, got {tier!r}")
|
||||
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(Rule)
|
||||
.outerjoin(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
||||
.outerjoin(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||
.outerjoin(Project, Rule.project_id == Project.id)
|
||||
.where(
|
||||
Rule.deleted_at.is_(None),
|
||||
Rule.verify_with.is_not(None),
|
||||
# One statement rather than two queries merged in Python, so
|
||||
# the ordering below is the database's and cannot disagree
|
||||
# with itself across the two halves of the XOR.
|
||||
or_(
|
||||
and_(
|
||||
Rulebook.owner_user_id == user_id,
|
||||
Rulebook.deleted_at.is_(None),
|
||||
RulebookTopic.deleted_at.is_(None),
|
||||
),
|
||||
Project.user_id == user_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
if tier:
|
||||
stmt = stmt.where(Rule.tier == tier)
|
||||
if never_only:
|
||||
stmt = stmt.where(Rule.verified_at.is_(None))
|
||||
elif older_than_days > 0:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
|
||||
stmt = stmt.where(
|
||||
or_(Rule.verified_at.is_(None), Rule.verified_at < cutoff)
|
||||
)
|
||||
stmt = stmt.order_by(Rule.verified_at.asc().nullsfirst(), Rule.id)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
def verification_row(rule: Rule) -> dict:
|
||||
"""One row of the sweep — the CHECK in full, unlike rule_brief.
|
||||
|
||||
The opposite call from a listing: here the caller is about to go and run
|
||||
the check, so the text they need is the point of the payload rather than
|
||||
the bloat. `days_since` is computed rather than left to the reader,
|
||||
because "2026-06-14" and "74 days" prompt different reactions and only
|
||||
one of them is the question being asked.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
days = None
|
||||
if rule.verified_at is not None:
|
||||
stamp = rule.verified_at
|
||||
if stamp.tzinfo is None:
|
||||
stamp = stamp.replace(tzinfo=timezone.utc)
|
||||
days = (datetime.now(timezone.utc) - stamp).days
|
||||
return {
|
||||
"id": rule.id,
|
||||
"title": rule.title,
|
||||
"statement": rule.statement,
|
||||
"tier": rule.tier,
|
||||
"topic_id": rule.topic_id,
|
||||
"project_id": rule.project_id,
|
||||
"when_to_apply": rule.when_to_apply or "",
|
||||
"verify_with": rule.verify_with or "",
|
||||
"expires_when": rule.expires_when or "",
|
||||
"last_verified": last_verified_label(rule),
|
||||
"days_since_verified": days,
|
||||
}
|
||||
|
||||
|
||||
async def mark_rule_verified(
|
||||
rule_id: int, user_id: int, still_true: bool = True,
|
||||
) -> Optional[Rule]:
|
||||
"""Stamp a rule as verified — or, when the check FAILED, refuse to.
|
||||
|
||||
A failing check is the outcome worth having, and the asymmetry is
|
||||
deliberate: passing writes a stamp, failing writes nothing. There is no
|
||||
"verified false" state to record, because a rule whose check failed is
|
||||
not a rule in a special condition — it is a rule that is WRONG, and the
|
||||
only honest resolutions are to correct it, retire it, or find out why.
|
||||
Recording the failure as a flag would let it sit there being false with
|
||||
the sweep quietly satisfied that someone had looked.
|
||||
|
||||
So a failed check leaves `verified_at` untouched, and the rule stays at
|
||||
the top of the sweep until someone actually deals with it.
|
||||
|
||||
Returns None when the rule is not found, not owned, or carries no
|
||||
`verify_with` — nothing to verify is a different answer from verified.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
async with async_session() as session:
|
||||
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
||||
if rule is None or not rule.verify_with:
|
||||
return None
|
||||
if still_true:
|
||||
rule.verified_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(rule)
|
||||
return rule
|
||||
|
||||
@@ -159,6 +159,11 @@ def fake_rule(**attrs) -> MagicMock:
|
||||
# `when_to_apply` and `arose_from_id` would be truthy MagicMocks and
|
||||
# rule_brief would attach both keys on every stand-in.
|
||||
"when_to_apply": None, "tier": "always_on", "arose_from_id": None,
|
||||
# Same reason, and the same trap one field further on: an unnamed
|
||||
# `verify_with` is a truthy MagicMock, so every stand-in rule would
|
||||
# claim to carry a check and rule_brief would stamp a MagicMock date
|
||||
# onto all of them. Most rules have none — that is the default here.
|
||||
"verify_with": None, "expires_when": None, "verified_at": None,
|
||||
"order_index": 0, "created_at": _now(), "updated_at": _now(),
|
||||
}, attrs)
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Real-Postgres tests for a rule's CHECK — the write half (milestone 312).
|
||||
|
||||
What mocks cannot prove, and what the staleness sweep depends on:
|
||||
|
||||
1. **Empty means NULL.** The sweep asks for rules where `verify_with` is set.
|
||||
A write that stored "" would leave a column that is neither null nor
|
||||
content, and every rule ever touched through the REST door would answer
|
||||
"yes, I have a check" — the sweep would list the whole rulebook and mean
|
||||
nothing. Only a real column can show the difference; `to_dict`'s `or ""`
|
||||
renders both the same.
|
||||
|
||||
2. **Clearing is possible at all.** "" means "leave unchanged" at the MCP
|
||||
door, so without an explicit clear there is no way to retire a check.
|
||||
|
||||
3. **A stamp does not outlive the check it certifies.** Reword the check and
|
||||
the old `verified_at` vouches for something that no longer exists.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.rulebook import Rule
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def constraint():
|
||||
"""One rule carrying a check, already verified.
|
||||
|
||||
Verified at creation time rather than left null, because every assertion
|
||||
here is about what happens to an EXISTING stamp — a fixture that started
|
||||
null could pass all of them by doing nothing.
|
||||
"""
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, "verification_owner")
|
||||
uid = owner.id
|
||||
await s.commit()
|
||||
|
||||
book = await rulebooks_svc.create_rulebook(uid, "Environment facts")
|
||||
topic = await rulebooks_svc.create_topic(book.id, uid, "ci")
|
||||
rule = await rulebooks_svc.create_rule(
|
||||
topic.id, uid, "The runner has no bash",
|
||||
"Write every `run:` step in POSIX sh.",
|
||||
verify_with="read the workflow's shell setting",
|
||||
expires_when="the runner can be given a bash shell",
|
||||
)
|
||||
async with async_session() as s:
|
||||
row = await s.get(Rule, rule.id)
|
||||
row.verified_at = datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||
await s.commit()
|
||||
return {"uid": uid, "rule": rule.id}
|
||||
|
||||
|
||||
async def _row(rule_id: int) -> Rule:
|
||||
async with async_session() as s:
|
||||
return await s.get(Rule, rule_id)
|
||||
|
||||
|
||||
async def test_the_check_and_its_expiry_persist(constraint):
|
||||
row = await _row(constraint["rule"])
|
||||
assert row.verify_with == "read the workflow's shell setting"
|
||||
assert row.expires_when == "the runner can be given a bash shell"
|
||||
assert row.verified_at is not None
|
||||
|
||||
|
||||
async def test_an_empty_string_becomes_null_not_an_empty_column(constraint):
|
||||
"""The REST door's idiom: a cleared form input arrives as "".
|
||||
|
||||
NULL is asserted directly against the column rather than through to_dict,
|
||||
which renders `None` and `""` identically — the difference this test
|
||||
exists for would be invisible one layer up.
|
||||
"""
|
||||
await rulebooks_svc.update_rule(
|
||||
constraint["rule"], constraint["uid"], verify_with="", expires_when="",
|
||||
)
|
||||
row = await _row(constraint["rule"])
|
||||
assert row.verify_with is None
|
||||
assert row.expires_when is None
|
||||
|
||||
|
||||
async def test_naming_a_field_in_clear_empties_it(constraint):
|
||||
"""The MCP door's idiom, where "" already means "leave this alone"."""
|
||||
await rulebooks_svc.update_rule(
|
||||
constraint["rule"], constraint["uid"], clear=["verify_with"],
|
||||
)
|
||||
row = await _row(constraint["rule"])
|
||||
assert row.verify_with is None
|
||||
# expires_when was NOT named, so it survives — clearing is per-field, and
|
||||
# a caller retiring one field must not lose the others.
|
||||
assert row.expires_when == "the runner can be given a bash shell"
|
||||
|
||||
|
||||
async def test_rewording_the_check_drops_the_stamp(constraint):
|
||||
"""A stamp certifies a check, not a rule.
|
||||
|
||||
The safe direction, for the same reason _valid_tier falls back to
|
||||
always_on: a rule wrongly listed as due costs one look, a rule wrongly
|
||||
vouched for costs exactly what the sweep exists to catch.
|
||||
"""
|
||||
await rulebooks_svc.update_rule(
|
||||
constraint["rule"], constraint["uid"],
|
||||
verify_with="read the runner's container shell, not the image's",
|
||||
)
|
||||
row = await _row(constraint["rule"])
|
||||
assert row.verified_at is None
|
||||
|
||||
|
||||
async def test_clearing_the_check_drops_the_stamp(constraint):
|
||||
await rulebooks_svc.update_rule(
|
||||
constraint["rule"], constraint["uid"], clear=["verify_with"],
|
||||
)
|
||||
row = await _row(constraint["rule"])
|
||||
assert row.verified_at is None
|
||||
|
||||
|
||||
async def test_editing_anything_else_leaves_the_stamp_alone(constraint):
|
||||
"""The other half of the rule above, and the one that keeps it useful.
|
||||
|
||||
If any edit reset the stamp, a rulebook tidy-up would put every constraint
|
||||
back at the top of the sweep and the ordering would carry no information.
|
||||
Only the check's own text invalidates its verification.
|
||||
"""
|
||||
await rulebooks_svc.update_rule(
|
||||
constraint["rule"], constraint["uid"],
|
||||
why="act_runner picks the shell, and the image's SHELL directive "
|
||||
"applies to the build, not to `run:`.",
|
||||
expires_when="the runner grows a shell setting",
|
||||
)
|
||||
row = await _row(constraint["rule"])
|
||||
assert row.verified_at is not None
|
||||
assert row.why.startswith("act_runner picks the shell")
|
||||
|
||||
|
||||
# ── the sweep itself (step 3) ──────────────────────────────────────────
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def rulebook_of_three():
|
||||
"""A decision, a never-checked constraint, and a long-ago-checked one.
|
||||
|
||||
Three rows because the sweep's whole value is an ORDER, and an order
|
||||
cannot be asserted with fewer.
|
||||
"""
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, "sweep_owner")
|
||||
uid = owner.id
|
||||
await s.commit()
|
||||
|
||||
book = await rulebooks_svc.create_rulebook(uid, "Sweep fixture")
|
||||
topic = await rulebooks_svc.create_topic(book.id, uid, "mixed")
|
||||
decision = await rulebooks_svc.create_rule(
|
||||
topic.id, uid, "dev is home", "Work directly on dev.",
|
||||
)
|
||||
never = await rulebooks_svc.create_rule(
|
||||
topic.id, uid, "The runner has no bash", "Use POSIX sh.",
|
||||
verify_with="read the workflow's shell setting",
|
||||
)
|
||||
stale = await rulebooks_svc.create_rule(
|
||||
topic.id, uid, "Bumps need a dashboard tick", "Tick it first.",
|
||||
verify_with="cat CI-runner/renovate/config.js",
|
||||
tier="conditional",
|
||||
)
|
||||
async with async_session() as s:
|
||||
row = await s.get(Rule, stale.id)
|
||||
row.verified_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
await s.commit()
|
||||
return {
|
||||
"uid": uid, "decision": decision.id,
|
||||
"never": never.id, "stale": stale.id,
|
||||
}
|
||||
|
||||
|
||||
async def test_a_rule_with_no_check_is_never_in_the_sweep(rulebook_of_three):
|
||||
"""The common case, and the one that keeps the list worth reading.
|
||||
|
||||
Most rules are decisions. If they appeared here the sweep would be the
|
||||
rulebook, and nobody would read it twice.
|
||||
"""
|
||||
rules = await rulebooks_svc.rules_due_for_verification(rulebook_of_three["uid"])
|
||||
assert rulebook_of_three["decision"] not in [r.id for r in rules]
|
||||
|
||||
|
||||
async def test_never_checked_outranks_checked_long_ago(rulebook_of_three):
|
||||
"""NULLS FIRST is the ordering decision this surface turns on.
|
||||
|
||||
Postgres sorts NULLs LAST by default on an ASC ordering, which would put
|
||||
the rules nobody has ever confirmed at the BOTTOM — behind every rule
|
||||
that at least once had someone look at it. That is exactly backwards: a
|
||||
claim with no evidence at all outranks an old one.
|
||||
"""
|
||||
ids = [r.id for r in await rulebooks_svc.rules_due_for_verification(
|
||||
rulebook_of_three["uid"]
|
||||
)]
|
||||
assert ids.index(rulebook_of_three["never"]) < ids.index(rulebook_of_three["stale"])
|
||||
|
||||
|
||||
async def test_verifying_a_rule_moves_it_off_the_top(rulebook_of_three):
|
||||
"""The loop closing: check it, stamp it, and it stops being the question."""
|
||||
await rulebooks_svc.mark_rule_verified(
|
||||
rulebook_of_three["never"], rulebook_of_three["uid"], still_true=True,
|
||||
)
|
||||
ids = [r.id for r in await rulebooks_svc.rules_due_for_verification(
|
||||
rulebook_of_three["uid"]
|
||||
)]
|
||||
# Still present — verified is not retired, and it will come due again.
|
||||
assert rulebook_of_three["never"] in ids
|
||||
assert ids.index(rulebook_of_three["stale"]) < ids.index(rulebook_of_three["never"])
|
||||
|
||||
|
||||
async def test_a_failed_check_writes_nothing(rulebook_of_three):
|
||||
"""The asymmetry that keeps the sweep honest.
|
||||
|
||||
There is no "verified false" state, because a rule whose check failed is
|
||||
not in a special condition — it is WRONG. Recording the failure would let
|
||||
it sit there being false with the sweep satisfied that someone looked.
|
||||
"""
|
||||
before = await _row(rulebook_of_three["stale"])
|
||||
await rulebooks_svc.mark_rule_verified(
|
||||
rulebook_of_three["stale"], rulebook_of_three["uid"], still_true=False,
|
||||
)
|
||||
after = await _row(rulebook_of_three["stale"])
|
||||
assert after.verified_at == before.verified_at
|
||||
|
||||
|
||||
async def test_a_rule_with_no_check_cannot_be_verified(rulebook_of_three):
|
||||
"""Nothing to verify is a different answer from verified — and stamping
|
||||
one would put a decision into a sweep it has no business being in."""
|
||||
assert await rulebooks_svc.mark_rule_verified(
|
||||
rulebook_of_three["decision"], rulebook_of_three["uid"],
|
||||
) is None
|
||||
|
||||
|
||||
async def test_never_only_and_the_age_filter_narrow_to_what_they_say(rulebook_of_three):
|
||||
uid = rulebook_of_three["uid"]
|
||||
# Membership, not equality: the integration lane shares one database for
|
||||
# the whole run and this fixture is function-scoped, so this owner has
|
||||
# accumulated rules from earlier tests. Asserting the exact list would
|
||||
# pass alone and fail in the suite.
|
||||
never_ids = [r.id for r in await rulebooks_svc.rules_due_for_verification(
|
||||
uid, never_only=True,
|
||||
)]
|
||||
assert rulebook_of_three["never"] in never_ids
|
||||
assert rulebook_of_three["stale"] not in never_ids
|
||||
assert rulebook_of_three["decision"] not in never_ids
|
||||
|
||||
# A rule checked in January is well past any sane window; one never
|
||||
# checked always qualifies, because it is the most overdue thing there is.
|
||||
aged = [r.id for r in await rulebooks_svc.rules_due_for_verification(
|
||||
uid, older_than_days=30,
|
||||
)]
|
||||
assert rulebook_of_three["stale"] in aged
|
||||
assert rulebook_of_three["never"] in aged
|
||||
|
||||
|
||||
async def test_the_tier_filter_narrows_to_one_tier(rulebook_of_three):
|
||||
ids = [r.id for r in await rulebooks_svc.rules_due_for_verification(
|
||||
rulebook_of_three["uid"], tier="conditional",
|
||||
)]
|
||||
assert rulebook_of_three["stale"] in ids
|
||||
assert rulebook_of_three["never"] not in ids
|
||||
|
||||
|
||||
async def test_another_users_rules_are_not_in_your_sweep(rulebook_of_three):
|
||||
"""Rules are ownership-scoped: there is no rule-sharing ACL in this
|
||||
schema, so the only correct answer is your own rules."""
|
||||
async with async_session() as s:
|
||||
stranger = await ensure_user(s, "sweep_stranger")
|
||||
sid = stranger.id
|
||||
await s.commit()
|
||||
|
||||
assert await rulebooks_svc.rules_due_for_verification(sid) == []
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Real-Postgres test that the CHECK actually accepts 'spike' (0091).
|
||||
|
||||
Rule 36 exists because the value and the constraint can drift apart: the
|
||||
code starts writing a new kind while the database still refuses it, and
|
||||
nothing catches it until a write fails in front of someone. A mock cannot
|
||||
show that — it has no CHECK — so the constraint gets its own real-DB test,
|
||||
the same way migration 0090's nullability did.
|
||||
|
||||
The negative half matters as much as the positive one. A test that only
|
||||
proves 'spike' is accepted would also pass against a table with NO
|
||||
constraint at all, which is the other way this goes wrong.
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def owner_id():
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, "spike_owner")
|
||||
uid = owner.id
|
||||
await s.commit()
|
||||
return uid
|
||||
|
||||
|
||||
async def _write(uid: int, kind: str) -> int:
|
||||
async with async_session() as s:
|
||||
# No is_task=: it is a derived read-only property (a note IS a task
|
||||
# when status is not None), so passing it raises rather than being
|
||||
# ignored. status="todo" is what makes this a task.
|
||||
note = Note(
|
||||
user_id=uid, title=f"kind {kind}", body="",
|
||||
status="todo", task_kind=kind,
|
||||
)
|
||||
s.add(note)
|
||||
await s.commit()
|
||||
return note.id
|
||||
|
||||
|
||||
async def test_a_spike_can_be_written(owner_id):
|
||||
note_id = await _write(owner_id, "spike")
|
||||
async with async_session() as s:
|
||||
assert (await s.get(Note, note_id)).task_kind == "spike"
|
||||
|
||||
|
||||
async def test_the_older_kinds_still_write(owner_id):
|
||||
"""0091 widens the whitelist; it must not narrow it by accident.
|
||||
|
||||
'plan' is retired — plans are milestones since 0066 — but historical
|
||||
plan-tasks still carry it, and a row that cannot be rewritten is a row
|
||||
that cannot be edited, restored, or migrated.
|
||||
"""
|
||||
for kind in ("work", "issue", "plan"):
|
||||
note_id = await _write(owner_id, kind)
|
||||
async with async_session() as s:
|
||||
assert (await s.get(Note, note_id)).task_kind == kind
|
||||
|
||||
|
||||
async def test_an_unknown_kind_is_still_refused(owner_id):
|
||||
"""The half that proves a constraint is there at all.
|
||||
|
||||
Without this, every assertion above would pass just as happily against a
|
||||
table whose CHECK had been dropped and never re-added — which is exactly
|
||||
the failure rule 36 is written against.
|
||||
"""
|
||||
with pytest.raises(IntegrityError):
|
||||
await _write(owner_id, "investigation")
|
||||
@@ -115,7 +115,46 @@ async def test_update_rule_only_sends_non_default_fields():
|
||||
await update_rule(rule_id=1, statement="new statement")
|
||||
args, kwargs = mock.call_args
|
||||
assert args == (1, 7)
|
||||
assert kwargs == {"statement": "new statement"}
|
||||
# `clear` is always forwarded — an empty tuple is "clear nothing", which is
|
||||
# a value, not an absent argument. Everything the caller left at its
|
||||
# default stays out: that is the property this test pins.
|
||||
assert kwargs == {"statement": "new statement", "clear": ()}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rule_forwards_the_fields_named_for_clearing():
|
||||
"""Naming a field is the only way to empty it through this door.
|
||||
|
||||
"" means "leave unchanged" here, so a caller has no value that means
|
||||
"remove it" — which is what makes an explicit list necessary and what
|
||||
stops a partial update from wiping the fields it did not mention.
|
||||
"""
|
||||
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
|
||||
mock = AsyncMock(return_value=rule)
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail():
|
||||
from scribe.mcp.tools.rulebooks import update_rule
|
||||
await update_rule(rule_id=1, clear_fields=["verify_with"])
|
||||
_args, kwargs = mock.call_args
|
||||
assert kwargs == {"clear": ["verify_with"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rule_sends_the_check_fields_when_given():
|
||||
rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
|
||||
mock = AsyncMock(return_value=rule)
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail():
|
||||
from scribe.mcp.tools.rulebooks import update_rule
|
||||
await update_rule(
|
||||
rule_id=1,
|
||||
verify_with="cat CI-runner/renovate/config.js",
|
||||
expires_when="approval is turned off",
|
||||
)
|
||||
_args, kwargs = mock.call_args
|
||||
assert kwargs == {
|
||||
"verify_with": "cat CI-runner/renovate/config.js",
|
||||
"expires_when": "approval is turned off",
|
||||
"clear": (),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -178,13 +217,20 @@ async def test_unsubscribe_project_from_rulebook_calls_service():
|
||||
assert mock.called
|
||||
|
||||
|
||||
def test_register_attaches_all_sixteen_tools():
|
||||
"""register(mcp) should call mcp.tool(name=...) for all 16 tools."""
|
||||
def test_register_attaches_every_tool():
|
||||
"""Every tool in the module reaches the server.
|
||||
|
||||
The count is the guard: a function added to the module but left out of
|
||||
register()'s tuple is invisible to callers and raises nothing. The name
|
||||
said "sixteen" for ten tools' worth of growth — the number lives in the
|
||||
assertion, not the title, so it cannot drift again.
|
||||
"""
|
||||
from scribe.mcp.tools.rulebooks import register
|
||||
mcp = FakeMCP()
|
||||
|
||||
register(mcp)
|
||||
assert len(mcp.names) == 26 # +relate_rules/unrelate_rules (milestone 307)
|
||||
# 26 through milestone 307, +2 for the staleness sweep (milestone 312).
|
||||
assert len(mcp.names) == 28
|
||||
# spot-check a few names
|
||||
assert "list_rulebooks" in mcp.names
|
||||
assert "create_rule" in mcp.names
|
||||
@@ -195,6 +241,9 @@ def test_register_attaches_all_sixteen_tools():
|
||||
assert "include_always_on_rulebook" in mcp.names
|
||||
assert "create_project_rule" in mcp.names
|
||||
assert "suppress_rule_for_project" in mcp.names
|
||||
# milestone 312: the sweep, and the stamp that answers it
|
||||
assert "rules_due_for_verification" in mcp.names
|
||||
assert "mark_rule_verified" in mcp.names
|
||||
assert "unsuppress_rule_for_project" in mcp.names
|
||||
assert "suppress_topic_for_project" in mcp.names
|
||||
assert "unsuppress_topic_for_project" in mcp.names
|
||||
|
||||
@@ -33,3 +33,17 @@ async def test_list_tasks_kind_empty_means_no_filter():
|
||||
from scribe.mcp.tools.tasks import list_tasks
|
||||
await list_tasks()
|
||||
assert mock.call_args.kwargs["task_kind"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_passes_spike():
|
||||
"""The kind a failed rule-check asks for (milestone 312).
|
||||
|
||||
Time-boxed, and its output is knowledge rather than a change — filing one
|
||||
as `work` makes a finished investigation look like an abandoned change.
|
||||
"""
|
||||
mock = AsyncMock(return_value=fake_note(task_kind="spike"))
|
||||
with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock):
|
||||
from scribe.mcp.tools.tasks import create_task
|
||||
await create_task(title="Can the runner be given a bash shell?", kind="spike")
|
||||
assert mock.call_args.kwargs["task_kind"] == "spike"
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Unit tests for the v4 backup export contract.
|
||||
"""Unit tests for the backup export contract.
|
||||
|
||||
CI runs pytest with no database, so these cover the parts that don't need one:
|
||||
the version/coverage constants, the pure join-table row helpers, and the export
|
||||
dict shape (via a mocked session). Full FK-remapping round-trip is exercised
|
||||
manually against a real DB (export a backup, confirm rulebooks appear).
|
||||
This is the no-database lane, so these cover the parts that need none: the
|
||||
version/coverage constants, the pure row helpers, and the export dict shape
|
||||
(via a mocked session). The full FK-remapping round-trip needs real Postgres
|
||||
and belongs in a `@pytest.mark.integration` module — it is not written yet,
|
||||
which is why every row helper here is a plain function that can be tested
|
||||
without a session.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -132,3 +135,58 @@ def test_supersession_rows_serialise_the_pair():
|
||||
{"superseder_id": 9, "superseded_id": 4},
|
||||
{"superseder_id": 9, "superseded_id": 5},
|
||||
]
|
||||
|
||||
|
||||
def test_rule_rows_carry_the_verification_fields():
|
||||
"""A rule's check must survive a backup.
|
||||
|
||||
`verify_with`/`expires_when`/`verified_at` (milestone 312) say whether a
|
||||
rule is a fact that can go false and when it was last confirmed. A backup
|
||||
that drops them restores a rulebook that has forgotten which of its rules
|
||||
can rot — the exact blindness the fields were added to end.
|
||||
|
||||
Column additions do not bump BACKUP_VERSION; only new SECTIONS do. Same
|
||||
call made for when_to_apply/tier/arose_from_id in 0088 (commit 6ddb8bf).
|
||||
"""
|
||||
checked = datetime(2026, 8, 27, 12, 0, tzinfo=timezone.utc)
|
||||
row = SimpleNamespace(
|
||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||
why="w", how_to_apply="h", order_index=0,
|
||||
when_to_apply="when", tier="conditional",
|
||||
verify_with="cat some/file", expires_when="the file grows a shell",
|
||||
verified_at=checked, arose_from_id=99,
|
||||
created_at=checked, updated_at=checked,
|
||||
)
|
||||
out = backup._rule_rows([row])[0]
|
||||
|
||||
assert out["verify_with"] == "cat some/file"
|
||||
assert out["expires_when"] == "the file grows a shell"
|
||||
assert out["verified_at"] == checked.isoformat()
|
||||
# Provenance was exported from 0088 onward but silently dropped on the way
|
||||
# back IN until milestone 312. Export side asserted here; the restore side
|
||||
# remaps it through note_id_map.
|
||||
assert out["arose_from_id"] == 99
|
||||
|
||||
|
||||
def test_rule_rows_keep_an_unverified_rule_unverified():
|
||||
"""NULL verified_at means never checked, and it must round-trip as null.
|
||||
|
||||
_dt substitutes now() so created_at/updated_at are never null. Reusing it
|
||||
here would restore a rule nobody ever checked as though it had just been
|
||||
checked — dropping it to the BOTTOM of the sweep it should top. That is
|
||||
why _dt_or_none exists.
|
||||
"""
|
||||
row = SimpleNamespace(
|
||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||
why=None, how_to_apply=None, order_index=0,
|
||||
when_to_apply=None, tier="always_on",
|
||||
verify_with=None, expires_when=None, verified_at=None,
|
||||
arose_from_id=None,
|
||||
created_at=datetime(2026, 8, 27, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2026, 8, 27, tzinfo=timezone.utc),
|
||||
)
|
||||
assert backup._rule_rows([row])[0]["verified_at"] is None
|
||||
assert backup._dt_or_none(None) is None
|
||||
assert backup._dt_or_none("2026-08-27T12:00:00+00:00") == datetime(
|
||||
2026, 8, 27, 12, 0, tzinfo=timezone.utc
|
||||
)
|
||||
|
||||
@@ -392,3 +392,116 @@ def test_an_unknown_tier_falls_back_to_binding():
|
||||
assert _valid_tier("Conditional") == "always_on"
|
||||
assert _valid_tier("") == "always_on"
|
||||
assert _valid_tier("occasionally") == "always_on"
|
||||
|
||||
|
||||
# ── verify_with / expires_when (milestone 312) ──────────────────────────
|
||||
|
||||
def test_a_rule_with_no_check_says_nothing_about_verification():
|
||||
"""The empty case is the COMMON case, and it must stay silent.
|
||||
|
||||
Most rules are decisions: they have no truth value and there is nothing to
|
||||
go and check. If a brief carried `last_verified` for those too, the signal
|
||||
would be worthless — every rule would look like something someone ought to
|
||||
be verifying, and the handful that genuinely rot would stop standing out.
|
||||
"""
|
||||
from scribe.services.rulebooks import last_verified_label, rule_brief
|
||||
|
||||
rule = fake_rule()
|
||||
assert last_verified_label(rule) is None
|
||||
assert "last_verified" not in rule_brief(rule)
|
||||
|
||||
|
||||
def test_an_unverified_constraint_reads_never_rather_than_null():
|
||||
"""#2483 again: a null key reads as a capability going unused. "never" is
|
||||
a different and much stronger claim — this rule asserts a fact about
|
||||
someone else's software and nobody has ever confirmed it."""
|
||||
from scribe.services.rulebooks import last_verified_label, rule_brief
|
||||
|
||||
rule = fake_rule(verify_with="cat CI-runner/renovate/config.js")
|
||||
assert last_verified_label(rule) == "never"
|
||||
assert rule_brief(rule)["last_verified"] == "never"
|
||||
|
||||
|
||||
def test_a_verified_constraint_reports_the_date_it_was_checked():
|
||||
"""A date, not a stamp — the question is "how old is this", the same call
|
||||
rule_brief makes for updated_at."""
|
||||
from scribe.services.rulebooks import last_verified_label
|
||||
|
||||
rule = fake_rule(
|
||||
verify_with="cat CI-runner/renovate/config.js",
|
||||
verified_at=datetime(2026, 8, 27, 11, 46, tzinfo=timezone.utc),
|
||||
)
|
||||
assert last_verified_label(rule) == "2026-08-27"
|
||||
|
||||
|
||||
def test_the_check_text_itself_never_enters_a_listing():
|
||||
"""A listing says WHICH rules can rot, not how to test them. The check can
|
||||
be a long command; multiplied across an always-on set it is the same bloat
|
||||
`why` and `how_to_apply` are kept out of a brief to avoid."""
|
||||
from scribe.services.rulebooks import rule_brief
|
||||
|
||||
out = rule_brief(fake_rule(
|
||||
verify_with="a very long command " * 20,
|
||||
expires_when="the runner learns a new shell",
|
||||
))
|
||||
assert "verify_with" not in out
|
||||
assert "expires_when" not in out
|
||||
|
||||
|
||||
# ── the sweep's row shape (milestone 312 step 3) ────────────────────────
|
||||
|
||||
def test_a_sweep_row_carries_the_check_in_full():
|
||||
"""The OPPOSITE call from rule_brief, and deliberately so.
|
||||
|
||||
A listing omits the depth because nobody reading it wants to act on one
|
||||
rule. A sweep row exists to be acted on — the reader is about to go and
|
||||
run the check — so the text is the payload's point, not its bloat.
|
||||
"""
|
||||
from scribe.services.rulebooks import verification_row
|
||||
|
||||
row = verification_row(fake_rule(
|
||||
verify_with="cat CI-runner/renovate/config.js",
|
||||
expires_when="dependencyDashboardApproval is turned off",
|
||||
when_to_apply="when a dependency bump is in play",
|
||||
))
|
||||
assert row["verify_with"] == "cat CI-runner/renovate/config.js"
|
||||
assert row["expires_when"] == "dependencyDashboardApproval is turned off"
|
||||
assert row["when_to_apply"] == "when a dependency bump is in play"
|
||||
assert row["tier"] == "always_on"
|
||||
|
||||
|
||||
def test_never_verified_reports_no_day_count_rather_than_zero():
|
||||
""""Never" is not "0 days ago" — the second reads as freshly checked.
|
||||
|
||||
Getting this wrong would invert the row's meaning for exactly the rules
|
||||
that most need attention.
|
||||
"""
|
||||
from scribe.services.rulebooks import verification_row
|
||||
|
||||
row = verification_row(fake_rule(verify_with="read the workflow"))
|
||||
assert row["last_verified"] == "never"
|
||||
assert row["days_since_verified"] is None
|
||||
|
||||
|
||||
def test_a_verified_row_counts_the_days():
|
||||
from datetime import timedelta
|
||||
|
||||
from scribe.services.rulebooks import verification_row
|
||||
|
||||
row = verification_row(fake_rule(
|
||||
verify_with="read the workflow",
|
||||
verified_at=datetime.now(timezone.utc) - timedelta(days=74, hours=1),
|
||||
))
|
||||
assert row["days_since_verified"] == 74
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unrecognised_tier_filter_raises_rather_than_narrowing():
|
||||
"""_valid_tier's silent always_on fallback is right for a WRITE — a typo
|
||||
should leave a rule binding. It is wrong for a FILTER, where the same
|
||||
fallback would quietly answer a different question than the one asked and
|
||||
return a short list that looks like good news."""
|
||||
from scribe.services.rulebooks import rules_due_for_verification
|
||||
|
||||
with pytest.raises(ValueError, match="tier must be one of"):
|
||||
await rules_due_for_verification(7, tier="occasionally")
|
||||
|
||||
Reference in New Issue
Block a user