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;
|
tier: RuleTier;
|
||||||
why: string;
|
why: string;
|
||||||
how_to_apply: 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. */
|
/** The note or task that caused this rule, if one was recorded. */
|
||||||
arose_from_id: number | null;
|
arose_from_id: number | null;
|
||||||
order_index: number;
|
order_index: number;
|
||||||
@@ -80,6 +90,12 @@ export interface RuleHeader {
|
|||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
when_to_apply?: string;
|
when_to_apply?: string;
|
||||||
arose_from_id?: number;
|
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 {
|
export interface ApplicableRules {
|
||||||
@@ -170,7 +186,14 @@ export async function getRule(id: number): Promise<Rule> {
|
|||||||
return apiGet(`/api/rules/${id}`);
|
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 {
|
export interface RuleWrite {
|
||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
@@ -181,6 +204,8 @@ export interface RuleWrite {
|
|||||||
order_index: number;
|
order_index: number;
|
||||||
system_ids: number[];
|
system_ids: number[];
|
||||||
arose_from_id: number | null;
|
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> {
|
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}`);
|
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,
|
/* Shared by the rules panes (RulebookListPane, RuleListPane,
|
||||||
RulebookDetailPane): the pane surface and its heading. Load with
|
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
|
<style src="@/assets/rules-shared.css" /> beside the component's own
|
||||||
scoped block; never restate these there (#2903, milestone 299). */
|
scoped block; never restate these there (#2903, milestone 299). */
|
||||||
.pane {
|
.pane {
|
||||||
@@ -13,3 +15,19 @@
|
|||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
}
|
}
|
||||||
.form-buttons { display: flex; gap: 0.5rem; }
|
.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 showPicker = ref(false);
|
||||||
const expandedRuleIds = ref<Set<number>>(new Set());
|
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 showProjectRuleForm = ref(false);
|
||||||
const newProjectRule = ref({
|
const newProjectRule = ref({
|
||||||
@@ -67,6 +70,9 @@ async function toggleRuleExpand(ruleId: number) {
|
|||||||
ruleDetails.value[ruleId] = {
|
ruleDetails.value[ruleId] = {
|
||||||
why: rule.why || "",
|
why: rule.why || "",
|
||||||
how_to_apply: rule.how_to_apply || "",
|
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);
|
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) {
|
function openInRulesView(rulebookId: number, ruleId?: number) {
|
||||||
const query: Record<string, string> = { rb: String(rulebookId) };
|
const query: Record<string, string> = { rb: String(rulebookId) };
|
||||||
if (ruleId) query.rule = String(ruleId);
|
if (ruleId) query.rule = String(ruleId);
|
||||||
@@ -279,6 +290,16 @@ watch(() => props.projectId, load);
|
|||||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||||
</div>
|
</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>
|
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -332,6 +353,13 @@ watch(() => props.projectId, load);
|
|||||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||||
</div>
|
</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
|
<button
|
||||||
class="edit-link"
|
class="edit-link"
|
||||||
@click="openInRulesView(r.rulebook_id, r.id)"
|
@click="openInRulesView(r.rulebook_id, r.id)"
|
||||||
@@ -428,6 +456,11 @@ ul { list-style: none; padding: 0; margin: 0; }
|
|||||||
}
|
}
|
||||||
.rule-head { cursor: pointer; }
|
.rule-head { cursor: pointer; }
|
||||||
.rule-title { font-weight: 500; }
|
.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-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
||||||
.rule-detail {
|
.rule-detail {
|
||||||
margin-top: 0.5rem; padding: 0.5rem;
|
margin-top: 0.5rem; padding: 0.5rem;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const tier = ref<RuleTier>("always_on");
|
|||||||
const systemIds = ref<number[]>([]);
|
const systemIds = ref<number[]>([]);
|
||||||
const why = ref("");
|
const why = ref("");
|
||||||
const howToApply = ref("");
|
const howToApply = ref("");
|
||||||
|
const verifyWith = ref("");
|
||||||
|
const expiresWhen = ref("");
|
||||||
|
|
||||||
const relations = computed(() => store.currentRule?.relations ?? []);
|
const relations = computed(() => store.currentRule?.relations ?? []);
|
||||||
|
|
||||||
@@ -38,6 +40,27 @@ function toggleSystem(id: number) {
|
|||||||
|
|
||||||
const isCreating = ref(props.ruleId === null);
|
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() {
|
async function load() {
|
||||||
if (props.ruleId !== null) {
|
if (props.ruleId !== null) {
|
||||||
await store.fetchRule(props.ruleId);
|
await store.fetchRule(props.ruleId);
|
||||||
@@ -50,6 +73,8 @@ async function load() {
|
|||||||
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
||||||
why.value = r.why || "";
|
why.value = r.why || "";
|
||||||
howToApply.value = r.how_to_apply || "";
|
howToApply.value = r.how_to_apply || "";
|
||||||
|
verifyWith.value = r.verify_with || "";
|
||||||
|
expiresWhen.value = r.expires_when || "";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
title.value = "";
|
title.value = "";
|
||||||
@@ -59,6 +84,8 @@ async function load() {
|
|||||||
systemIds.value = [];
|
systemIds.value = [];
|
||||||
why.value = "";
|
why.value = "";
|
||||||
howToApply.value = "";
|
howToApply.value = "";
|
||||||
|
verifyWith.value = "";
|
||||||
|
expiresWhen.value = "";
|
||||||
}
|
}
|
||||||
await canon.fetchCatalog();
|
await canon.fetchCatalog();
|
||||||
}
|
}
|
||||||
@@ -78,6 +105,11 @@ async function save() {
|
|||||||
system_ids: systemIds.value,
|
system_ids: systemIds.value,
|
||||||
why: why.value,
|
why: why.value,
|
||||||
how_to_apply: howToApply.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) {
|
if (isCreating.value && props.topicId !== null) {
|
||||||
await store.createRule(props.topicId, fields);
|
await store.createRule(props.topicId, fields);
|
||||||
@@ -162,6 +194,45 @@ watch(() => props.ruleId, load);
|
|||||||
</p>
|
</p>
|
||||||
</fieldset>
|
</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">
|
<section v-if="relations.length" class="relations">
|
||||||
<h3>Related rules</h3>
|
<h3>Related rules</h3>
|
||||||
<ul>
|
<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-target { color: var(--fs-text-primary); }
|
||||||
.relation-note { width: 100%; font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
.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, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
||||||
.trash:hover, .close:hover { opacity: 1; }
|
.trash:hover, .close:hover { opacity: 1; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,7 +17,17 @@ const emit = defineEmits<{
|
|||||||
{{ r.title }}
|
{{ r.title }}
|
||||||
<!-- Only conditional is marked: always-on is the default and
|
<!-- Only conditional is marked: always-on is the default and
|
||||||
badging every row would say nothing. -->
|
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>
|
||||||
<div class="statement">{{ r.statement }}</div>
|
<div class="statement">{{ r.statement }}</div>
|
||||||
<div v-if="r.when_to_apply || r.updated_at" class="meta">
|
<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; }
|
.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; }
|
.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; }
|
.age { color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
||||||
.tier-chip {
|
/* Only the departures from .rule-chip (rules-shared.css) live here. */
|
||||||
margin-left: 0.4rem;
|
.check-chip { font-variant-numeric: tabular-nums; }
|
||||||
font-family: var(--fs-font-body);
|
/* No age-graded colour on purpose. The sweep is already ordered by urgency, so
|
||||||
font-style: normal;
|
a red/amber ramp would restate the ordering AND require an invented "stale
|
||||||
font-size: 0.62rem;
|
after N days" threshold — a magic number nobody could defend and the first
|
||||||
color: var(--fs-text-secondary);
|
thing to go out of date. Only "never" is marked, because it is categorically
|
||||||
background: var(--fs-surface-raised);
|
different from a date rather than a worse one. */
|
||||||
border-radius: var(--fs-radius-pill);
|
.check-chip.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
|
||||||
padding: 0.05rem 0.4rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
.new-rule { cursor: pointer; }
|
.new-rule { cursor: pointer; }
|
||||||
</style>
|
</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 { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
import type { Rulebook } from "@/api/rulebooks";
|
import type { Rulebook } from "@/api/rulebooks";
|
||||||
|
|
||||||
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null }>();
|
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null; sweepActive: boolean }>();
|
||||||
const emit = defineEmits<{ select: [id: number] }>();
|
const emit = defineEmits<{ select: [id: number]; "select-sweep": [] }>();
|
||||||
|
|
||||||
const store = useRulebooksStore();
|
const store = useRulebooksStore();
|
||||||
const isCreating = ref(false);
|
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>
|
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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">
|
<div class="new-rulebook">
|
||||||
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
|
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
|
||||||
<form v-else @submit.prevent="submitNew">
|
<form v-else @submit.prevent="submitNew">
|
||||||
@@ -63,6 +75,15 @@ li:hover { background: var(--fs-surface-hover); }
|
|||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
margin-left: auto;
|
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 { margin-top: 1rem; }
|
||||||
.new-rulebook input {
|
.new-rulebook input {
|
||||||
width: 100%; margin-bottom: 0.5rem;
|
width: 100%; margin-bottom: 0.5rem;
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
const topicsByRulebook = ref<Record<number, RulebookTopic[]>>({});
|
const topicsByRulebook = ref<Record<number, RulebookTopic[]>>({});
|
||||||
const rulesByTopic = ref<Record<number, RuleHeader[]>>({});
|
const rulesByTopic = ref<Record<number, RuleHeader[]>>({});
|
||||||
const currentRule = ref<Rule | null>(null);
|
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);
|
const loading = ref(false);
|
||||||
|
|
||||||
async function fetchRulebooks() {
|
async function fetchRulebooks() {
|
||||||
@@ -111,6 +116,13 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
updated_at: rule.updated_at,
|
updated_at: rule.updated_at,
|
||||||
when_to_apply: rule.when_to_apply || undefined,
|
when_to_apply: rule.when_to_apply || undefined,
|
||||||
arose_from_id: rule.arose_from_id ?? 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);
|
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) {
|
async function deleteRule(id: number) {
|
||||||
await api.deleteRule(id);
|
await api.deleteRule(id);
|
||||||
if (currentRule.value?.id === id) currentRule.value = null;
|
if (currentRule.value?.id === id) currentRule.value = null;
|
||||||
@@ -157,10 +204,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading,
|
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
|
||||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||||
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
||||||
createTopic, updateTopic, deleteTopic,
|
createTopic, updateTopic, deleteTopic,
|
||||||
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
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 TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
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 type NoteType = "note" | "process" | "snippet";
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import RulebookListPane from "@/components/rules/RulebookListPane.vue";
|
|||||||
import RulebookDetailPane from "@/components/rules/RulebookDetailPane.vue";
|
import RulebookDetailPane from "@/components/rules/RulebookDetailPane.vue";
|
||||||
import RuleListPane from "@/components/rules/RuleListPane.vue";
|
import RuleListPane from "@/components/rules/RuleListPane.vue";
|
||||||
import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue";
|
import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue";
|
||||||
|
import RuleSweepPane from "@/components/rules/RuleSweepPane.vue";
|
||||||
|
|
||||||
const store = useRulebooksStore();
|
const store = useRulebooksStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -15,6 +16,7 @@ const selectedRulebookId = ref<number | null>(null);
|
|||||||
const selectedTopicId = ref<number | null>(null);
|
const selectedTopicId = ref<number | null>(null);
|
||||||
const editingRuleId = ref<number | null>(null);
|
const editingRuleId = ref<number | null>(null);
|
||||||
const creatingRuleForTopic = ref<number | null>(null);
|
const creatingRuleForTopic = ref<number | null>(null);
|
||||||
|
const sweepActive = ref(false);
|
||||||
|
|
||||||
function syncFromRoute() {
|
function syncFromRoute() {
|
||||||
const rb = route.query.rb ? Number(route.query.rb) : null;
|
const rb = route.query.rb ? Number(route.query.rb) : null;
|
||||||
@@ -23,9 +25,20 @@ function syncFromRoute() {
|
|||||||
selectedRulebookId.value = rb;
|
selectedRulebookId.value = rb;
|
||||||
selectedTopicId.value = topic;
|
selectedTopicId.value = topic;
|
||||||
editingRuleId.value = rule;
|
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) {
|
function selectRulebook(id: number) {
|
||||||
|
sweepActive.value = false;
|
||||||
selectedRulebookId.value = id;
|
selectedRulebookId.value = id;
|
||||||
selectedTopicId.value = null;
|
selectedTopicId.value = null;
|
||||||
router.replace({ query: { rb: String(id) } });
|
router.replace({ query: { rb: String(id) } });
|
||||||
@@ -70,10 +83,13 @@ watch(() => route.query, syncFromRoute);
|
|||||||
<RulebookListPane
|
<RulebookListPane
|
||||||
:rulebooks="store.rulebooks"
|
:rulebooks="store.rulebooks"
|
||||||
:selected-id="selectedRulebookId"
|
:selected-id="selectedRulebookId"
|
||||||
|
:sweep-active="sweepActive"
|
||||||
@select="selectRulebook"
|
@select="selectRulebook"
|
||||||
|
@select-sweep="selectSweep"
|
||||||
/>
|
/>
|
||||||
|
<RuleSweepPane v-if="sweepActive" class="sweep-span" @open-rule="openRule" />
|
||||||
<RulebookDetailPane
|
<RulebookDetailPane
|
||||||
v-if="selectedRulebookId !== null"
|
v-else-if="selectedRulebookId !== null"
|
||||||
:rulebook-id="selectedRulebookId"
|
:rulebook-id="selectedRulebookId"
|
||||||
:topics="store.topicsByRulebook[selectedRulebookId] || []"
|
:topics="store.topicsByRulebook[selectedRulebookId] || []"
|
||||||
:selected-topic-id="selectedTopicId"
|
:selected-topic-id="selectedTopicId"
|
||||||
@@ -83,13 +99,13 @@ watch(() => route.query, syncFromRoute);
|
|||||||
<p>Select a rulebook to view its topics.</p>
|
<p>Select a rulebook to view its topics.</p>
|
||||||
</div>
|
</div>
|
||||||
<RuleListPane
|
<RuleListPane
|
||||||
v-if="selectedTopicId !== null"
|
v-if="!sweepActive && selectedTopicId !== null"
|
||||||
:topic-id="selectedTopicId"
|
:topic-id="selectedTopicId"
|
||||||
:rules="store.rulesByTopic[selectedTopicId] || []"
|
:rules="store.rulesByTopic[selectedTopicId] || []"
|
||||||
@open-rule="openRule"
|
@open-rule="openRule"
|
||||||
@create-rule="startCreatingRule"
|
@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>
|
<p>Select a topic to view its rules.</p>
|
||||||
</div>
|
</div>
|
||||||
<RuleEditorSlideOver
|
<RuleEditorSlideOver
|
||||||
@@ -109,6 +125,9 @@ watch(() => route.query, syncFromRoute);
|
|||||||
gap: 1px;
|
gap: 1px;
|
||||||
background: var(--fs-border-color);
|
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 {
|
.pane.empty {
|
||||||
background: var(--fs-surface-hover);
|
background: var(--fs-surface-hover);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
@@ -578,6 +578,7 @@ useEditorGuards(dirty, save);
|
|||||||
<select v-model="kind" @change="markDirty" class="sb-select">
|
<select v-model="kind" @change="markDirty" class="sb-select">
|
||||||
<option value="work">Work</option>
|
<option value="work">Work</option>
|
||||||
<option value="issue">Issue</option>
|
<option value="issue">Issue</option>
|
||||||
|
<option value="spike">Spike</option>
|
||||||
<!-- 'plan' is retired (plans are milestones via start_planning);
|
<!-- 'plan' is retired (plans are milestones via start_planning);
|
||||||
offered only so legacy plan-tasks display their kind. -->
|
offered only so legacy plan-tasks display their kind. -->
|
||||||
<option v-if="kind === 'plan'" value="plan">Plan (legacy)</option>
|
<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
|
Pair with get_project(id).applicable_rules when working on a specific
|
||||||
project to also load that project's subscription-derived rules.
|
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:
|
Args:
|
||||||
project_id: 0 (default) = the user-wide set. Inside a project, pass
|
project_id: 0 (default) = the user-wide set. Inside a project, pass
|
||||||
its id: an always-on rulebook the project EXCLUDED at inception
|
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 = "",
|
topic_id: int, title: str, statement: str, when_to_apply: str = "",
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||||
tier: str = "always_on", system_ids: list[int] | None = None,
|
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:
|
) -> dict:
|
||||||
"""Create a new rule in a rulebook (a SHARED rule — keep it general).
|
"""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,
|
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.
|
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
|
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
|
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
|
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
|
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
|
when it matters, and a rule nobody can place is a rule nobody
|
||||||
applies.
|
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".
|
tier: "always_on" (default) or "conditional".
|
||||||
The test: can you name the trigger WITHOUT naming a system, an
|
The test: can you name the trigger WITHOUT naming a system, an
|
||||||
artifact type or a moment? If the honest answer is "whenever you
|
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.
|
cannot be followed and does not survive a rewording.
|
||||||
why: Optional rationale — the reason the rule exists.
|
why: Optional rationale — the reason the rule exists.
|
||||||
how_to_apply: Optional operationalization — when / where it kicks in.
|
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).
|
order_index: Display order within the topic (default 0).
|
||||||
force: Bypass the near-duplicate gate. By default, a title-identical rule
|
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
|
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,
|
title=title, statement=statement, when_to_apply=when_to_apply,
|
||||||
tier=tier, arose_from_id=arose_from_id,
|
tier=tier, arose_from_id=arose_from_id,
|
||||||
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
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)
|
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 = "",
|
project_id: int, statement: str, title: str = "", when_to_apply: str = "",
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||||
tier: str = "always_on", system_ids: list[int] | None = None,
|
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:
|
) -> dict:
|
||||||
"""Create a rule scoped to a single project (no rulebook needed).
|
"""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
|
title: Short imperative title. If empty, derived from the first ~50
|
||||||
characters of statement.
|
characters of statement.
|
||||||
when_to_apply: WHEN this rule fires — the trigger, not the
|
when_to_apply: WHEN this rule fires — the trigger, not the
|
||||||
instruction. See create_rule; it decides the tier and it is how
|
instruction, and the rule's retrieval surface: name the SYMPTOM,
|
||||||
the rule is found at the moment it matters.
|
the words someone would type while stuck. See create_rule for the
|
||||||
tier: "always_on" (default) or "conditional" — see create_rule.
|
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
|
system_ids: Ids from list_canonical_systems — the global AREAS this
|
||||||
rule is about.
|
rule is about. Worth setting even on a project rule: it is what
|
||||||
arose_from_id: The note or task that CAUSED this rule.
|
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.
|
why: Optional rationale — the reason the rule exists.
|
||||||
how_to_apply: Optional operationalization — when / where it kicks in.
|
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).
|
order_index: Display order within the project's rule list (default 0).
|
||||||
force: Bypass the near-duplicate gate. By default, a title-identical rule
|
force: Bypass the near-duplicate gate. By default, a title-identical rule
|
||||||
already on this project BLOCKS creation and returns its id so you
|
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,
|
title=derived_title, statement=statement, when_to_apply=when_to_apply,
|
||||||
tier=tier, arose_from_id=arose_from_id,
|
tier=tier, arose_from_id=arose_from_id,
|
||||||
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
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)
|
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 = "",
|
rule_id: int, title: str = "", statement: str = "", when_to_apply: str = "",
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = -1,
|
why: str = "", how_to_apply: str = "", order_index: int = -1,
|
||||||
tier: str = "", system_ids: list[int] | None = None, arose_from_id: int = 0,
|
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:
|
) -> dict:
|
||||||
"""Update a rule. Empty strings / order_index=-1 leave fields unchanged.
|
"""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
|
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
|
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).
|
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()
|
uid = current_user_id()
|
||||||
fields: dict = {}
|
fields: dict = {}
|
||||||
@@ -430,9 +527,15 @@ async def update_rule(
|
|||||||
fields["why"] = why
|
fields["why"] = why
|
||||||
if how_to_apply:
|
if how_to_apply:
|
||||||
fields["how_to_apply"] = 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:
|
if order_index >= 0:
|
||||||
fields["order_index"] = order_index
|
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:
|
if rule is None:
|
||||||
raise ValueError(f"rule {rule_id} not found")
|
raise ValueError(f"rule {rule_id} not found")
|
||||||
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
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")
|
raise ValueError(f"relation {relation_id} not found")
|
||||||
return {"deleted": relation_id}
|
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:
|
def register(mcp) -> None:
|
||||||
for fn in (
|
for fn in (
|
||||||
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
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_rule_for_project, unsuppress_rule_for_project,
|
||||||
suppress_topic_for_project, unsuppress_topic_for_project,
|
suppress_topic_for_project, unsuppress_topic_for_project,
|
||||||
exclude_always_on_rulebook, include_always_on_rulebook,
|
exclude_always_on_rulebook, include_always_on_rulebook,
|
||||||
|
rules_due_for_verification, mark_rule_verified,
|
||||||
):
|
):
|
||||||
mcp.tool(name=fn.__name__)(fn)
|
mcp.tool(name=fn.__name__)(fn)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from scribe.services.access import owner_names_for
|
|||||||
from scribe.services.embeddings import (
|
from scribe.services.embeddings import (
|
||||||
DEFAULT_SIMILARITY_THRESHOLD, semantic_search_notes, semantic_search_rules,
|
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
|
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
|
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
|
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
|
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
|
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.
|
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,
|
"tier": rule.tier,
|
||||||
"why": rule.why or "",
|
"why": rule.why or "",
|
||||||
"how_to_apply": rule.how_to_apply 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,
|
"topic_id": rule.topic_id,
|
||||||
"project_id": rule.project_id,
|
"project_id": rule.project_id,
|
||||||
"similarity": float(score),
|
"similarity": float(score),
|
||||||
|
|||||||
@@ -336,7 +336,8 @@ async def list_system_records(
|
|||||||
slice, search(system_id=...) filters semantic search to this association.
|
slice, search(system_id=...) filters semantic search to this association.
|
||||||
|
|
||||||
Args:
|
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).
|
open_only: limit to tasks not done/cancelled (e.g. open issues only).
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
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
|
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
|
every project's. 0 = no filter (all projects — use only for a
|
||||||
deliberate cross-project view).
|
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.
|
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).
|
milestone_id: Place within a project milestone (0 = no milestone).
|
||||||
parent_id: Make this a sub-task of another task (0 = top-level).
|
parent_id: Make this a sub-task of another task (0 = top-level).
|
||||||
tags: List of plain-string tags without # prefix.
|
tags: List of plain-string tags without # prefix.
|
||||||
kind: 'work' (default) or 'issue'. An issue is corrective work — a
|
kind: 'work' (default), 'issue', or 'spike'.
|
||||||
problem you fixed or are fixing; record symptom → root cause → fix
|
An ISSUE is corrective work — a problem you fixed or are fixing;
|
||||||
in the body. (Plans are milestones now — call start_planning to begin
|
record symptom → root cause → fix in the body.
|
||||||
a plan; 'plan' is not a valid kind here.)
|
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
|
system_ids: Ids of the project's Systems (reusable subsystem/area
|
||||||
objects; see list_systems / create_system) to associate this task with.
|
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
|
arose_from_id: For an issue, the id of the task/feature it arose from;
|
||||||
(provenance). 0 = none.
|
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
|
force: Bypass the near-duplicate gate. By default, if a title- or
|
||||||
meaning-similar task already exists in the same project, creation is
|
meaning-similar task already exists in the same project, creation is
|
||||||
BLOCKED and the existing task's id is returned so you update it
|
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
|
# 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.)
|
# 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")
|
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
|
# 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
|
# 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")
|
task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work")
|
||||||
# Queryable structured fields for typed records — currently snippets, whose
|
# Queryable structured fields for typed records — currently snippets, whose
|
||||||
# name/language/signature/locations live here so they can be INDEXED. The
|
# 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")
|
tier: Mapped[str] = mapped_column(Text, default="always_on", server_default="always_on")
|
||||||
why: Mapped[str | None] = mapped_column(Text, nullable=True)
|
why: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
how_to_apply: 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
|
# 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
|
# 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.
|
# field, so it survives a rewording of the paragraph.
|
||||||
@@ -126,6 +146,9 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
"tier": self.tier,
|
"tier": self.tier,
|
||||||
"why": self.why or "",
|
"why": self.why or "",
|
||||||
"how_to_apply": self.how_to_apply 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,
|
"arose_from_id": self.arose_from_id,
|
||||||
"order_index": self.order_index,
|
"order_index": self.order_index,
|
||||||
"created_at": iso(self.created_at),
|
"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", ""),
|
when_to_apply=data.get("when_to_apply", ""),
|
||||||
tier=data.get("tier", "always_on"),
|
tier=data.get("tier", "always_on"),
|
||||||
arose_from_id=data.get("arose_from_id", 0) or 0,
|
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:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 404
|
return jsonify({"error": str(exc)}), 404
|
||||||
@@ -191,8 +193,13 @@ async def update_rule(rule_id: int):
|
|||||||
fields = {
|
fields = {
|
||||||
k: v for k, v in data.items()
|
k: v for k, v in data.items()
|
||||||
if k in ("title", "statement", "why", "how_to_apply", "order_index",
|
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)
|
rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
|
||||||
if rule is None:
|
if rule is None:
|
||||||
return jsonify({"error": "rule not found"}), 404
|
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", ""),
|
when_to_apply=data.get("when_to_apply", ""),
|
||||||
tier=data.get("tier", "always_on"),
|
tier=data.get("tier", "always_on"),
|
||||||
arose_from_id=data.get("arose_from_id", 0) or 0,
|
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:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 404
|
return jsonify({"error": str(exc)}), 404
|
||||||
return jsonify(await rulebooks_svc.rule_detail(
|
return jsonify(await rulebooks_svc.rule_detail(
|
||||||
get_current_user_id(), rule, data.get("system_ids"),
|
get_current_user_id(), rule, data.get("system_ids"),
|
||||||
)), 201
|
)), 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)
|
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:
|
def _d(val: str | None) -> date | None:
|
||||||
return date.fromisoformat(val) if val else 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,
|
"title": r.title, "statement": r.statement, "why": r.why,
|
||||||
"how_to_apply": r.how_to_apply, "order_index": r.order_index,
|
"how_to_apply": r.how_to_apply, "order_index": r.order_index,
|
||||||
"when_to_apply": r.when_to_apply, "tier": r.tier,
|
"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,
|
"arose_from_id": r.arose_from_id,
|
||||||
"created_at": r.created_at.isoformat(),
|
"created_at": r.created_at.isoformat(),
|
||||||
"updated_at": r.updated_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
|
# is the pre-0088 behaviour, so an old backup restores rules
|
||||||
# that bind exactly as they did when it was taken.
|
# that bind exactly as they did when it was taken.
|
||||||
tier=r_data.get("tier") or "always_on",
|
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),
|
order_index=r_data.get("order_index", 0),
|
||||||
created_at=_dt(r_data.get("created_at")),
|
created_at=_dt(r_data.get("created_at")),
|
||||||
updated_at=_dt(r_data.get("updated_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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Iterable
|
||||||
from typing import Optional
|
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 import async_session
|
||||||
from scribe.models.system import System
|
from scribe.models.system import System
|
||||||
@@ -288,6 +289,17 @@ TIERS = ("always_on", "conditional")
|
|||||||
RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
|
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:
|
def _valid_tier(tier: str) -> str:
|
||||||
"""An unrecognised tier falls back to always_on — the SAFE direction.
|
"""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"
|
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:
|
def rule_brief(rule: Rule, **extra) -> dict:
|
||||||
"""The shape a rule takes when it is SURFACED rather than opened.
|
"""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
|
out["when_to_apply"] = rule.when_to_apply
|
||||||
if rule.arose_from_id:
|
if rule.arose_from_id:
|
||||||
out["arose_from_id"] = 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})
|
out.update({k: v for k, v in extra.items() if v is not None})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -436,6 +473,7 @@ async def create_rule(
|
|||||||
topic_id: int, user_id: int, title: str, statement: str,
|
topic_id: int, user_id: int, title: str, statement: str,
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||||
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
|
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
|
||||||
|
verify_with: str = "", expires_when: str = "",
|
||||||
) -> Rule:
|
) -> Rule:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
await _assert_topic_owned(session, topic_id, user_id)
|
await _assert_topic_owned(session, topic_id, user_id)
|
||||||
@@ -447,6 +485,8 @@ async def create_rule(
|
|||||||
tier=_valid_tier(tier),
|
tier=_valid_tier(tier),
|
||||||
why=why or None,
|
why=why or None,
|
||||||
how_to_apply=how_to_apply 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,
|
arose_from_id=arose_from_id or None,
|
||||||
order_index=order_index,
|
order_index=order_index,
|
||||||
)
|
)
|
||||||
@@ -461,6 +501,7 @@ async def create_project_rule(
|
|||||||
project_id: int, user_id: int, title: str, statement: str,
|
project_id: int, user_id: int, title: str, statement: str,
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||||
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
|
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
|
||||||
|
verify_with: str = "", expires_when: str = "",
|
||||||
) -> Rule:
|
) -> Rule:
|
||||||
"""Create a rule scoped to a single project (no rulebook ceremony).
|
"""Create a rule scoped to a single project (no rulebook ceremony).
|
||||||
|
|
||||||
@@ -478,6 +519,8 @@ async def create_project_rule(
|
|||||||
tier=_valid_tier(tier),
|
tier=_valid_tier(tier),
|
||||||
why=why or None,
|
why=why or None,
|
||||||
how_to_apply=how_to_apply 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,
|
arose_from_id=arose_from_id or None,
|
||||||
order_index=order_index,
|
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)
|
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:
|
async with async_session() as session:
|
||||||
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
||||||
if rule is None:
|
if rule is None:
|
||||||
@@ -689,10 +748,32 @@ async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
|
|||||||
allowed = {
|
allowed = {
|
||||||
"title", "statement", "why", "how_to_apply", "order_index",
|
"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",
|
||||||
}
|
}
|
||||||
|
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():
|
for key, value in fields.items():
|
||||||
if key in allowed and value is not None:
|
if key not in allowed or value is None:
|
||||||
setattr(rule, key, _valid_tier(value) if key == "tier" else value)
|
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.commit()
|
||||||
await session.refresh(rule)
|
await session.refresh(rule)
|
||||||
_refresh_rule_embedding(rule)
|
_refresh_rule_embedding(rule)
|
||||||
@@ -1280,3 +1361,150 @@ def rules_payload(applicable: dict) -> dict:
|
|||||||
"suppressed_topics": applicable.get("suppressed_topics", []),
|
"suppressed_topics": applicable.get("suppressed_topics", []),
|
||||||
"excluded_always_on": applicable.get("excluded_always_on", []),
|
"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
|
# `when_to_apply` and `arose_from_id` would be truthy MagicMocks and
|
||||||
# rule_brief would attach both keys on every stand-in.
|
# rule_brief would attach both keys on every stand-in.
|
||||||
"when_to_apply": None, "tier": "always_on", "arose_from_id": None,
|
"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(),
|
"order_index": 0, "created_at": _now(), "updated_at": _now(),
|
||||||
}, attrs)
|
}, 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")
|
await update_rule(rule_id=1, statement="new statement")
|
||||||
args, kwargs = mock.call_args
|
args, kwargs = mock.call_args
|
||||||
assert args == (1, 7)
|
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
|
@pytest.mark.asyncio
|
||||||
@@ -178,13 +217,20 @@ async def test_unsubscribe_project_from_rulebook_calls_service():
|
|||||||
assert mock.called
|
assert mock.called
|
||||||
|
|
||||||
|
|
||||||
def test_register_attaches_all_sixteen_tools():
|
def test_register_attaches_every_tool():
|
||||||
"""register(mcp) should call mcp.tool(name=...) for all 16 tools."""
|
"""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
|
from scribe.mcp.tools.rulebooks import register
|
||||||
mcp = FakeMCP()
|
mcp = FakeMCP()
|
||||||
|
|
||||||
register(mcp)
|
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
|
# spot-check a few names
|
||||||
assert "list_rulebooks" in mcp.names
|
assert "list_rulebooks" in mcp.names
|
||||||
assert "create_rule" 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 "include_always_on_rulebook" in mcp.names
|
||||||
assert "create_project_rule" in mcp.names
|
assert "create_project_rule" in mcp.names
|
||||||
assert "suppress_rule_for_project" 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 "unsuppress_rule_for_project" in mcp.names
|
||||||
assert "suppress_topic_for_project" in mcp.names
|
assert "suppress_topic_for_project" in mcp.names
|
||||||
assert "unsuppress_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
|
from scribe.mcp.tools.tasks import list_tasks
|
||||||
await list_tasks()
|
await list_tasks()
|
||||||
assert mock.call_args.kwargs["task_kind"] is None
|
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:
|
This is the no-database lane, so these cover the parts that need none: the
|
||||||
the version/coverage constants, the pure join-table row helpers, and the export
|
version/coverage constants, the pure row helpers, and the export dict shape
|
||||||
dict shape (via a mocked session). Full FK-remapping round-trip is exercised
|
(via a mocked session). The full FK-remapping round-trip needs real Postgres
|
||||||
manually against a real DB (export a backup, confirm rulebooks appear).
|
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 types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
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": 4},
|
||||||
{"superseder_id": 9, "superseded_id": 5},
|
{"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("Conditional") == "always_on"
|
||||||
assert _valid_tier("") == "always_on"
|
assert _valid_tier("") == "always_on"
|
||||||
assert _valid_tier("occasionally") == "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