Rules that can go stale say so — verify_with, expires_when, the sweep, and task_kind='spike' (milestone 312, steps 1–5) #132
@@ -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> {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
@@ -314,6 +323,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 +346,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 +378,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 +387,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).
|
||||||
|
|
||||||
@@ -383,6 +420,13 @@ async def create_project_rule(
|
|||||||
arose_from_id: The note or task that CAUSED this rule.
|
arose_from_id: The note or task that CAUSED this rule.
|
||||||
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 +443,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 +452,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 +496,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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,6 +382,8 @@ 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
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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 delete as sql_delete, insert, or_, select
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,136 @@
|
|||||||
|
"""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")
|
||||||
@@ -392,3 +392,57 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user