feat(rules)!: retire the always-on tier — every rule arrives by retrieval (#394)
CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Failing after 12s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Failing after 35s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Failing after 12s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Failing after 35s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Milestone 394, steps 5-8. Operator: "remove the always on rule functionality as the goal was to not have it at all since it didn't seem to work as expected." Unconditional preload had three failures the retrieval arms do not. It could not be MEASURED — a resident rule is in the context whether or not it mattered, so nothing distinguished "this governed the act" from "this was scenery", and it was the one surface structurally exempt from the scoreboard judging every other. It was SUMMARISED AWAY by compaction while the session went on believing it held the rules. And it CROWDED OUT the few rules that applied with the thirty that did not. WHAT GOES Schema (0100): rules.tier + ck_rules_tier, rule_versions.tier, rulebooks.always_on, and project_rulebook_exclusions — a table recording a project's opt-out of something that no longer binds it unasked. Tools: list_always_on_rules, exclude_always_on_rulebook, include_always_on_rulebook. Service: the same three plus rules_etag_for, _valid_tier and the whole etag family. The SessionStart preload and the write-path staleness arm go with them: nothing is resident, so nothing can have drifted since a session loaded it. THREE CALLS WORTH REVIEWING enter_project got NARROWER, not wider. Its filter was `always_on OR area-tagged`; dropping the tier arm leaves the deterministic half, so a project with no canonical-tagged Systems gets no bulk rules and reaches them by retrieval instead. Dropping the whole clause would have made that payload bigger than the preload this milestone deletes. Backups import tolerantly. A pre-394 archive carries tier, always_on and the retired inception choice; none is read, and the exclusion key is DROPPED rather than remapped, because restoring it would write data that validate_inception now rejects as unknown. The migration is irreversible in the way that matters and says so: downgrade recreates the columns at their defaults and cannot restore which rules were always-on. A value invented to fill a hole is not a measurement. THE INSTRUCTION SURFACES SAY THE HARDER THING Deleting "call list_always_on_rules()" is easy; replacing it is not, because the new model asks a session to trust something it cannot see. All three surfaces now say a session holds nothing, that rules arrive when work matches them, and — the half that got dangerous — that "no rule arrived" means "nothing matched", never "there is no rule". Under residency an empty session was rare and suspicious; it is now the ordinary state of most turns, so reading it as permission is wrong on nearly every turn rather than occasionally. That is #3720's defect at session scale. test_instruction_surfaces_agree is repointed rather than retired: its two halves collapsed into one instruction, and it gains a guard that every surface states what absence means. _INSTRUCTIONS is back at 1999/2000 — the inception clause paid for the longer HOW line. UI (rule 27, and the opportunity step 8 named) The tier selector is gone, and what replaces it is the point: `when_to_apply` is now the field that decides whether a rule is ever seen, so the editor marks it required, warns while it is empty, and both rule lists badge a trigger-less rule "never surfaces". A rule without one is not quiet, it is unreachable. TESTS Two files deleted outright — test_rules_etag.py and test_inception_rules.py tested subsystems that no longer exist. Elsewhere obsolete cases were removed and the rest repointed. One deserves naming: the wiring test asserted the act arms pass no `tier`, which had become an assertion that could not fail. It is repointed onto `kind`, which does still exist and where the same claim is live — a preference must reach a write exactly as a rule does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
This commit is contained in:
@@ -0,0 +1,96 @@
|
|||||||
|
"""drop the always-on tier: rules.tier, rulebooks.always_on, the exclusions table
|
||||||
|
|
||||||
|
Revision ID: 0100
|
||||||
|
Revises: 0099
|
||||||
|
Create Date: 2026-09-11
|
||||||
|
|
||||||
|
Milestone 394. Every rule now reaches a session by retrieval — because
|
||||||
|
something it is about to do made the rule relevant — and the machinery that
|
||||||
|
delivered rules unconditionally goes with it.
|
||||||
|
|
||||||
|
WHAT GOES, AND WHERE IT CAME FROM
|
||||||
|
|
||||||
|
- ``rules.tier`` and its ``ck_rules_tier`` CHECK (migration 0088). Dropping
|
||||||
|
the column takes the constraint with it. Rule 36 is about ADDING a value to
|
||||||
|
a live whitelist, which needs DROP + ADD in the same migration; it does not
|
||||||
|
speak to removing the column outright, and saying so here is cheaper than
|
||||||
|
the next reader wondering whether it was forgotten.
|
||||||
|
- ``rule_versions.tier`` (migration 0098). A version records what a rule
|
||||||
|
SAID; with no tier on a rule there is nothing for a snapshot to carry.
|
||||||
|
- ``rulebooks.always_on`` (migration 0058). A rulebook reaches a project by
|
||||||
|
subscription now, and by nothing else.
|
||||||
|
- ``project_rulebook_exclusions`` (migration 0085). It recorded a project's
|
||||||
|
opt-out of an always-on rulebook. Opting out of something that no longer
|
||||||
|
binds you is not a state that can exist — declining a rulebook is
|
||||||
|
expressed by not subscribing to it.
|
||||||
|
|
||||||
|
IRREVERSIBLE, AND THE DOWNGRADE SAYS SO RATHER THAN PRETENDING
|
||||||
|
|
||||||
|
The downgrade recreates the columns and the table with their DEFAULTS. It
|
||||||
|
cannot restore WHICH rules were always-on, which rulebooks bound every project,
|
||||||
|
or which projects had opted out — that information is in what this drops.
|
||||||
|
|
||||||
|
That distinction is the one this repo keeps insisting on: a value invented to
|
||||||
|
fill a hole is not a measurement. So a downgraded database is structurally able
|
||||||
|
to run the old code and is NOT the database the old code was running against —
|
||||||
|
every rule comes back at the ``always_on`` default, which for the tier column
|
||||||
|
happens to mean "binding", the safe direction to be wrong in.
|
||||||
|
|
||||||
|
Anyone who needs the real prior state restores a backup taken before this ran.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0100"
|
||||||
|
down_revision = "0099"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TIERS = ("always_on", "conditional")
|
||||||
|
|
||||||
|
|
||||||
|
def _in_list(column: str, values: tuple[str, ...]) -> str:
|
||||||
|
return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_table("project_rulebook_exclusions")
|
||||||
|
op.drop_column("rulebooks", "always_on")
|
||||||
|
op.drop_column("rule_versions", "tier")
|
||||||
|
# The CHECK goes with the column it constrains; naming it here would be a
|
||||||
|
# second drop of the same object.
|
||||||
|
op.drop_column("rules", "tier")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Structure only. See the module docstring — the values are gone."""
|
||||||
|
op.add_column(
|
||||||
|
"rules",
|
||||||
|
sa.Column("tier", sa.Text(), nullable=False, server_default="always_on"),
|
||||||
|
)
|
||||||
|
op.create_check_constraint("ck_rules_tier", "rules", _in_list("tier", _TIERS))
|
||||||
|
op.add_column("rule_versions", sa.Column("tier", sa.Text(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"rulebooks",
|
||||||
|
sa.Column(
|
||||||
|
"always_on", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"project_rulebook_exclusions",
|
||||||
|
sa.Column(
|
||||||
|
"project_id", sa.BigInteger(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True, nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"rulebook_id", sa.BigInteger(),
|
||||||
|
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True, nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"), nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
import { apiGet, apiPost } from "@/api/client";
|
import { apiGet, apiPost } from "@/api/client";
|
||||||
|
|
||||||
export interface InceptionChoices {
|
export interface InceptionChoices {
|
||||||
exclude_always_on_rulebooks: number[];
|
|
||||||
subscribe_rulebooks: number[];
|
subscribe_rulebooks: number[];
|
||||||
design_system_id: number | null;
|
design_system_id: number | null;
|
||||||
seed_systems: boolean;
|
seed_systems: boolean;
|
||||||
@@ -16,9 +15,7 @@ export interface InceptionRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InceptionDefaults {
|
export interface InceptionDefaults {
|
||||||
always_on_rulebooks: { id: number; title: string }[];
|
rulebooks: { id: number; title: string }[];
|
||||||
other_rulebooks: { id: number; title: string }[];
|
|
||||||
excluded_always_on: { id: number; title: string }[];
|
|
||||||
subscribed_rulebooks: { id: number; title: string }[];
|
subscribed_rulebooks: { id: number; title: string }[];
|
||||||
design_system_id: number | null;
|
design_system_id: number | null;
|
||||||
design_systems: { id: number; title: string }[];
|
design_systems: { id: number; title: string }[];
|
||||||
@@ -32,7 +29,7 @@ export interface InceptionDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const emptyChoices = (): InceptionChoices => ({
|
export const emptyChoices = (): InceptionChoices => ({
|
||||||
exclude_always_on_rulebooks: [], subscribe_rulebooks: [], design_system_id: null, seed_systems: false,
|
subscribe_rulebooks: [], design_system_id: null, seed_systems: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fetchInceptionDefaults = (projectId: number) =>
|
export const fetchInceptionDefaults = (projectId: number) =>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { RecordUsage } from "@/types/usage";
|
|||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||||
|
|
||||||
/** How a rule reaches a session (milestone 307). */
|
/** How a rule reaches a session (milestone 307). */
|
||||||
export type RuleTier = "always_on" | "conditional";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A typed edge between two rules. Each kind exists because its absence forced
|
* A typed edge between two rules. Each kind exists because its absence forced
|
||||||
@@ -26,7 +25,6 @@ export interface Rulebook {
|
|||||||
owner_user_id: number;
|
owner_user_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
always_on: boolean;
|
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
@@ -49,12 +47,6 @@ export interface Rule {
|
|||||||
statement: string;
|
statement: string;
|
||||||
/** WHEN this rule fires — the trigger, not the instruction. */
|
/** WHEN this rule fires — the trigger, not the instruction. */
|
||||||
when_to_apply: string;
|
when_to_apply: string;
|
||||||
/**
|
|
||||||
* always_on preloads into every session; conditional is reachable and
|
|
||||||
* surfaced when its trigger fires. A rule with no tier set behaves as
|
|
||||||
* always_on, which is how every rule behaved before this existed.
|
|
||||||
*/
|
|
||||||
tier: RuleTier;
|
|
||||||
why: string;
|
why: string;
|
||||||
how_to_apply: string;
|
how_to_apply: string;
|
||||||
/**
|
/**
|
||||||
@@ -87,7 +79,6 @@ export interface RuleHeader {
|
|||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
topic_id: number | null;
|
topic_id: number | null;
|
||||||
tier: RuleTier;
|
|
||||||
/** A date (YYYY-MM-DD), not a timestamp. */
|
/** A date (YYYY-MM-DD), not a timestamp. */
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
when_to_apply?: string;
|
when_to_apply?: string;
|
||||||
@@ -134,7 +125,6 @@ export interface ApplicableRules {
|
|||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
subscribed_rulebooks: { id: number; title: string }[];
|
subscribed_rulebooks: { id: number; title: string }[];
|
||||||
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
|
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
|
||||||
excluded_always_on: { id: number; title: string }[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rulebooks ───────────────────────────────────────────────────────
|
// ── Rulebooks ───────────────────────────────────────────────────────
|
||||||
@@ -152,7 +142,7 @@ export async function createRulebook(data: { title: string; description?: string
|
|||||||
return apiPost("/api/rulebooks", data);
|
return apiPost("/api/rulebooks", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string; always_on: boolean }>): Promise<Rulebook> {
|
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string }>): Promise<Rulebook> {
|
||||||
return apiPatch(`/api/rulebooks/${id}`, data);
|
return apiPatch(`/api/rulebooks/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +197,6 @@ export interface RuleWrite {
|
|||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
when_to_apply: string;
|
when_to_apply: string;
|
||||||
tier: RuleTier;
|
|
||||||
why: string;
|
why: string;
|
||||||
how_to_apply: string;
|
how_to_apply: string;
|
||||||
order_index: number;
|
order_index: number;
|
||||||
@@ -258,7 +247,6 @@ export interface RuleVersion {
|
|||||||
why?: string;
|
why?: string;
|
||||||
how_to_apply?: string;
|
how_to_apply?: string;
|
||||||
when_to_apply?: string;
|
when_to_apply?: string;
|
||||||
tier?: string;
|
|
||||||
verify_with?: string;
|
verify_with?: string;
|
||||||
expires_when?: string;
|
expires_when?: string;
|
||||||
}
|
}
|
||||||
@@ -323,16 +311,6 @@ export async function unsuppressTopicForProject(projectId: number, topicId: numb
|
|||||||
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Always-on exclusions (milestone 297) ────────────────────────────────────
|
|
||||||
|
|
||||||
export async function excludeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
|
|
||||||
await apiPost(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function includeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
|
|
||||||
await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One row of the staleness sweep. Unlike RuleHeader this carries the CHECK
|
* One row of the staleness sweep. Unlike RuleHeader this carries the CHECK
|
||||||
@@ -343,7 +321,6 @@ export interface RuleVerificationRow {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
tier: RuleTier;
|
|
||||||
topic_id: number | null;
|
topic_id: number | null;
|
||||||
project_id: number | null;
|
project_id: number | null;
|
||||||
when_to_apply: string;
|
when_to_apply: string;
|
||||||
@@ -366,12 +343,10 @@ export interface RuleVerificationRow {
|
|||||||
*/
|
*/
|
||||||
export async function listRulesDueForVerification(opts: {
|
export async function listRulesDueForVerification(opts: {
|
||||||
olderThanDays?: number;
|
olderThanDays?: number;
|
||||||
tier?: RuleTier;
|
|
||||||
neverOnly?: boolean;
|
neverOnly?: boolean;
|
||||||
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
|
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (opts.olderThanDays) q.set("older_than_days", String(opts.olderThanDays));
|
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");
|
if (opts.neverOnly) q.set("never_only", "true");
|
||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`);
|
return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`);
|
||||||
|
|||||||
@@ -20,6 +20,15 @@
|
|||||||
milestone (tier, then verification) and were byte-identical; a third would
|
milestone (tier, then verification) and were byte-identical; a third would
|
||||||
have drifted. The pane's italic serif title is inherited by anything inside
|
have drifted. The pane's italic serif title is inherited by anything inside
|
||||||
it, so the chip resets family and style explicitly. */
|
it, so the chip resets family and style explicitly. */
|
||||||
|
/* A rule with no trigger cannot be retrieved, and since milestone 394
|
||||||
|
retrieval is the only delivery — so this marks a rule that will never
|
||||||
|
reach a session. Warning rather than error: the rule is not broken, it is
|
||||||
|
unreachable, and the fix is one field away. */
|
||||||
|
.rule-chip-inert {
|
||||||
|
color: var(--fs-warning-fg);
|
||||||
|
background: color-mix(in srgb, var(--fs-warning) 12%, var(--fs-surface-raised));
|
||||||
|
}
|
||||||
|
|
||||||
.rule-chip {
|
.rule-chip {
|
||||||
margin-left: 0.4rem;
|
margin-left: 0.4rem;
|
||||||
font-family: var(--fs-font-body);
|
font-family: var(--fs-font-body);
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
|
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
|
||||||
const alwaysOn = ref<{ id: number; title: string }[]>([]);
|
|
||||||
const others = ref<{ id: number; title: string }[]>([]);
|
const others = ref<{ id: number; title: string }[]>([]);
|
||||||
const designSystems = ref<{ id: number; title: string }[]>([]);
|
const designSystems = ref<{ id: number; title: string }[]>([]);
|
||||||
const systemsCount = ref(0);
|
const systemsCount = ref(0);
|
||||||
@@ -47,21 +46,18 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
if (props.mode === "decide" && props.projectId) {
|
if (props.mode === "decide" && props.projectId) {
|
||||||
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
|
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
|
||||||
alwaysOn.value = d.always_on_rulebooks;
|
others.value = d.rulebooks;
|
||||||
others.value = d.other_rulebooks;
|
|
||||||
designSystems.value = d.design_systems;
|
designSystems.value = d.design_systems;
|
||||||
systemsCount.value = d.systems;
|
systemsCount.value = d.systems;
|
||||||
// Start from what stands today so "record" without changes is a true inherit-all.
|
// Start from what stands today so "record" without changes is a true inherit-all.
|
||||||
local.value = {
|
local.value = {
|
||||||
exclude_always_on_rulebooks: d.excluded_always_on.map((r) => r.id),
|
|
||||||
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
|
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
|
||||||
design_system_id: d.design_system_id,
|
design_system_id: d.design_system_id,
|
||||||
seed_systems: false,
|
seed_systems: false,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]);
|
const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]);
|
||||||
alwaysOn.value = rulebooks.filter((r) => r.always_on).map((r) => ({ id: r.id, title: r.title }));
|
others.value = rulebooks.map((r) => ({ id: r.id, title: r.title }));
|
||||||
others.value = rulebooks.filter((r) => !r.always_on).map((r) => ({ id: r.id, title: r.title }));
|
|
||||||
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
|
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -71,13 +67,6 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function inherits(id: number): boolean {
|
|
||||||
return !local.value.exclude_always_on_rulebooks.includes(id);
|
|
||||||
}
|
|
||||||
function toggleInherit(id: number) {
|
|
||||||
const list = local.value.exclude_always_on_rulebooks;
|
|
||||||
local.value.exclude_always_on_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
|
|
||||||
}
|
|
||||||
function subscribed(id: number): boolean {
|
function subscribed(id: number): boolean {
|
||||||
return local.value.subscribe_rulebooks.includes(id);
|
return local.value.subscribe_rulebooks.includes(id);
|
||||||
}
|
}
|
||||||
@@ -87,7 +76,7 @@ function toggleSubscribe(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nothingToDecide = computed(
|
const nothingToDecide = computed(
|
||||||
() => !alwaysOn.value.length && !others.value.length && !designSystems.value.length,
|
() => !others.value.length && !designSystems.value.length,
|
||||||
);
|
);
|
||||||
|
|
||||||
async function record() {
|
async function record() {
|
||||||
@@ -118,16 +107,11 @@ onMounted(load);
|
|||||||
<p v-if="loading" class="inception-muted">Loading…</p>
|
<p v-if="loading" class="inception-muted">Loading…</p>
|
||||||
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="alwaysOn.length" class="inception-group">
|
|
||||||
<h4>Always-on rulebooks</h4>
|
|
||||||
<p class="inception-muted">Checked = inherits. Uncheck to exclude a rulebook for this project only.</p>
|
|
||||||
<label v-for="rb in alwaysOn" :key="rb.id" class="inception-choice">
|
|
||||||
<input type="checkbox" :checked="inherits(rb.id)" @change="toggleInherit(rb.id)" />
|
|
||||||
<span>{{ rb.title }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div v-if="others.length" class="inception-group">
|
<div v-if="others.length" class="inception-group">
|
||||||
<h4>Subscribe to rulebooks</h4>
|
<h4>Subscribe to rulebooks</h4>
|
||||||
|
<p class="inception-muted">
|
||||||
|
A rulebook binds this project only if it is subscribed — nothing is inherited automatically.
|
||||||
|
</p>
|
||||||
<label v-for="rb in others" :key="rb.id" class="inception-choice">
|
<label v-for="rb in others" :key="rb.id" class="inception-choice">
|
||||||
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
|
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
|
||||||
<span>{{ rb.title }}</span>
|
<span>{{ rb.title }}</span>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
unsuppressRuleForProject,
|
unsuppressRuleForProject,
|
||||||
suppressTopicForProject,
|
suppressTopicForProject,
|
||||||
unsuppressTopicForProject,
|
unsuppressTopicForProject,
|
||||||
includeAlwaysOnRulebook,
|
|
||||||
} from "@/api/rulebooks";
|
} from "@/api/rulebooks";
|
||||||
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
||||||
|
|
||||||
@@ -32,7 +31,7 @@ const ruleDetails = ref<Record<number, {
|
|||||||
const showProjectRuleForm = ref(false);
|
const showProjectRuleForm = ref(false);
|
||||||
const newProjectRule = ref({
|
const newProjectRule = ref({
|
||||||
title: "", statement: "", why: "", how_to_apply: "",
|
title: "", statement: "", why: "", how_to_apply: "",
|
||||||
when_to_apply: "", tier: "always_on" as "always_on" | "conditional",
|
when_to_apply: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -49,11 +48,6 @@ async function subscribe(rulebookId: number) {
|
|||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function includeBack(rulebookId: number) {
|
|
||||||
await includeAlwaysOnRulebook(props.projectId, rulebookId);
|
|
||||||
await load();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unsubscribe(rulebookId: number) {
|
async function unsubscribe(rulebookId: number) {
|
||||||
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
||||||
await unsubscribeProject(props.projectId, rulebookId);
|
await unsubscribeProject(props.projectId, rulebookId);
|
||||||
@@ -134,11 +128,10 @@ async function submitProjectRule() {
|
|||||||
why: newProjectRule.value.why.trim() || undefined,
|
why: newProjectRule.value.why.trim() || undefined,
|
||||||
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
|
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
|
||||||
when_to_apply: newProjectRule.value.when_to_apply.trim() || undefined,
|
when_to_apply: newProjectRule.value.when_to_apply.trim() || undefined,
|
||||||
tier: newProjectRule.value.tier,
|
|
||||||
});
|
});
|
||||||
newProjectRule.value = {
|
newProjectRule.value = {
|
||||||
title: "", statement: "", why: "", how_to_apply: "",
|
title: "", statement: "", why: "", how_to_apply: "",
|
||||||
when_to_apply: "", tier: "always_on",
|
when_to_apply: "",
|
||||||
};
|
};
|
||||||
showProjectRuleForm.value = false;
|
showProjectRuleForm.value = false;
|
||||||
await load();
|
await load();
|
||||||
@@ -210,17 +203,6 @@ watch(() => props.projectId, load);
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="applicable.excluded_always_on?.length" class="excluded">
|
|
||||||
<h3>Excluded always-on rulebooks</h3>
|
|
||||||
<p class="excluded-note">Opted out at inception — these do not bind this project.</p>
|
|
||||||
<div class="chips">
|
|
||||||
<span v-for="rb in applicable.excluded_always_on" :key="rb.id" class="chip chip-excluded">
|
|
||||||
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
|
|
||||||
<button class="chip-remove" @click="includeBack(rb.id)" aria-label="Include again" title="Include again">↩</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="project-rules">
|
<section class="project-rules">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h3>Project rules</h3>
|
<h3>Project rules</h3>
|
||||||
@@ -246,22 +228,13 @@ watch(() => props.projectId, load);
|
|||||||
></textarea>
|
></textarea>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newProjectRule.when_to_apply"
|
v-model="newProjectRule.when_to_apply"
|
||||||
placeholder="When to apply — the trigger, not the instruction"
|
placeholder="When to apply — the moment, in the words a session actually produces"
|
||||||
rows="2"
|
rows="2"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="tier-row">
|
<p v-if="!newProjectRule.when_to_apply.trim()" class="trigger-hint">
|
||||||
<label>
|
Without a trigger the rule will never reach a session — nothing is
|
||||||
<input v-model="newProjectRule.tier" type="radio" value="always_on" />
|
preloaded, so a rule arrives only when work matches what it names.
|
||||||
Always on
|
</p>
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input v-model="newProjectRule.tier" type="radio" value="conditional" />
|
|
||||||
Conditional
|
|
||||||
</label>
|
|
||||||
<span class="tier-hint">
|
|
||||||
Conditional if you had to name a system, an artifact or a moment to state the trigger.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newProjectRule.why"
|
v-model="newProjectRule.why"
|
||||||
placeholder="Why (optional) — the rationale"
|
placeholder="Why (optional) — the rationale"
|
||||||
@@ -405,14 +378,9 @@ watch(() => props.projectId, load);
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tier-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
|
.trigger-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); }
|
||||||
.tier-row label { display: inline-flex; align-items: center; gap: 0.3rem; }
|
|
||||||
.tier-row input { accent-color: var(--fs-accent); }
|
|
||||||
.tier-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); }
|
|
||||||
|
|
||||||
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
|
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
|
||||||
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
|
|
||||||
.chip-excluded .chip-remove { text-decoration: none; }
|
|
||||||
.rules-tab { padding: 1rem; }
|
.rules-tab { padding: 1rem; }
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { computed, ref, watch, onMounted } from "vue";
|
import { computed, ref, watch, onMounted } from "vue";
|
||||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||||
import type { RuleTier } from "@/api/rulebooks";
|
|
||||||
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
||||||
|
|
||||||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||||
@@ -13,7 +12,6 @@ const canon = useCanonicalSystemsStore();
|
|||||||
const title = ref("");
|
const title = ref("");
|
||||||
const statement = ref("");
|
const statement = ref("");
|
||||||
const whenToApply = ref("");
|
const whenToApply = ref("");
|
||||||
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("");
|
||||||
@@ -70,7 +68,6 @@ async function load() {
|
|||||||
title.value = r.title;
|
title.value = r.title;
|
||||||
statement.value = r.statement;
|
statement.value = r.statement;
|
||||||
whenToApply.value = r.when_to_apply || "";
|
whenToApply.value = r.when_to_apply || "";
|
||||||
tier.value = r.tier || "always_on";
|
|
||||||
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 || "";
|
||||||
@@ -81,7 +78,6 @@ async function load() {
|
|||||||
title.value = "";
|
title.value = "";
|
||||||
statement.value = "";
|
statement.value = "";
|
||||||
whenToApply.value = "";
|
whenToApply.value = "";
|
||||||
tier.value = "always_on";
|
|
||||||
systemIds.value = [];
|
systemIds.value = [];
|
||||||
why.value = "";
|
why.value = "";
|
||||||
howToApply.value = "";
|
howToApply.value = "";
|
||||||
@@ -100,7 +96,6 @@ async function save() {
|
|||||||
title: title.value,
|
title: title.value,
|
||||||
statement: statement.value,
|
statement: statement.value,
|
||||||
when_to_apply: whenToApply.value,
|
when_to_apply: whenToApply.value,
|
||||||
tier: tier.value,
|
|
||||||
// Always sent, so clearing the last area actually clears it — the server
|
// Always sent, so clearing the last area actually clears it — the server
|
||||||
// reads a list as "these ARE the areas now".
|
// reads a list as "these ARE the areas now".
|
||||||
system_ids: systemIds.value,
|
system_ids: systemIds.value,
|
||||||
@@ -147,38 +142,19 @@ watch(() => props.ruleId, load);
|
|||||||
Statement <span class="required">*</span>
|
Statement <span class="required">*</span>
|
||||||
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label :class="{ 'trigger-missing': !whenToApply.trim() }">
|
||||||
When to apply
|
When to apply <span class="required">*</span>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="whenToApply"
|
v-model="whenToApply"
|
||||||
rows="2"
|
rows="3"
|
||||||
placeholder="The trigger, not the instruction — “before any git push”, “when a release is being cut”."
|
placeholder="The moment, in the words a session actually produces — “about to run git push with an earlier CI run unread”, not “when pacing actions”."
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<p v-if="!whenToApply.trim()" class="trigger-warning">
|
||||||
<fieldset class="tier">
|
<strong>Without this, the rule will never reach a session.</strong>
|
||||||
<legend>How it reaches a session</legend>
|
Nothing is preloaded: a rule arrives when what someone is doing matches
|
||||||
<label class="tier-opt">
|
its trigger, so an empty trigger leaves the rule findable by nobody.
|
||||||
<input v-model="tier" type="radio" value="always_on" />
|
|
||||||
<span>
|
|
||||||
<strong>Always on</strong>
|
|
||||||
— loaded into every session.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label class="tier-opt">
|
|
||||||
<input v-model="tier" type="radio" value="conditional" />
|
|
||||||
<span>
|
|
||||||
<strong>Conditional</strong>
|
|
||||||
— arrives when its trigger fires.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<p class="tier-test">
|
|
||||||
The test: can you name the trigger <em>without</em> naming a system, an artifact type
|
|
||||||
or a moment? If the honest answer is “whenever you are working”, it is always on.
|
|
||||||
Conditional costs nothing when it is irrelevant, which is what lets it be as long as
|
|
||||||
it needs to be.
|
|
||||||
</p>
|
</p>
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset v-if="canon.catalog.length" class="areas">
|
<fieldset v-if="canon.catalog.length" class="areas">
|
||||||
<legend>Areas this rule is about</legend>
|
<legend>Areas this rule is about</legend>
|
||||||
@@ -190,14 +166,14 @@ watch(() => props.ruleId, load);
|
|||||||
/>
|
/>
|
||||||
<span>{{ entry.name }}</span>
|
<span>{{ entry.name }}</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="tier-test">
|
<p class="field-note">
|
||||||
What lets this rule reach a project working in that area.
|
What lets this rule reach a project working in that area.
|
||||||
</p>
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="check">
|
<fieldset class="check">
|
||||||
<legend>Can this rule go stale?</legend>
|
<legend>Can this rule go stale?</legend>
|
||||||
<p class="tier-test intro">
|
<p class="field-note intro">
|
||||||
Most rules are <em>decisions</em> — they have no truth value and change only when you
|
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
|
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.
|
<em>fact</em> about something outside your control, because those go false quietly.
|
||||||
@@ -226,7 +202,7 @@ watch(() => props.ruleId, load);
|
|||||||
<button type="button" :disabled="verifying" @click="verify(false)">No longer true</button>
|
<button type="button" :disabled="verifying" @click="verify(false)">No longer true</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="savedCheck" class="tier-test">
|
<p v-if="savedCheck" class="field-note">
|
||||||
Record this after actually running the check, never on the strength of the rule
|
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,
|
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
|
not in a state worth recording, so it stays at the top of the sweep until you fix or
|
||||||
@@ -243,7 +219,7 @@ watch(() => props.ruleId, load);
|
|||||||
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
|
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="tier-test">
|
<p class="field-note">
|
||||||
Rules that <em>fail together</em> are linked, never merged — a merged rule cannot be
|
Rules that <em>fail together</em> are linked, never merged — a merged rule cannot be
|
||||||
cited, surfaced or suppressed a clause at a time.
|
cited, surfaced or suppressed a clause at a time.
|
||||||
</p>
|
</p>
|
||||||
@@ -303,9 +279,16 @@ input, textarea {
|
|||||||
}
|
}
|
||||||
fieldset { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: 0.75rem; margin-bottom: 1rem; }
|
fieldset { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: 0.75rem; margin-bottom: 1rem; }
|
||||||
legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||||||
.tier-opt, .area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
|
.area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
|
||||||
.tier-opt input, .area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
.area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||||||
.tier-test { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
.trigger-missing textarea { border-color: var(--fs-warning); }
|
||||||
|
/* --fs-warning-fg, not --fs-warning: the token set draws the distinction
|
||||||
|
between the warning HUE and warning text, and this is text. */
|
||||||
|
.trigger-warning {
|
||||||
|
margin: -0.35rem 0 0.6rem; font-size: 0.78rem; line-height: 1.45;
|
||||||
|
color: var(--fs-warning-fg);
|
||||||
|
}
|
||||||
|
.field-note { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||||||
|
|
||||||
.relations h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
.relations h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||||
.relations ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
.relations ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
|||||||
@@ -40,14 +40,13 @@ const loadingDetail = ref(false);
|
|||||||
// Labels rather than column names: a reader is deciding whether to open a
|
// Labels rather than column names: a reader is deciding whether to open a
|
||||||
// row, and "How to apply" reads where "how_to_apply" has to be decoded.
|
// row, and "How to apply" reads where "how_to_apply" has to be decoded.
|
||||||
type TextField =
|
type TextField =
|
||||||
| "title" | "statement" | "when_to_apply" | "tier"
|
| "title" | "statement" | "when_to_apply"
|
||||||
| "why" | "how_to_apply" | "verify_with" | "expires_when";
|
| "why" | "how_to_apply" | "verify_with" | "expires_when";
|
||||||
|
|
||||||
const FIELDS: Array<[TextField, string]> = [
|
const FIELDS: Array<[TextField, string]> = [
|
||||||
["title", "Title"],
|
["title", "Title"],
|
||||||
["statement", "Statement"],
|
["statement", "Statement"],
|
||||||
["when_to_apply", "When to apply"],
|
["when_to_apply", "When to apply"],
|
||||||
["tier", "Tier"],
|
|
||||||
["why", "Why"],
|
["why", "Why"],
|
||||||
["how_to_apply", "How to apply"],
|
["how_to_apply", "How to apply"],
|
||||||
["verify_with", "Check"],
|
["verify_with", "Check"],
|
||||||
|
|||||||
@@ -26,9 +26,14 @@ const emit = defineEmits<{
|
|||||||
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
{{ r.title }}
|
{{ r.title }}
|
||||||
<!-- Only conditional is marked: always-on is the default and
|
<!-- Marked only when something is WRONG: every rule arrives by
|
||||||
badging every row would say nothing. -->
|
retrieval now, so "conditional" stopped distinguishing anything.
|
||||||
<span v-if="r.tier === 'conditional'" class="rule-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
|
A missing trigger does — it means nothing can retrieve this. -->
|
||||||
|
<span
|
||||||
|
v-if="!r.when_to_apply"
|
||||||
|
class="rule-chip rule-chip-inert"
|
||||||
|
title="No trigger, so nothing can retrieve it — this rule will never reach a session"
|
||||||
|
>never surfaces</span>
|
||||||
<!-- Present only on a rule carrying a check, so the chip's very
|
<!-- Present only on a rule carrying a check, so the chip's very
|
||||||
presence says "this one asserts a fact that can go false". -->
|
presence says "this one asserts a fact that can go false". -->
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -10,19 +10,16 @@
|
|||||||
*/
|
*/
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
import type { RuleTier } from "@/api/rulebooks";
|
|
||||||
|
|
||||||
const emit = defineEmits<{ "open-rule": [id: number] }>();
|
const emit = defineEmits<{ "open-rule": [id: number] }>();
|
||||||
|
|
||||||
const store = useRulebooksStore();
|
const store = useRulebooksStore();
|
||||||
const neverOnly = ref(false);
|
const neverOnly = ref(false);
|
||||||
const tier = ref<RuleTier | "">("");
|
|
||||||
const busyId = ref<number | null>(null);
|
const busyId = ref<number | null>(null);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
return store.fetchRulesDue({
|
return store.fetchRulesDue({
|
||||||
neverOnly: neverOnly.value || undefined,
|
neverOnly: neverOnly.value || undefined,
|
||||||
tier: tier.value || undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,14 +50,6 @@ onMounted(reload);
|
|||||||
<input v-model="neverOnly" type="checkbox" @change="reload" />
|
<input v-model="neverOnly" type="checkbox" @change="reload" />
|
||||||
<span>Never checked only</span>
|
<span>Never checked only</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p v-if="store.loading" class="state">Loading…</p>
|
<p v-if="store.loading" class="state">Loading…</p>
|
||||||
@@ -68,14 +57,18 @@ onMounted(reload);
|
|||||||
<!-- An empty sweep is GOOD NEWS, and must not read like a broken page. -->
|
<!-- An empty sweep is GOOD NEWS, and must not read like a broken page. -->
|
||||||
<p v-else-if="!store.rulesDue.length" class="state empty">
|
<p v-else-if="!store.rulesDue.length" class="state empty">
|
||||||
Nothing to check.
|
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." }}
|
{{ neverOnly ? "No rule matches these filters." : "No rule carries a check yet — add one to a rule that asserts a fact." }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ol v-else class="rows">
|
<ol v-else class="rows">
|
||||||
<li v-for="r in store.rulesDue" :key="r.id" class="row">
|
<li v-for="r in store.rulesDue" :key="r.id" class="row">
|
||||||
<div class="row-head">
|
<div class="row-head">
|
||||||
<button class="row-title" @click="emit('open-rule', r.id)">{{ r.title }}</button>
|
<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
|
||||||
|
v-if="!r.when_to_apply"
|
||||||
|
class="rule-chip rule-chip-inert"
|
||||||
|
title="No trigger, so nothing can retrieve it — this rule will never reach a session"
|
||||||
|
>never surfaces</span>
|
||||||
<span class="age" :class="{ unchecked: r.days_since_verified === null }">
|
<span class="age" :class="{ unchecked: r.days_since_verified === null }">
|
||||||
{{ r.days_since_verified === null
|
{{ r.days_since_verified === null
|
||||||
? "never checked"
|
? "never checked"
|
||||||
|
|||||||
@@ -74,14 +74,6 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
|||||||
<section class="pane">
|
<section class="pane">
|
||||||
<header>
|
<header>
|
||||||
<h2>Topics</h2>
|
<h2>Topics</h2>
|
||||||
<label v-if="currentRulebook" class="always-on-toggle" title="When on, rules from this rulebook load at session start regardless of project context">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="currentRulebook.always_on"
|
|
||||||
@change="store.toggleAlwaysOn(currentRulebook.id)"
|
|
||||||
/>
|
|
||||||
<span>Always on</span>
|
|
||||||
</label>
|
|
||||||
</header>
|
</header>
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li
|
||||||
@@ -124,12 +116,6 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
|||||||
<style src="@/assets/rules-shared.css" />
|
<style src="@/assets/rules-shared.css" />
|
||||||
<style scoped>
|
<style scoped>
|
||||||
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||||
.always-on-toggle {
|
|
||||||
display: flex; align-items: center; gap: 0.4rem;
|
|
||||||
font-size: 0.85rem; opacity: 0.85; cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.always-on-toggle input { cursor: pointer; }
|
|
||||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||||
li.active { background: var(--fs-accent-soft); }
|
li.active { background: var(--fs-accent-soft); }
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ async function submitNew() {
|
|||||||
@click="emit('select', rb.id)"
|
@click="emit('select', rb.id)"
|
||||||
>
|
>
|
||||||
<span class="title">{{ rb.title }}</span>
|
<span class="title">{{ rb.title }}</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
|
<!-- Not a rulebook, and deliberately below them: a cross-cutting view over
|
||||||
@@ -65,16 +64,6 @@ ul { list-style: none; padding: 0; margin: 1rem 0; }
|
|||||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
|
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
|
||||||
li.active { background: var(--fs-accent-soft); }
|
li.active { background: var(--fs-accent-soft); }
|
||||||
li:hover { background: var(--fs-surface-hover); }
|
li:hover { background: var(--fs-surface-hover); }
|
||||||
.always-on-badge {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: var(--fs-accent);
|
|
||||||
color: var(--fs-text-on-action);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
.sweep-entry {
|
.sweep-entry {
|
||||||
display: block; width: 100%; text-align: left;
|
display: block; width: 100%; text-align: left;
|
||||||
margin-top: var(--fs-space-3);
|
margin-top: var(--fs-space-3);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
// Kept so a verify re-reads the sweep with the SAME filters the operator is
|
// 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
|
// looking at — re-fetching unfiltered would silently widen the list under
|
||||||
// them at the moment they acted on it.
|
// them at the moment they acted on it.
|
||||||
const lastSweepOpts = ref<{ olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean }>({});
|
const lastSweepOpts = ref<{ olderThanDays?: number; neverOnly?: boolean }>({});
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
async function fetchRulebooks() {
|
async function fetchRulebooks() {
|
||||||
@@ -57,19 +57,13 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
return rb;
|
return rb;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description" | "always_on">>) {
|
async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description">>) {
|
||||||
const rb = await api.updateRulebook(id, data);
|
const rb = await api.updateRulebook(id, data);
|
||||||
const idx = rulebooks.value.findIndex((r) => r.id === id);
|
const idx = rulebooks.value.findIndex((r) => r.id === id);
|
||||||
if (idx >= 0) rulebooks.value[idx] = rb;
|
if (idx >= 0) rulebooks.value[idx] = rb;
|
||||||
return rb;
|
return rb;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleAlwaysOn(id: number) {
|
|
||||||
const current = rulebooks.value.find((r) => r.id === id);
|
|
||||||
if (!current) return;
|
|
||||||
return updateRulebook(id, { always_on: !current.always_on });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteRulebook(id: number) {
|
async function deleteRulebook(id: number) {
|
||||||
await api.deleteRulebook(id);
|
await api.deleteRulebook(id);
|
||||||
rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
|
rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
|
||||||
@@ -112,7 +106,6 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
title: rule.title,
|
title: rule.title,
|
||||||
statement: rule.statement,
|
statement: rule.statement,
|
||||||
topic_id: rule.topic_id,
|
topic_id: rule.topic_id,
|
||||||
tier: rule.tier,
|
|
||||||
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,
|
||||||
@@ -162,7 +155,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
|
|
||||||
/** The staleness sweep: rules asserting a fact, oldest verification first. */
|
/** The staleness sweep: rules asserting a fact, oldest verification first. */
|
||||||
async function fetchRulesDue(opts: {
|
async function fetchRulesDue(opts: {
|
||||||
olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean;
|
olderThanDays?: number; neverOnly?: boolean;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
lastSweepOpts.value = opts;
|
lastSweepOpts.value = opts;
|
||||||
@@ -206,7 +199,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
return {
|
return {
|
||||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
|
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
|
||||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||||
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
createRulebook, updateRulebook, deleteRulebook,
|
||||||
createTopic, updateTopic, deleteTopic,
|
createTopic, updateTopic, deleteTopic,
|
||||||
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
||||||
fetchRulesDue, verifyRule,
|
fetchRulesDue, verifyRule,
|
||||||
|
|||||||
@@ -716,9 +716,6 @@ async function confirmDelete() {
|
|||||||
/>
|
/>
|
||||||
<p v-else-if="project.inception" class="inception-line">
|
<p v-else-if="project.inception" class="inception-line">
|
||||||
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
|
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
|
||||||
<template v-if="project.inception.choices.exclude_always_on_rulebooks.length">
|
|
||||||
· excludes {{ project.inception.choices.exclude_always_on_rulebooks.length }} always-on rulebook(s)
|
|
||||||
</template>
|
|
||||||
<template v-if="project.inception.choices.subscribe_rulebooks.length">
|
<template v-if="project.inception.choices.subscribe_rulebooks.length">
|
||||||
· subscribes {{ project.inception.choices.subscribe_rulebooks.length }}
|
· subscribes {{ project.inception.choices.subscribe_rulebooks.length }}
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -156,28 +156,10 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
|||||||
-H "Authorization: Bearer ${token}" \
|
-H "Authorization: Bearer ${token}" \
|
||||||
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
|
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
|
||||||
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
|
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
|
||||||
# Stash the rules marker for the write-path hook (milestone 323). THIS is
|
# The rules marker is gone with the resident set it described
|
||||||
# where it has to be captured: the model receives one from
|
# (milestone 394). Nothing is preloaded, so there is no set whose
|
||||||
# an MCP tool too, but a hook cannot see a tool's result. Stored
|
# drift a later write could be told about — a rule is retrieved at
|
||||||
# under the same state dir the prior-art hook already uses, keyed by session,
|
# the moment it applies, which cannot be stale.
|
||||||
# so "changed since" means since THIS session loaded its rules.
|
|
||||||
#
|
|
||||||
# Written on `compact` as well as `startup`, and that is correct rather than
|
|
||||||
# convenient: a compact tells the session to re-pull its rules, so the marker
|
|
||||||
# should describe the set it is about to hold. It is also why this cannot
|
|
||||||
# cover the compaction case — see the table in services/plugin_context.py.
|
|
||||||
if [ -n "$body" ]; then
|
|
||||||
etag=$(printf '%s' "$body" | jq -r '.rules_etag // empty' 2>/dev/null) || etag=""
|
|
||||||
if [ -n "$etag" ]; then
|
|
||||||
sid=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || sid=""
|
|
||||||
safe_sid=$(printf '%s' "${sid:-nosession}" | tr -c 'A-Za-z0-9._-' '_')
|
|
||||||
etag_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
|
||||||
# Best-effort throughout: a marker that cannot be stored costs a hint,
|
|
||||||
# never the session.
|
|
||||||
mkdir -p "$etag_dir" 2>/dev/null \
|
|
||||||
&& printf '%s' "$etag" > "$etag_dir/${safe_sid}.rules_etag" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
[ -z "$dyn" ] && status="> ⚠️ Scribe: live project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\` as needed."
|
[ -z "$dyn" ] && status="> ⚠️ Scribe: live project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\` as needed."
|
||||||
elif [ -n "$url" ] && [ -z "$token" ]; then
|
elif [ -n "$url" ] && [ -z "$token" ]; then
|
||||||
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`."
|
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`."
|
||||||
|
|||||||
@@ -256,18 +256,11 @@ project_rule_suppressions = Table(
|
|||||||
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
||||||
)
|
)
|
||||||
|
|
||||||
# A project's opt-out of a whole ALWAYS-ON rulebook (milestone 297): the
|
# `project_rulebook_exclusions` lived here until milestone 394. It recorded a
|
||||||
# sibling of the two suppression tables below, one level up. Always-on
|
# project's opt-out of a whole always-on rulebook — which only made sense
|
||||||
# rulebooks bind every project implicitly; an inception decision can exclude
|
# while a rulebook could bind a project WITHOUT being asked. Subscription is
|
||||||
# specific ones for this project, and get_applicable_rules /
|
# now the only reach a rulebook has, so declining one is expressed by not
|
||||||
# get_applicable_rules(project_id) skips them. FKs CASCADE like the others.
|
# subscribing, and there is nothing left to opt out of.
|
||||||
project_rulebook_exclusions = Table(
|
|
||||||
"project_rulebook_exclusions",
|
|
||||||
Base.metadata,
|
|
||||||
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
|
||||||
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
|
|
||||||
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
|
||||||
)
|
|
||||||
|
|
||||||
project_topic_suppressions = Table(
|
project_topic_suppressions = Table(
|
||||||
"project_topic_suppressions",
|
"project_topic_suppressions",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from scribe.models.rulebook import (
|
|||||||
Rulebook,
|
Rulebook,
|
||||||
RulebookTopic,
|
RulebookTopic,
|
||||||
project_rule_suppressions,
|
project_rule_suppressions,
|
||||||
project_rulebook_exclusions,
|
|
||||||
project_rulebook_subscriptions,
|
project_rulebook_subscriptions,
|
||||||
project_topic_suppressions,
|
project_topic_suppressions,
|
||||||
)
|
)
|
||||||
@@ -82,7 +81,7 @@ _BACKED_UP = [
|
|||||||
"users", "projects", "milestones", "notes", "task_logs", "note_drafts",
|
"users", "projects", "milestones", "notes", "task_logs", "note_drafts",
|
||||||
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
|
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
|
||||||
"project_rulebook_subscriptions", "project_rule_suppressions",
|
"project_rulebook_subscriptions", "project_rule_suppressions",
|
||||||
"project_topic_suppressions", "project_rulebook_exclusions",
|
"project_topic_suppressions",
|
||||||
# v5 (2026-08): the five-year gap this list was written to stop.
|
# v5 (2026-08): the five-year gap this list was written to stop.
|
||||||
"systems", "record_systems", "design_systems", "design_tokens",
|
"systems", "record_systems", "design_systems", "design_tokens",
|
||||||
"note_usage_events", "repo_bindings", "note_supersessions",
|
"note_usage_events", "repo_bindings", "note_supersessions",
|
||||||
@@ -247,8 +246,6 @@ def _topic_suppression_rows(rows) -> list[dict]:
|
|||||||
return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows]
|
return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def _rulebook_exclusion_rows(rows) -> list[dict]:
|
|
||||||
return [{"project_id": r.project_id, "rulebook_id": r.rulebook_id} for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
# The v5 sections. Pure row-builders like the join-table helpers above, for the
|
# The v5 sections. Pure row-builders like the join-table helpers above, for the
|
||||||
@@ -652,9 +649,6 @@ async def export_full_backup() -> dict:
|
|||||||
topic_suppressions = (await session.execute(
|
topic_suppressions = (await session.execute(
|
||||||
select(project_topic_suppressions)
|
select(project_topic_suppressions)
|
||||||
)).all()
|
)).all()
|
||||||
rulebook_exclusions = (await session.execute(
|
|
||||||
select(project_rulebook_exclusions)
|
|
||||||
)).all()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": BACKUP_VERSION,
|
"version": BACKUP_VERSION,
|
||||||
@@ -680,7 +674,6 @@ async def export_full_backup() -> dict:
|
|||||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||||
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
|
|
||||||
"canonical_systems": _canonical_system_rows(canonical_systems),
|
"canonical_systems": _canonical_system_rows(canonical_systems),
|
||||||
"rule_systems": _rule_system_rows(rule_system_rows),
|
"rule_systems": _rule_system_rows(rule_system_rows),
|
||||||
"rule_relations": _rule_relation_rows(rule_relations),
|
"rule_relations": _rule_relation_rows(rule_relations),
|
||||||
@@ -848,13 +841,8 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
project_topic_suppressions.c.project_id.in_(project_ids)
|
project_topic_suppressions.c.project_id.in_(project_ids)
|
||||||
)
|
)
|
||||||
)).all()
|
)).all()
|
||||||
rulebook_exclusions = (await session.execute(
|
|
||||||
select(project_rulebook_exclusions).where(
|
|
||||||
project_rulebook_exclusions.c.project_id.in_(project_ids)
|
|
||||||
)
|
|
||||||
)).all()
|
|
||||||
else:
|
else:
|
||||||
subscriptions = rule_suppressions = topic_suppressions = rulebook_exclusions = []
|
subscriptions = rule_suppressions = topic_suppressions = []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": BACKUP_VERSION,
|
"version": BACKUP_VERSION,
|
||||||
@@ -882,7 +870,6 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||||
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
|
|
||||||
"canonical_systems": _canonical_system_rows(canonical_systems),
|
"canonical_systems": _canonical_system_rows(canonical_systems),
|
||||||
"rule_systems": _rule_system_rows(rule_system_rows),
|
"rule_systems": _rule_system_rows(rule_system_rows),
|
||||||
"rule_relations": _rule_relation_rows(rule_relations),
|
"rule_relations": _rule_relation_rows(rule_relations),
|
||||||
@@ -1028,7 +1015,7 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
"task_logs": 0, "note_drafts": 0, "note_versions": 0,
|
"task_logs": 0, "note_drafts": 0, "note_versions": 0,
|
||||||
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
|
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
|
||||||
"rulebook_subscriptions": 0, "rule_suppressions": 0,
|
"rulebook_subscriptions": 0, "rule_suppressions": 0,
|
||||||
"topic_suppressions": 0, "rulebook_exclusions": 0,
|
"topic_suppressions": 0,
|
||||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||||
"design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0,
|
"design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0,
|
||||||
"repo_bindings": 0,
|
"repo_bindings": 0,
|
||||||
@@ -1347,15 +1334,11 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
stats["topic_suppressions"] += 1
|
stats["topic_suppressions"] += 1
|
||||||
|
|
||||||
# 14b. Always-on rulebook exclusions (v10, milestone 297)
|
# 14b. Always-on rulebook exclusions (v10, milestone 297)
|
||||||
for exc in data.get("rulebook_exclusions", []):
|
# `rulebook_exclusions` was a v10 section and is READ BY NOBODY since
|
||||||
mapped_pid = project_id_map.get(exc.get("project_id", 0))
|
# milestone 394 removed the table. An archive carrying it still
|
||||||
mapped_rbid = rulebook_id_map.get(exc.get("rulebook_id", 0))
|
# imports — the key is simply not looked at — because refusing a
|
||||||
if mapped_pid is None or mapped_rbid is None:
|
# backup for remembering something we deleted would make every v10-v13
|
||||||
continue
|
# archive unrestorable.
|
||||||
await session.execute(project_rulebook_exclusions.insert().values(
|
|
||||||
project_id=mapped_pid, rulebook_id=mapped_rbid,
|
|
||||||
))
|
|
||||||
stats["rulebook_exclusions"] += 1
|
|
||||||
|
|
||||||
# --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4
|
# --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4
|
||||||
# payload restores without them rather than failing on an absent key.
|
# payload restores without them rather than failing on an absent key.
|
||||||
@@ -1680,10 +1663,12 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
inception = p_data.get("inception")
|
inception = p_data.get("inception")
|
||||||
if isinstance(inception, dict):
|
if isinstance(inception, dict):
|
||||||
choices = dict(inception.get("choices") or {})
|
choices = dict(inception.get("choices") or {})
|
||||||
choices["exclude_always_on_rulebooks"] = [
|
# DROPPED, not remapped (milestone 394). A pre-394 archive
|
||||||
rulebook_id_map[i] for i in choices.get("exclude_always_on_rulebooks") or []
|
# carries the retired exclusion choice; restoring it would put
|
||||||
if i in rulebook_id_map
|
# a key back that `validate_inception` now rejects as unknown,
|
||||||
]
|
# so the next edit to that project would fail on data this
|
||||||
|
# importer wrote.
|
||||||
|
choices.pop("exclude_always_on_rulebooks", None)
|
||||||
choices["subscribe_rulebooks"] = [
|
choices["subscribe_rulebooks"] = [
|
||||||
rulebook_id_map[i] for i in choices.get("subscribe_rulebooks") or []
|
rulebook_id_map[i] for i in choices.get("subscribe_rulebooks") or []
|
||||||
if i in rulebook_id_map
|
if i in rulebook_id_map
|
||||||
|
|||||||
@@ -2142,18 +2142,6 @@ def _stamp_line(path: str, stamped: list[dict]) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _topic_titles(topic_ids: set[int]) -> dict[int, str]:
|
|
||||||
"""Map topic_id -> title for the given ids (live topics only)."""
|
|
||||||
if not topic_ids:
|
|
||||||
return {}
|
|
||||||
async with async_session() as session:
|
|
||||||
rows = await session.execute(
|
|
||||||
select(RulebookTopic.id, RulebookTopic.title).where(
|
|
||||||
RulebookTopic.id.in_(topic_ids),
|
|
||||||
RulebookTopic.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return {tid: title for tid, title in rows.all()}
|
|
||||||
|
|
||||||
|
|
||||||
async def build_session_context(
|
async def build_session_context(
|
||||||
|
|||||||
+1
-1
@@ -225,7 +225,7 @@ def fake_rule(**attrs) -> MagicMock:
|
|||||||
# Named for the note-2109 reason the whole helper exists: unnamed,
|
# Named for the note-2109 reason the whole helper exists: unnamed,
|
||||||
# `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, "arose_from_id": None,
|
||||||
# Named for the same reason one line up, and it bites harder here.
|
# Named for the same reason one line up, and it bites harder here.
|
||||||
# `rule_brief` and `to_dict` both emit `kind or "rule"`, and a
|
# `rule_brief` and `to_dict` both emit `kind or "rule"`, and a
|
||||||
# MagicMock is truthy — so an unnamed `kind` would put a MagicMock
|
# MagicMock is truthy — so an unnamed `kind` would put a MagicMock
|
||||||
|
|||||||
+11
-8
@@ -32,28 +32,31 @@ def test_exclusions_table_is_the_suppressions_sibling():
|
|||||||
|
|
||||||
|
|
||||||
def test_validate_inception_pins_the_choice_vocabulary():
|
def test_validate_inception_pins_the_choice_vocabulary():
|
||||||
assert CHOICE_KEYS == ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems")
|
assert CHOICE_KEYS == ("subscribe_rulebooks", "design_system_id", "seed_systems")
|
||||||
assert validate_inception({}) is None
|
assert validate_inception({}) is None
|
||||||
assert validate_inception({"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [2],
|
assert validate_inception({"subscribe_rulebooks": [2],
|
||||||
"design_system_id": 3, "seed_systems": True}) is None
|
"design_system_id": 3, "seed_systems": True}) is None
|
||||||
assert validate_inception({"design_system_id": None}) is None
|
assert validate_inception({"design_system_id": None}) is None
|
||||||
assert "must be an object" in validate_inception([])
|
assert "must be an object" in validate_inception([])
|
||||||
assert "unknown inception choice" in validate_inception({"repo": "x"})
|
assert "unknown inception choice" in validate_inception({"repo": "x"})
|
||||||
assert "list of rulebook ids" in validate_inception({"exclude_always_on_rulebooks": "1"})
|
# The retired exclusion key is now an UNKNOWN key rather than a typed one,
|
||||||
|
# which is the right error: a caller still passing it is asking for a
|
||||||
|
# choice that no longer exists, and silently ignoring it would let them
|
||||||
|
# believe a rulebook had been declined.
|
||||||
|
assert "unknown inception choice" in validate_inception(
|
||||||
|
{"exclude_always_on_rulebooks": [1]})
|
||||||
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [0]})
|
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [0]})
|
||||||
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [True]})
|
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [True]})
|
||||||
assert "both excluded and subscribed" in validate_inception(
|
|
||||||
{"exclude_always_on_rulebooks": [1, 2], "subscribe_rulebooks": [2]})
|
|
||||||
assert "positive id or null" in validate_inception({"design_system_id": 0})
|
assert "positive id or null" in validate_inception({"design_system_id": 0})
|
||||||
assert "positive id or null" in validate_inception({"design_system_id": True})
|
assert "positive id or null" in validate_inception({"design_system_id": True})
|
||||||
assert "true or false" in validate_inception({"seed_systems": "yes"})
|
assert "true or false" in validate_inception({"seed_systems": "yes"})
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_choices_is_canonical_and_complete():
|
def test_normalize_choices_is_canonical_and_complete():
|
||||||
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3], "exclude_always_on_rulebooks": [2]})
|
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3]})
|
||||||
assert out == {"exclude_always_on_rulebooks": [2], "subscribe_rulebooks": [1, 3],
|
assert out == {"subscribe_rulebooks": [1, 3],
|
||||||
"design_system_id": None, "seed_systems": False}
|
"design_system_id": None, "seed_systems": False}
|
||||||
assert normalize_choices(None) == {"exclude_always_on_rulebooks": [], "subscribe_rulebooks": [],
|
assert normalize_choices(None) == {"subscribe_rulebooks": [],
|
||||||
"design_system_id": None, "seed_systems": False}
|
"design_system_id": None, "seed_systems": False}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
"""Milestone 297 step 2 — always-on exclusions reach every rule surface.
|
|
||||||
|
|
||||||
The SQL is the integration lane's; here the contracts: rules_payload carries
|
|
||||||
the seventh key, list_always_on_rules takes project_id, the session-start
|
|
||||||
block names the excluded rulebooks, and the MCP tools mount.
|
|
||||||
"""
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from scribe.services.rulebooks import rules_payload
|
|
||||||
|
|
||||||
|
|
||||||
def test_rules_payload_carries_excluded_always_on_as_the_seventh_key():
|
|
||||||
out = rules_payload({
|
|
||||||
"rules": [], "truncated": False, "subscribed_rulebooks": [],
|
|
||||||
"excluded_always_on": [{"id": 1, "title": "Family"}],
|
|
||||||
}, user_id=1, source="enter_project")
|
|
||||||
assert set(out) == {
|
|
||||||
"applicable_rules", "applicable_rules_truncated", "subscribed_rulebooks",
|
|
||||||
"project_rules", "suppressed_rules", "suppressed_topics", "excluded_always_on",
|
|
||||||
}
|
|
||||||
assert out["excluded_always_on"] == [{"id": 1, "title": "Family"}]
|
|
||||||
# An older applicable dict without the key still renders (empty list).
|
|
||||||
assert rules_payload(
|
|
||||||
{"rules": [], "truncated": False, "subscribed_rulebooks": []},
|
|
||||||
user_id=1, source="enter_project",
|
|
||||||
)["excluded_always_on"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_always_on_rules_service_and_tool_take_a_project_id():
|
|
||||||
import inspect
|
|
||||||
|
|
||||||
from scribe.mcp.tools import rulebooks as tools
|
|
||||||
from scribe.services import rulebooks as svc
|
|
||||||
assert "project_id" in inspect.signature(svc.list_always_on_rules).parameters
|
|
||||||
assert "project_id" in inspect.signature(tools.list_always_on_rules).parameters
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_session_context_names_the_excluded_always_on_rulebooks():
|
|
||||||
from types import SimpleNamespace as NS
|
|
||||||
|
|
||||||
from scribe.services.plugin_context import build_session_context
|
|
||||||
rules = [NS(id=1, title="`dev` is home", topic_id=1, statement="x")]
|
|
||||||
project = NS(id=9, title="Widget", goal="", design_system_id=None)
|
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
|
||||||
AsyncMock(return_value=rules)) as lao, \
|
|
||||||
patch("scribe.services.plugin_context.rulebooks_svc.excluded_always_on_rulebooks",
|
|
||||||
AsyncMock(return_value=[{"id": 5, "title": "Design standards"}])), \
|
|
||||||
patch("scribe.services.plugin_context._topic_titles", AsyncMock(return_value={1: "git"})), \
|
|
||||||
patch("scribe.services.plugin_context.projects_svc.get_project", AsyncMock(return_value=project)), \
|
|
||||||
patch("scribe.services.plugin_context.notes_svc.list_notes", AsyncMock(return_value=([], 0))), \
|
|
||||||
patch("scribe.services.plugin_context.rulebooks_svc.get_applicable_rules",
|
|
||||||
AsyncMock(return_value={"rules": [], "truncated": False, "subscribed_rulebooks": [],
|
|
||||||
"project_rules": [], "suppressed_rules": [],
|
|
||||||
"suppressed_topics": [], "excluded_always_on": []})):
|
|
||||||
out = await build_session_context(user_id=7, project_id=9)
|
|
||||||
# The always-on set was asked FOR THIS PROJECT, and the departure is named.
|
|
||||||
assert lao.await_args.kwargs.get("project_id") == 9
|
|
||||||
assert "Excluded for this project by its inception decision" in out["context"]
|
|
||||||
assert "Design standards (#5)" in out["context"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_exclusion_routes_are_registered():
|
|
||||||
from scribe.app import create_app
|
|
||||||
rules = {r.rule for r in create_app().url_map.iter_rules()}
|
|
||||||
assert "/api/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>" in rules
|
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""The instruction surfaces must agree that the agent pulls the rules itself.
|
"""The instruction surfaces must agree on how a rule reaches a session.
|
||||||
|
|
||||||
WHY THIS EXISTS
|
WHY THIS EXISTS
|
||||||
|
|
||||||
@@ -17,16 +17,23 @@ an extended period, and nothing announced it. An agent trusting the push would
|
|||||||
have run with no binding rules and no signal — while those rules govern branch,
|
have run with no binding rules and no signal — while those rules govern branch,
|
||||||
commit, push and other hard-to-reverse actions.
|
commit, push and other hard-to-reverse actions.
|
||||||
|
|
||||||
The asymmetry is the whole argument, and it is what these tests pin: pulling
|
The asymmetry is the whole argument, and it is what these tests pin: asking
|
||||||
when a push also arrived costs one redundant call; not pulling when the push
|
when a rule had already arrived costs one redundant call; not asking when
|
||||||
never came costs the operator's rules entirely.
|
nothing arrived costs the operator's rules entirely.
|
||||||
|
|
||||||
|
MILESTONE 394 SHARPENED IT RATHER THAN RETIRING IT. There is no longer a
|
||||||
|
resident set to pull, so "no rule in front of me" went from a rare and
|
||||||
|
suspicious state to the ordinary state of most turns. The instruction that
|
||||||
|
used to be supplementary — go and ask — is now the only route a rule has, and
|
||||||
|
the surfaces must additionally say what an EMPTY session means, or a session
|
||||||
|
reads silence as permission on nearly every turn.
|
||||||
|
|
||||||
WHAT THIS DOES NOT DO
|
WHAT THIS DOES NOT DO
|
||||||
|
|
||||||
It cannot tell whether two surfaces contradict each other in prose generally —
|
It cannot tell whether two surfaces contradict each other in prose generally —
|
||||||
that needs a reader. It pins the one instruction whose absence is known to be
|
that needs a reader. It pins the instructions whose absence is known to be
|
||||||
load-bearing, and the specific shape the #2497 defect took: naming the push
|
load-bearing, and the specific shape the #2497 defect took: naming the push
|
||||||
without also stating the pull.
|
without also stating how to ask.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -34,8 +41,33 @@ import pathlib
|
|||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
# The pull instruction, however a surface phrases the surrounding prose.
|
# THE PULL IS NOW THE ASK (milestone 394). This was `list_always_on_rules`,
|
||||||
PULL = "list_always_on_rules"
|
# the call that fetched the resident set. There is no resident set and no such
|
||||||
|
# call: a rule reaches a session by retrieval, and the only thing a session can
|
||||||
|
# DO about a rule it has not been handed is go looking for one.
|
||||||
|
#
|
||||||
|
# So the two halves this file used to pin separately — "pull the resident set"
|
||||||
|
# and "and retrieve the conditional ones too" — have collapsed into one
|
||||||
|
# instruction, and it is the load-bearing one rather than the supplementary
|
||||||
|
# one it used to be.
|
||||||
|
ASK = 'content_type="rule"'
|
||||||
|
|
||||||
|
# A surface must also say what an EMPTY session means, which is the half that
|
||||||
|
# is newly dangerous. Under residency, "no rule in front of me" was rare and
|
||||||
|
# suspicious. Under retrieval it is the ordinary state of most turns, so a
|
||||||
|
# session that reads it as "there is no rule" is wrong on nearly every turn
|
||||||
|
# rather than occasionally — the #3720 defect at session scale.
|
||||||
|
#
|
||||||
|
# Claim phrases, not a single word, for the reason BINDING_CLAIMS gives below:
|
||||||
|
# a bare "matched" or "silence" appears in prose that is not making this claim
|
||||||
|
# at all. A surface passes by asserting the distinction however it words it.
|
||||||
|
ABSENCE_CLAIMS = (
|
||||||
|
"nothing matched",
|
||||||
|
"is not the same as \"there is no rule",
|
||||||
|
"never \"there is no rule",
|
||||||
|
"silence is not absence",
|
||||||
|
"not evidence there is none",
|
||||||
|
)
|
||||||
|
|
||||||
# Surfaces a session loads before substantive work. Hand-written because
|
# Surfaces a session loads before substantive work. Hand-written because
|
||||||
# "is this a session-start surface?" is an editorial fact, not a derivable one —
|
# "is this a session-start surface?" is an editorial fact, not a derivable one —
|
||||||
@@ -67,21 +99,51 @@ def _all_surfaces() -> list[tuple[str, str]]:
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
def test_every_session_start_surface_states_the_pull():
|
def test_every_session_start_surface_states_the_ask():
|
||||||
|
"""Retrieval is the only delivery, so asking is the only recourse."""
|
||||||
missing = []
|
missing = []
|
||||||
for path in SESSION_START_SURFACES:
|
for path in SESSION_START_SURFACES:
|
||||||
assert path.exists(), (
|
assert path.exists(), (
|
||||||
f"{path.relative_to(ROOT)} is gone — it was one of the surfaces "
|
f"{path.relative_to(ROOT)} is gone — it was one of the surfaces "
|
||||||
f"carrying the load-the-rules instruction. If it moved, update "
|
f"carrying the rules instruction. If it moved, update "
|
||||||
f"SESSION_START_SURFACES; if it was retired, check the instruction "
|
f"SESSION_START_SURFACES; if it was retired, check the instruction "
|
||||||
f"still lives somewhere a fresh session reads."
|
f"still lives somewhere a fresh session reads."
|
||||||
)
|
)
|
||||||
if PULL not in path.read_text():
|
if ASK not in path.read_text():
|
||||||
missing.append(str(path.relative_to(ROOT)))
|
missing.append(str(path.relative_to(ROOT)))
|
||||||
assert not missing, (
|
assert not missing, (
|
||||||
f"these surfaces no longer tell the agent to call {PULL}(): {missing}. "
|
f"these surfaces never tell the agent how to ask for a rule "
|
||||||
f"The rules are pull-only and the push is best-effort, so a surface "
|
f"({ASK}): {missing}. Nothing is pushed and nothing is resident, so a "
|
||||||
f"that omits this leaves a session bound by nothing (#2198, #2497)."
|
f"surface that omits this leaves a session with no way to reach a rule "
|
||||||
|
f"it was not handed — bound by nothing (#2198, #2497, milestone 394)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_session_start_surface_says_an_empty_session_is_not_an_empty_rulebook():
|
||||||
|
"""The half that got dangerous when residency went away.
|
||||||
|
|
||||||
|
Under the old model a session opened holding every applicable rule, so
|
||||||
|
"nothing is in front of me" was a rare state and a suspicious one. Under
|
||||||
|
retrieval it is the NORMAL state of most turns. A surface that describes
|
||||||
|
where rules come from, without also saying what their absence means, leaves
|
||||||
|
a session reading silence as permission — on nearly every turn rather than
|
||||||
|
occasionally.
|
||||||
|
|
||||||
|
That is #3720's defect ("absence reads as non-existence") moved from a
|
||||||
|
readout to the session itself, and this milestone is what makes every
|
||||||
|
session start in the absent state.
|
||||||
|
"""
|
||||||
|
missing = []
|
||||||
|
for path in SESSION_START_SURFACES:
|
||||||
|
text = path.read_text().lower()
|
||||||
|
if not any(c.lower() in text for c in ABSENCE_CLAIMS):
|
||||||
|
missing.append(str(path.relative_to(ROOT)))
|
||||||
|
assert not missing, (
|
||||||
|
f"these surfaces say how a rule arrives but never what it means when "
|
||||||
|
f"none does: {missing}. 'No rule arrived' means 'nothing matched', "
|
||||||
|
f"never 'there is no rule' — and only one of those has been checked. "
|
||||||
|
f"Say it however you like; one of {ABSENCE_CLAIMS} is what this looks "
|
||||||
|
f"for."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -195,47 +257,7 @@ def test_displaced_topics_live_on_a_delivered_surface():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# The SECOND pull (milestone 333 step 3). `list_always_on_rules()` fetches the
|
def test_no_surface_names_the_push_without_stating_the_ask():
|
||||||
# resident tier; this one says that tier is not all of them, and that a
|
|
||||||
# conditional rule has to be gone looking for. However a surface words the
|
|
||||||
# surrounding prose, it names the call.
|
|
||||||
RETRIEVE = 'content_type="rule"'
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_session_start_surface_states_the_conditional_retrieval():
|
|
||||||
"""The push/pull asymmetry, one level in.
|
|
||||||
|
|
||||||
The tests above pin that a session PULLS the resident rules rather than
|
|
||||||
trusting the SessionStart push. This pins the same shape between the two
|
|
||||||
TIERS: an always-on rule is delivered, a conditional one is retrieved, and
|
|
||||||
a surface that states only the first leaves a session reading its loaded
|
|
||||||
set as the whole rulebook.
|
|
||||||
|
|
||||||
That reading is wrong in the direction that costs something. "Nothing was
|
|
||||||
pushed" and "no rule applies" are different claims, and only one of them
|
|
||||||
has been checked — the same asymmetry as #2198, now between tiers instead
|
|
||||||
of between channels.
|
|
||||||
|
|
||||||
It is also what made the always-on tier the only one that worked, on any
|
|
||||||
install rather than this one (rule 115). A rule nothing retrieves has to be
|
|
||||||
resident to bind at all, so every rule worth keeping becomes resident; and
|
|
||||||
a resident rule costs tokens in every session forever, so a rulebook that
|
|
||||||
only delivers cannot grow past what one session can hold. Retrieval is what
|
|
||||||
lifts that ceiling — and it only fires if something asks.
|
|
||||||
"""
|
|
||||||
missing = []
|
|
||||||
for path in SESSION_START_SURFACES:
|
|
||||||
if RETRIEVE not in path.read_text():
|
|
||||||
missing.append(str(path.relative_to(ROOT)))
|
|
||||||
assert not missing, (
|
|
||||||
f"these surfaces state the always-on pull but never tell the agent to "
|
|
||||||
f"retrieve a conditional rule ({RETRIEVE}): {missing}. A session that "
|
|
||||||
f"reads its loaded set as the whole rulebook will act on \"I was not "
|
|
||||||
f"told\" as if it meant \"there is no rule\" (milestone 333 step 3)."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_surface_names_the_push_without_stating_the_pull():
|
|
||||||
"""The exact shape #2497 took.
|
"""The exact shape #2497 took.
|
||||||
|
|
||||||
Mentioning the SessionStart hook is fine and often useful. Mentioning it
|
Mentioning the SessionStart hook is fine and often useful. Mentioning it
|
||||||
@@ -244,13 +266,14 @@ def test_no_surface_names_the_push_without_stating_the_pull():
|
|||||||
"""
|
"""
|
||||||
offenders = [
|
offenders = [
|
||||||
label for label, text in _all_surfaces()
|
label for label, text in _all_surfaces()
|
||||||
if "SessionStart" in text and PULL not in text
|
if "SessionStart" in text and ASK not in text
|
||||||
]
|
]
|
||||||
assert not offenders, (
|
assert not offenders, (
|
||||||
f"these surfaces describe the SessionStart push but never state the "
|
f"these surfaces describe the SessionStart push but never state how to "
|
||||||
f"explicit pull: {offenders}. The push is a delivery optimisation, not "
|
f"ask: {offenders}. The push is a delivery optimisation, not the "
|
||||||
f"the bridge — it can be absent without saying so. Name it if it helps, "
|
f"bridge — it can be absent without saying so, and since milestone 394 "
|
||||||
f"but say to call {PULL}() regardless."
|
f"it carries no rules at all. Name it if it helps, but say how to ask "
|
||||||
|
f"({ASK}) regardless."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -117,14 +117,12 @@ async def source():
|
|||||||
statement="Use sh.",
|
statement="Use sh.",
|
||||||
why="the image ships no bash",
|
why="the image ships no bash",
|
||||||
verify_with="read the workflow's shell setting",
|
verify_with="read the workflow's shell setting",
|
||||||
tier="always_on",
|
|
||||||
),
|
),
|
||||||
# The actor is already gone — what SET NULL leaves behind.
|
# The actor is already gone — what SET NULL leaves behind.
|
||||||
RuleVersion(
|
RuleVersion(
|
||||||
rule_id=rule.id, user_id=None,
|
rule_id=rule.id, user_id=None,
|
||||||
title="The runner has no bash",
|
title="The runner has no bash",
|
||||||
statement="Use POSIX sh in run steps.",
|
statement="Use POSIX sh in run steps.",
|
||||||
tier="always_on",
|
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
await s.commit()
|
await s.commit()
|
||||||
@@ -263,4 +261,3 @@ async def test_the_text_survives(restored):
|
|||||||
assert by_statement["Use sh."].verify_with == (
|
assert by_statement["Use sh."].verify_with == (
|
||||||
"read the workflow's shell setting"
|
"read the workflow's shell setting"
|
||||||
)
|
)
|
||||||
assert by_statement["Use sh."].tier == "always_on"
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import pytest_asyncio
|
|||||||
|
|
||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
from scribe.models.project import Project
|
from scribe.models.project import Project
|
||||||
from scribe.models.rulebook import Rulebook
|
|
||||||
from scribe.services import inception as inception_svc
|
from scribe.services import inception as inception_svc
|
||||||
from scribe.services import rulebooks as rulebooks_svc
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
from scribe.services import canonical_systems as canonical_svc
|
from scribe.services import canonical_systems as canonical_svc
|
||||||
@@ -31,12 +30,12 @@ async def seeded():
|
|||||||
await s.flush()
|
await s.flush()
|
||||||
ids = {"owner": owner.id, "pid": project.id}
|
ids = {"owner": owner.id, "pid": project.id}
|
||||||
await s.commit()
|
await s.commit()
|
||||||
|
# Two ordinary rulebooks. One was flagged always-on until milestone 394
|
||||||
|
# removed the tier; a rulebook now reaches a project only by subscription,
|
||||||
|
# so what used to be "binds automatically" and "binds if you opt in" are
|
||||||
|
# the same kind of thing.
|
||||||
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
||||||
other = await rulebooks_svc.create_rulebook(ids["owner"], "Optional practices")
|
other = await rulebooks_svc.create_rulebook(ids["owner"], "Optional practices")
|
||||||
async with async_session() as s:
|
|
||||||
rb = await s.get(Rulebook, always.id)
|
|
||||||
rb.always_on = True
|
|
||||||
await s.commit()
|
|
||||||
t1 = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
|
t1 = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
|
||||||
await rulebooks_svc.create_rule(t1.id, ids["owner"], "dev is home", "Work on dev.")
|
await rulebooks_svc.create_rule(t1.id, ids["owner"], "dev is home", "Work on dev.")
|
||||||
t2 = await rulebooks_svc.create_topic(other.id, ids["owner"], "docs")
|
t2 = await rulebooks_svc.create_topic(other.id, ids["owner"], "docs")
|
||||||
@@ -48,20 +47,20 @@ async def seeded():
|
|||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_decide_applies_every_effect_and_records_last(seeded):
|
async def test_decide_applies_every_effect_and_records_last(seeded):
|
||||||
owner, pid = seeded["owner"], seeded["pid"]
|
owner, pid = seeded["owner"], seeded["pid"]
|
||||||
# Undecided: the always-on rulebook binds, nothing subscribed, no Systems.
|
# Undecided: nothing binds, because nothing has been subscribed. Before
|
||||||
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
|
# milestone 394 the always-on rulebook bound here without being asked for,
|
||||||
|
# and this assertion read the other way round.
|
||||||
defaults = await inception_svc.current_defaults(owner, pid)
|
defaults = await inception_svc.current_defaults(owner, pid)
|
||||||
assert [r["id"] for r in defaults["always_on_rulebooks"]] == [seeded["always"]]
|
assert sorted(r["id"] for r in defaults["rulebooks"]) == sorted(
|
||||||
assert [r["id"] for r in defaults["other_rulebooks"]] == [seeded["other"]]
|
[seeded["always"], seeded["other"]])
|
||||||
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
|
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
|
||||||
|
assert (await rulebooks_svc.get_applicable_rules(pid, owner))["rules"] == []
|
||||||
|
|
||||||
out = await inception_svc.decide(owner, pid, via="mcp", choices={
|
out = await inception_svc.decide(owner, pid, via="mcp", choices={
|
||||||
"exclude_always_on_rulebooks": [seeded["always"]],
|
|
||||||
"subscribe_rulebooks": [seeded["other"]],
|
"subscribe_rulebooks": [seeded["other"]],
|
||||||
"design_system_id": None,
|
"design_system_id": None,
|
||||||
"seed_systems": True,
|
"seed_systems": True,
|
||||||
})
|
})
|
||||||
assert out["effects"]["excluded"] == [seeded["always"]]
|
|
||||||
assert out["effects"]["subscribed"] == [seeded["other"]]
|
assert out["effects"]["subscribed"] == [seeded["other"]]
|
||||||
catalog = await canonical_svc.list_canonical_systems()
|
catalog = await canonical_svc.list_canonical_systems()
|
||||||
assert len(out["effects"]["systems_seeded"]) == len(catalog)
|
assert len(out["effects"]["systems_seeded"]) == len(catalog)
|
||||||
@@ -69,35 +68,32 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
|
|||||||
seeded_systems = await systems_svc.list_systems(owner, pid)
|
seeded_systems = await systems_svc.list_systems(owner, pid)
|
||||||
assert all(s.canonical_id is not None for s in seeded_systems)
|
assert all(s.canonical_id is not None for s in seeded_systems)
|
||||||
|
|
||||||
# The exclusion is total: the project's always-on set is empty, the
|
# The subscription is what binds, and it is the ONLY thing that does —
|
||||||
# departure is named, the subscription binds.
|
# the unsubscribed rulebook contributes nothing even though it used to
|
||||||
assert await rulebooks_svc.list_always_on_rules(owner, project_id=pid) == []
|
# bind every project by default.
|
||||||
assert len(await rulebooks_svc.list_always_on_rules(owner)) == 1 # user-wide unchanged
|
|
||||||
applicable = await rulebooks_svc.get_applicable_rules(pid, owner)
|
applicable = await rulebooks_svc.get_applicable_rules(pid, owner)
|
||||||
assert [r["title"] for r in applicable["rules"]] == ["Write the why"]
|
|
||||||
assert [e["id"] for e in applicable["excluded_always_on"]] == [seeded["always"]]
|
|
||||||
assert [s["id"] for s in applicable["subscribed_rulebooks"]] == [seeded["other"]]
|
assert [s["id"] for s in applicable["subscribed_rulebooks"]] == [seeded["other"]]
|
||||||
|
assert "dev is home" not in [r["title"] for r in applicable["rules"]]
|
||||||
# The record, written last, says why.
|
# The record, written last, says why.
|
||||||
async with async_session() as s:
|
async with async_session() as s:
|
||||||
project = await s.get(Project, pid)
|
project = await s.get(Project, pid)
|
||||||
assert inception_svc.is_decided(project)
|
assert inception_svc.is_decided(project)
|
||||||
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
|
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
|
||||||
assert project.inception["choices"]["exclude_always_on_rulebooks"] == [seeded["always"]]
|
assert project.inception["choices"]["subscribe_rulebooks"] == [seeded["other"]]
|
||||||
# Re-deciding with seed again mints nothing twice; include reverses the exclusion.
|
# Re-deciding with seed again mints nothing twice.
|
||||||
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
|
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
|
||||||
assert again["effects"]["systems_seeded"] == []
|
assert again["effects"]["systems_seeded"] == []
|
||||||
assert len(await systems_svc.list_systems(owner, pid)) == len(catalog)
|
assert len(await systems_svc.list_systems(owner, pid)) == len(catalog)
|
||||||
await rulebooks_svc.include_always_on_rulebook_for_project(pid, seeded["always"], owner)
|
|
||||||
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_a_bad_decision_applies_nothing(seeded):
|
async def test_a_bad_decision_applies_nothing(seeded):
|
||||||
owner, pid = seeded["owner"], seeded["pid"]
|
owner, pid = seeded["owner"], seeded["pid"]
|
||||||
# Excluding a rulebook that is not always-on is refused BEFORE any effect.
|
# A subscription to a rulebook that is not yours is refused BEFORE any
|
||||||
with pytest.raises(ValueError, match="not always-on"):
|
# effect lands — the seed must not happen on a decision that fails.
|
||||||
|
with pytest.raises(ValueError, match="not found"):
|
||||||
await inception_svc.decide(owner, pid, via="mcp", choices={
|
await inception_svc.decide(owner, pid, via="mcp", choices={
|
||||||
"exclude_always_on_rulebooks": [seeded["other"]], "seed_systems": True,
|
"subscribe_rulebooks": [999999], "seed_systems": True,
|
||||||
})
|
})
|
||||||
assert await systems_svc.list_systems(owner, pid) == []
|
assert await systems_svc.list_systems(owner, pid) == []
|
||||||
with pytest.raises(ValueError, match="not found"):
|
with pytest.raises(ValueError, match="not found"):
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
"""Real-Postgres tests for WHICH rules reach a session (milestone 307 step 5).
|
"""Real-Postgres tests for WHICH rules reach a session (milestone 307 step 5,
|
||||||
|
narrowed by 394).
|
||||||
|
|
||||||
What mocks can't prove, and what this milestone must not get wrong:
|
What mocks can't prove, and what this design must not get wrong:
|
||||||
|
|
||||||
1. **Nothing stops binding.** A rule with no tier, no areas and no edges
|
1. A rule is invisible to a project that doesn't work in its area, and
|
||||||
behaves exactly as it did before tiers existed. That is the one failure this
|
arrives — binding, not suggested — to one that does. Area matching is
|
||||||
whole design must not produce, and it is asserted first.
|
DETERMINISTIC: a tag comparison, never a similarity score.
|
||||||
2. A conditional rule is invisible to a project that doesn't work in its area,
|
2. A `co_surfaces` partner arrives with its other half, which is the failure
|
||||||
and arrives — binding, not suggested — to one that does.
|
|
||||||
3. A `co_surfaces` partner arrives with its other half, which is the failure
|
|
||||||
that made merging rule 144 into rule 46 look like the only fix.
|
that made merging rule 144 into rule 46 look like the only fix.
|
||||||
4. An explicit suppression outranks an edge.
|
3. An explicit suppression outranks an edge.
|
||||||
|
|
||||||
|
TWO CLAIMS WERE DROPPED HERE BY MILESTONE 394, and it is worth saying which
|
||||||
|
rather than leaving a shorter list. "A rule with no tier binds exactly as
|
||||||
|
before" and "a conditional rule is reachable, not resident" were both about
|
||||||
|
the always-on tier. There is no tier and no resident payload, so neither
|
||||||
|
states anything that can now be true or false — they were not failing, they
|
||||||
|
had stopped being claims.
|
||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
from scribe.models.project import Project
|
from scribe.models.project import Project
|
||||||
from scribe.models.rulebook import Rulebook
|
|
||||||
from scribe.services import canonical_systems as canonical_svc
|
from scribe.services import canonical_systems as canonical_svc
|
||||||
from scribe.services import rulebooks as rulebooks_svc
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
from scribe.services import systems as systems_svc
|
from scribe.services import systems as systems_svc
|
||||||
@@ -27,12 +32,13 @@ pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine"
|
|||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def world():
|
async def world():
|
||||||
"""A project with TWO rulebooks, because the two payloads are different sets.
|
"""A project with two rulebooks — one subscribed, one not.
|
||||||
|
|
||||||
`list_always_on_rules` covers always-on rulebooks; `get_applicable_rules`
|
Both are ordinary rulebooks since milestone 394; the fixture used to flag
|
||||||
covers SUBSCRIBED ones. Conflating them is easy and would make these tests
|
one always-on because that was a second, separate way to reach a project.
|
||||||
assert nothing, so the fixture carries one of each and every test says
|
Keeping two is still worth it: a rulebook nobody subscribed to must
|
||||||
which payload it is about.
|
contribute nothing, and a fixture with only the subscribed one could not
|
||||||
|
tell "correctly scoped" from "returns everything".
|
||||||
"""
|
"""
|
||||||
async with async_session() as s:
|
async with async_session() as s:
|
||||||
owner = await ensure_user(s, "surfacing_owner")
|
owner = await ensure_user(s, "surfacing_owner")
|
||||||
@@ -43,10 +49,6 @@ async def world():
|
|||||||
await s.commit()
|
await s.commit()
|
||||||
|
|
||||||
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
||||||
async with async_session() as s:
|
|
||||||
rb = await s.get(Rulebook, always.id)
|
|
||||||
rb.always_on = True
|
|
||||||
await s.commit()
|
|
||||||
always_topic = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
|
always_topic = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
|
||||||
await rulebooks_svc.create_rule(
|
await rulebooks_svc.create_rule(
|
||||||
always_topic.id, ids["owner"], "dev is home", "Work on dev.",
|
always_topic.id, ids["owner"], "dev is home", "Work on dev.",
|
||||||
@@ -72,39 +74,8 @@ async def _titles(ids) -> set[str]:
|
|||||||
return {r["title"] for r in applicable["rules"]}
|
return {r["title"] for r in applicable["rules"]}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
async def test_a_rule_with_no_tier_no_areas_and_no_edges_binds_exactly_as_before(world):
|
|
||||||
"""THE compatibility guarantee. An install upgrades and every rule it
|
|
||||||
already had keeps arriving — no tier set, no areas, no edges, still bound.
|
|
||||||
Getting this wrong would silently stop enforcing rules people rely on,
|
|
||||||
which is worse than any amount of payload bloat."""
|
|
||||||
always_on = await rulebooks_svc.list_always_on_rules(world["owner"])
|
|
||||||
assert "dev is home" in {r.title for r in always_on}
|
|
||||||
assert "Between batches, keep stacking" in await _titles(world)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
async def test_a_conditional_rule_is_reachable_not_resident(world):
|
|
||||||
"""It leaves the session-start payload entirely — that is the point of the
|
|
||||||
tier — and it does NOT reach a project with no matching area."""
|
|
||||||
# In the ALWAYS-ON book: the tier alone keeps it out of the session-start
|
|
||||||
# payload, which is the whole point of the tier.
|
|
||||||
resident = await rulebooks_svc.create_rule(
|
|
||||||
world["always_topic"], world["owner"], "Release tagging", "Derive the tag.",
|
|
||||||
when_to_apply="when cutting a release", tier="conditional",
|
|
||||||
)
|
|
||||||
assert resident.tier == "conditional"
|
|
||||||
always_on = await rulebooks_svc.list_always_on_rules(world["owner"])
|
|
||||||
assert "Release tagging" not in {r.title for r in always_on}
|
|
||||||
|
|
||||||
# In the SUBSCRIBED book, untagged: the project has no area to reach it by,
|
|
||||||
# so it stays out of the project payload too. Absent for a DIFFERENT reason
|
|
||||||
# than above, which is why both are asserted.
|
|
||||||
await rulebooks_svc.create_rule(
|
|
||||||
world["topic"], world["owner"], "Untagged conditional", "No area yet.",
|
|
||||||
when_to_apply="sometime", tier="conditional",
|
|
||||||
)
|
|
||||||
assert "Untagged conditional" not in await _titles(world)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -117,7 +88,7 @@ async def test_a_conditional_rule_binds_a_project_that_works_in_its_area(world):
|
|||||||
|
|
||||||
rule = await rulebooks_svc.create_rule(
|
rule = await rulebooks_svc.create_rule(
|
||||||
world["topic"], world["owner"], "Release tagging", "Derive the tag.",
|
world["topic"], world["owner"], "Release tagging", "Derive the tag.",
|
||||||
when_to_apply="when cutting a release", tier="conditional",
|
when_to_apply="when cutting a release",
|
||||||
)
|
)
|
||||||
await rulebooks_svc.set_rule_systems(rule.id, world["owner"], [area.id])
|
await rulebooks_svc.set_rule_systems(rule.id, world["owner"], [area.id])
|
||||||
|
|
||||||
@@ -145,7 +116,7 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
|
|||||||
partner = await rulebooks_svc.create_rule(
|
partner = await rulebooks_svc.create_rule(
|
||||||
world["topic"], world["owner"], "Version names are labels",
|
world["topic"], world["owner"], "Version names are labels",
|
||||||
"A name decides nothing.",
|
"A name decides nothing.",
|
||||||
when_to_apply="when naming a build", tier="conditional",
|
when_to_apply="when naming a build",
|
||||||
)
|
)
|
||||||
await rulebooks_svc.add_rule_relation(
|
await rulebooks_svc.add_rule_relation(
|
||||||
world["owner"], world["plain"], partner.id, "co_surfaces",
|
world["owner"], world["plain"], partner.id, "co_surfaces",
|
||||||
@@ -163,7 +134,6 @@ async def test_a_suppression_outranks_an_edge(world):
|
|||||||
does not want that one. An explicit decision beats an inferred one."""
|
does not want that one. An explicit decision beats an inferred one."""
|
||||||
partner = await rulebooks_svc.create_rule(
|
partner = await rulebooks_svc.create_rule(
|
||||||
world["topic"], world["owner"], "Muted partner", "Should not arrive.",
|
world["topic"], world["owner"], "Muted partner", "Should not arrive.",
|
||||||
tier="conditional",
|
|
||||||
)
|
)
|
||||||
await rulebooks_svc.add_rule_relation(
|
await rulebooks_svc.add_rule_relation(
|
||||||
world["owner"], world["plain"], partner.id, "co_surfaces",
|
world["owner"], world["plain"], partner.id, "co_surfaces",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ async def test_rewording_the_check_drops_the_stamp(constraint):
|
|||||||
"""A stamp certifies a check, not a rule.
|
"""A stamp certifies a check, not a rule.
|
||||||
|
|
||||||
The safe direction, for the same reason _valid_tier falls back to
|
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
|
the safe direction: a rule wrongly listed as due costs one look, a rule wrongly
|
||||||
vouched for costs exactly what the sweep exists to catch.
|
vouched for costs exactly what the sweep exists to catch.
|
||||||
"""
|
"""
|
||||||
await rulebooks_svc.update_rule(
|
await rulebooks_svc.update_rule(
|
||||||
@@ -162,7 +162,6 @@ async def rulebook_of_three():
|
|||||||
stale = await rulebooks_svc.create_rule(
|
stale = await rulebooks_svc.create_rule(
|
||||||
topic.id, uid, "Bumps need a dashboard tick", "Tick it first.",
|
topic.id, uid, "Bumps need a dashboard tick", "Tick it first.",
|
||||||
verify_with="cat CI-runner/renovate/config.js",
|
verify_with="cat CI-runner/renovate/config.js",
|
||||||
tier="conditional",
|
|
||||||
)
|
)
|
||||||
async with async_session() as s:
|
async with async_session() as s:
|
||||||
row = await s.get(Rule, stale.id)
|
row = await s.get(Rule, stale.id)
|
||||||
@@ -256,12 +255,6 @@ async def test_never_only_and_the_age_filter_narrow_to_what_they_say(rulebook_of
|
|||||||
assert rulebook_of_three["never"] 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):
|
async def test_another_users_rules_are_not_in_your_sweep(rulebook_of_three):
|
||||||
|
|||||||
@@ -412,10 +412,10 @@ async def test_create_project_with_inception_args_decides_via_mcp():
|
|||||||
decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}}
|
decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}}
|
||||||
with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
|
with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
|
||||||
patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
|
patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
|
||||||
out = await create_project(title="P", exclude_always_on_rulebooks=[1], design_system_id=-1, seed_systems=True)
|
out = await create_project(title="P", subscribe_rulebooks=[1], design_system_id=-1, seed_systems=True)
|
||||||
kw = decide.await_args.kwargs
|
kw = decide.await_args.kwargs
|
||||||
assert decide.await_args.args[1] == 5 and kw["via"] == "mcp"
|
assert decide.await_args.args[1] == 5 and kw["via"] == "mcp"
|
||||||
assert kw["choices"] == {"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [],
|
assert kw["choices"] == {"subscribe_rulebooks": [1],
|
||||||
"design_system_id": None, "seed_systems": True}
|
"design_system_id": None, "seed_systems": True}
|
||||||
assert out["inception"]["via"] == "mcp" and "inception_effects" in out
|
assert out["inception"]["via"] == "mcp" and "inception_effects" in out
|
||||||
|
|
||||||
@@ -433,7 +433,7 @@ async def test_decide_project_inception_tool_records_an_inherit_all_decision_whe
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project():
|
async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project():
|
||||||
applicable = {"rules": [], "project_rules": [], "truncated": False,
|
applicable = {"rules": [], "project_rules": [], "truncated": False,
|
||||||
"subscribed_rulebooks": [], "excluded_always_on": []}
|
"subscribed_rulebooks": []}
|
||||||
ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"}
|
ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"}
|
||||||
|
|
||||||
async def run(project):
|
async def run(project):
|
||||||
@@ -466,6 +466,6 @@ def test_inception_routes_and_tool_are_registered():
|
|||||||
mcp = build_mcp_server()
|
mcp = build_mcp_server()
|
||||||
assert mcp._tool_manager.get_tool("decide_project_inception") is not None
|
assert mcp._tool_manager.get_tool("decide_project_inception") is not None
|
||||||
tool = mcp._tool_manager.get_tool("create_project")
|
tool = mcp._tool_manager.get_tool("create_project")
|
||||||
for name in ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems"):
|
for name in ("subscribe_rulebooks", "design_system_id", "seed_systems"):
|
||||||
assert name in tool.parameters.get("properties", {}), name
|
assert name in tool.parameters.get("properties", {}), name
|
||||||
|
|
||||||
|
|||||||
@@ -225,7 +225,9 @@ def test_register_attaches_every_tool():
|
|||||||
# 26 through milestone 307, +2 for the staleness sweep (milestone 312),
|
# 26 through milestone 307, +2 for the staleness sweep (milestone 312),
|
||||||
# +1 for a rule's edit history (milestone 323), +2 for preferences
|
# +1 for a rule's edit history (milestone 323), +2 for preferences
|
||||||
# (milestone 399).
|
# (milestone 399).
|
||||||
assert len(mcp.names) == 31
|
# 28 since milestone 394 took list_always_on_rules and the two
|
||||||
|
# always-on exclusion tools with the tier they served.
|
||||||
|
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
|
||||||
@@ -236,10 +238,6 @@ def test_register_attaches_every_tool():
|
|||||||
assert "create_preference" in mcp.names
|
assert "create_preference" in mcp.names
|
||||||
assert "update_preference" in mcp.names
|
assert "update_preference" in mcp.names
|
||||||
assert "subscribe_project_to_rulebook" in mcp.names
|
assert "subscribe_project_to_rulebook" in mcp.names
|
||||||
assert "list_always_on_rules" in mcp.names
|
|
||||||
# milestone 297: a project's opt-out of a whole always-on rulebook
|
|
||||||
assert "exclude_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
|
# milestone 312: the sweep, and the stamp that answers it
|
||||||
@@ -252,57 +250,12 @@ def test_register_attaches_every_tool():
|
|||||||
assert "unsuppress_topic_for_project" in mcp.names
|
assert "unsuppress_topic_for_project" in mcp.names
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_always_on_rules_returns_empty_when_no_always_on_rulebooks():
|
|
||||||
with patch(
|
|
||||||
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
|
|
||||||
AsyncMock(return_value=[]),
|
|
||||||
):
|
|
||||||
from scribe.mcp.tools.rulebooks import list_always_on_rules
|
|
||||||
out = await list_always_on_rules()
|
|
||||||
# An install with no always-on rulebooks still gets a marker (milestone
|
|
||||||
# 323): "no rules" is a STATE, and a payload that omitted the key would
|
|
||||||
# make the write path read every session on a fresh install as a change.
|
|
||||||
assert out == {"rules": [], "total": 0, "rules_etag": "empty|0"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_always_on_rules_projects_each_rule():
|
|
||||||
rules = [fake_rule(id=100, title="r", statement="s", topic_id=10), fake_rule(id=101, title="r", statement="s", topic_id=10)]
|
|
||||||
with patch(
|
|
||||||
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
|
|
||||||
AsyncMock(return_value=rules),
|
|
||||||
):
|
|
||||||
from scribe.mcp.tools.rulebooks import list_always_on_rules
|
|
||||||
out = await list_always_on_rules()
|
|
||||||
assert out["total"] == 2
|
|
||||||
assert {r["id"] for r in out["rules"]} == {100, 101}
|
|
||||||
assert all("topic_id" in r for r in out["rules"])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_rulebook_forwards_always_on_when_set():
|
|
||||||
rb = fake_rulebook(id=1, title="t")
|
|
||||||
mock = AsyncMock(return_value=rb)
|
|
||||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
|
|
||||||
from scribe.mcp.tools.rulebooks import update_rulebook
|
|
||||||
await update_rulebook(rulebook_id=1, always_on=True)
|
|
||||||
kwargs = mock.call_args.kwargs
|
|
||||||
assert kwargs.get("always_on") is True
|
|
||||||
assert "title" not in kwargs
|
|
||||||
assert "description" not in kwargs
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_rulebook_omits_always_on_when_none():
|
|
||||||
rb = fake_rulebook(id=1, title="t")
|
|
||||||
mock = AsyncMock(return_value=rb)
|
|
||||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
|
|
||||||
from scribe.mcp.tools.rulebooks import update_rulebook
|
|
||||||
await update_rulebook(rulebook_id=1, title="new title")
|
|
||||||
kwargs = mock.call_args.kwargs
|
|
||||||
assert "always_on" not in kwargs
|
|
||||||
assert kwargs["title"] == "new title"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -454,7 +407,7 @@ def _fake_version(**over):
|
|||||||
"id": 5, "rule_id": 100, "user_id": 1,
|
"id": 5, "rule_id": 100, "user_id": 1,
|
||||||
"title": "The runner has no bash", "statement": "Use sh.",
|
"title": "The runner has no bash", "statement": "Use sh.",
|
||||||
"why": "the image ships no bash", "how_to_apply": None,
|
"why": "the image ships no bash", "how_to_apply": None,
|
||||||
"when_to_apply": None, "tier": "always_on",
|
"when_to_apply": None,
|
||||||
"verify_with": "read the workflow's shell setting",
|
"verify_with": "read the workflow's shell setting",
|
||||||
"expires_when": None,
|
"expires_when": None,
|
||||||
"created_at": datetime(2026, 8, 29, tzinfo=timezone.utc),
|
"created_at": datetime(2026, 8, 29, tzinfo=timezone.utc),
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def test_service_signatures_require_user_id():
|
|||||||
"create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
|
"create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
|
||||||
"create_rule", "create_project_rule", "rule_detail",
|
"create_rule", "create_project_rule", "rule_detail",
|
||||||
"set_rule_systems", "add_rule_relation", "remove_rule_relation",
|
"set_rule_systems", "add_rule_relation", "remove_rule_relation",
|
||||||
"list_rules", "list_always_on_rules",
|
"list_rules",
|
||||||
"get_rule", "update_rule", "delete_rule",
|
"get_rule", "update_rule", "delete_rule",
|
||||||
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
|
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
|
||||||
"suppress_rule_for_project", "unsuppress_rule_for_project",
|
"suppress_rule_for_project", "unsuppress_rule_for_project",
|
||||||
@@ -93,24 +93,8 @@ def test_suppression_association_tables_declared():
|
|||||||
assert "rule_id" in cols or "topic_id" in cols
|
assert "rule_id" in cols or "topic_id" in cols
|
||||||
|
|
||||||
|
|
||||||
def test_rulebook_model_carries_always_on():
|
|
||||||
"""Migration 0058 added rulebooks.always_on — verify the model declares it."""
|
|
||||||
from scribe.models.rulebook import Rulebook
|
|
||||||
assert "always_on" in Rulebook.__table__.columns
|
|
||||||
col = Rulebook.__table__.columns["always_on"]
|
|
||||||
assert col.nullable is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_rulebook_route_accepts_always_on():
|
|
||||||
"""PATCH /api/rulebooks/<id> must pass always_on through to the service.
|
|
||||||
|
|
||||||
The handler filters body keys against a whitelist; that whitelist needs to
|
|
||||||
include always_on or toggling from the UI silently drops the field.
|
|
||||||
"""
|
|
||||||
import inspect as _inspect
|
|
||||||
from scribe.routes import rulebooks as rb_routes
|
|
||||||
src = _inspect.getsource(rb_routes.update_rulebook)
|
|
||||||
assert "always_on" in src, "update_rulebook handler missing always_on in field whitelist"
|
|
||||||
|
|
||||||
|
|
||||||
def test_rule_and_subscription_handlers_callable():
|
def test_rule_and_subscription_handlers_callable():
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ WHY THIS EXISTS
|
|||||||
#3749 clears the ledger when an EVENT destroys context — a compaction, a
|
#3749 clears the ledger when an EVENT destroys context — a compaction, a
|
||||||
/clear. This covers the case with no event at all: a long session where a rule
|
/clear. This covers the case with no event at all: a long session where a rule
|
||||||
was named two hundred turns ago and has simply fallen out of attention. Same
|
was named two hundred turns ago and has simply fallen out of attention. Same
|
||||||
argument #3702 made at the tier level (present in context and salient at the
|
argument #3702 made about tiers (present in context and salient at the
|
||||||
moment are different properties), applied to time instead of to tier.
|
moment are different properties), applied to time instead. The tier itself is
|
||||||
|
gone since milestone 394; the distinction it taught is what survives.
|
||||||
|
|
||||||
WHAT IS PINNED, AND WHAT DELIBERATELY IS NOT
|
WHAT IS PINNED, AND WHAT DELIBERATELY IS NOT
|
||||||
|
|
||||||
|
|||||||
@@ -167,14 +167,21 @@ async def test_the_arm_searches_on_its_OWN_bar_not_the_code_one():
|
|||||||
kw = search.await_args.kwargs
|
kw = search.await_args.kwargs
|
||||||
assert kw["threshold"] == 0.81, "the arm is still using the code threshold"
|
assert kw["threshold"] == 0.81, "the arm is still using the code threshold"
|
||||||
assert kw["limit"] == pc.RULEHINT_LIMIT
|
assert kw["limit"] == pc.RULEHINT_LIMIT
|
||||||
# NO tier filter (#3702). The arms search every rule the caller owns,
|
# NO CATEGORY FILTER (#3702, repointed by 394). This asserted that the arm
|
||||||
# because "already in the session" is not the same as "in front of the
|
# passed no `tier`. That parameter no longer exists, so the assertion had
|
||||||
# reader at the moment it applies" — and relevance is the threshold's
|
# become one that could not fail — which rule 167 rates below having none.
|
||||||
# job, not a category's. If this assertion is failing because a tier
|
#
|
||||||
# argument came back, read the block above RULEHINT_LIMIT first: the
|
# The claim it was making is still live, on the axis that DOES still exist:
|
||||||
# filter may legitimately return, but only carrying a measured reason.
|
# the act arms narrow by nothing, so a preference reaches a write exactly
|
||||||
assert "tier" not in kw or kw["tier"] is None, (
|
# as a rule does. "Already in the session" was never the same as "in front
|
||||||
"the arm is filtering the rule corpus by tier again"
|
# of the reader at the moment it applies", and relevance is the
|
||||||
|
# threshold's job rather than a category's. The one place a kind filter
|
||||||
|
# belongs is the reserved preference slot, which asks for a kind BECAUSE
|
||||||
|
# it is guaranteeing that kind a place.
|
||||||
|
assert "kind" not in kw or kw["kind"] is None, (
|
||||||
|
"the act arm is narrowing the rule corpus by kind — a preference and "
|
||||||
|
"a rule both apply to a write, and filtering here is how one of them "
|
||||||
|
"silently stops arriving"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
"""The staleness marker on the rules payload (milestone 323 step 5).
|
|
||||||
|
|
||||||
WHAT THE MARKER IS FOR: telling a session that the rules it is holding have
|
|
||||||
MOVED since it loaded them. Not general staleness — the limitation is stated
|
|
||||||
in services/rulebooks.py and in the write-path arm, and two tests here pin the
|
|
||||||
cases that would otherwise be quietly lost.
|
|
||||||
|
|
||||||
The two that matter most are both about NOT crying wolf. A marker that reports
|
|
||||||
a change when nothing changed gets ignored within a day, and an ignored
|
|
||||||
staleness signal is worse than none: it trains a reader to skip the line that
|
|
||||||
will one day be true.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from scribe.services import rulebooks as svc
|
|
||||||
|
|
||||||
NOW = datetime(2026, 8, 30, 12, 0, tzinfo=timezone.utc)
|
|
||||||
LATER = NOW + timedelta(hours=1)
|
|
||||||
|
|
||||||
|
|
||||||
def _rule(updated_at=NOW, rid=1, title="a rule"):
|
|
||||||
"""A Rule-shaped stand-in. The marker only ever reads three attributes,
|
|
||||||
and a real model would need a session to build."""
|
|
||||||
return SimpleNamespace(id=rid, title=title, updated_at=updated_at)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_same_set_produces_the_same_marker():
|
|
||||||
"""The whole mechanism rests on this. If the marker moved on its own, every
|
|
||||||
write would report a change and the line would be noise by lunchtime."""
|
|
||||||
rules = [_rule(rid=1), _rule(rid=2, updated_at=LATER)]
|
|
||||||
assert svc.rules_etag(rules) == svc.rules_etag(list(reversed(rules))), (
|
|
||||||
"the marker depends on the ORDER rules come back in, so any query "
|
|
||||||
"whose sort changes would look like an edit"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_edit_moves_the_marker():
|
|
||||||
before = svc.rules_etag([_rule(rid=1), _rule(rid=2)])
|
|
||||||
after = svc.rules_etag([_rule(rid=1), _rule(rid=2, updated_at=LATER)])
|
|
||||||
assert before != after
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_DELETED_rule_moves_the_marker():
|
|
||||||
"""THE CASE max(updated_at) ALONE CANNOT SEE, and the reason the count is
|
|
||||||
in there. Deleting a rule moves no timestamp — and it is the single change
|
|
||||||
that takes an instruction OUT of force, which is the one a session most
|
|
||||||
needs to hear about."""
|
|
||||||
before = svc.rules_etag([_rule(rid=1), _rule(rid=2)])
|
|
||||||
after = svc.rules_etag([_rule(rid=1)])
|
|
||||||
assert before != after, (
|
|
||||||
"a deleted rule left the marker unchanged — the count is missing, and "
|
|
||||||
"the session would keep obeying an instruction that no longer exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_rules_is_a_state_not_a_change():
|
|
||||||
"""Rule 115: this has to behave on an install with no rules at all. `max()`
|
|
||||||
over an empty set raises; a marker that raised would take the whole write
|
|
||||||
path's hint down, and one that varied would tell every session on a fresh
|
|
||||||
install that its rules had changed."""
|
|
||||||
assert svc.rules_etag([]) == svc.rules_etag([])
|
|
||||||
assert svc.rules_etag([]) != svc.rules_etag([_rule()])
|
|
||||||
|
|
||||||
|
|
||||||
def test_moved_since_names_only_what_actually_moved():
|
|
||||||
held = svc.rules_etag([_rule(rid=1), _rule(rid=2)])
|
|
||||||
current = [_rule(rid=1), _rule(rid=2, updated_at=LATER, title="reworded")]
|
|
||||||
moved = svc.rules_moved_since(current, held)
|
|
||||||
assert [r.id for r in moved] == [2]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_matching_marker_names_nothing():
|
|
||||||
rules = [_rule(rid=1), _rule(rid=2)]
|
|
||||||
assert svc.rules_moved_since(rules, svc.rules_etag(rules)) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_unreadable_marker_reports_no_change():
|
|
||||||
"""A caller cannot act on "something differs but I cannot say what", and a
|
|
||||||
garbled marker must never be rendered as a change — that is the shape of a
|
|
||||||
signal that gets ignored."""
|
|
||||||
assert svc.rules_moved_since([_rule(updated_at=LATER)], "not-an-etag") == []
|
|
||||||
assert svc.rules_moved_since([_rule(updated_at=LATER)], "") == []
|
|
||||||
assert svc.etag_count("garbled") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_empty_marker_reports_no_change():
|
|
||||||
"""An install that had no rules and now has some: the count says so, and
|
|
||||||
this function has no timestamp to reason from. Silence here, not a claim."""
|
|
||||||
assert svc.rules_moved_since([_rule()], svc.rules_etag([])) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_count_survives_the_round_trip():
|
|
||||||
assert svc.etag_count(svc.rules_etag([_rule(rid=1), _rule(rid=2)])) == 2
|
|
||||||
assert svc.etag_count(svc.rules_etag([])) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_limitation_is_stated_where_a_reader_will_be():
|
|
||||||
"""A future reader who finds an etag will assume it covers staleness
|
|
||||||
generally. It does not — it is blind to compaction, which is the most
|
|
||||||
common case — and the SessionStart nudge is that case's only mechanism.
|
|
||||||
|
|
||||||
Pinned because the plausible mistake is retiring a nudge that works on the
|
|
||||||
strength of a signal that does not cover it, and the comment is the only
|
|
||||||
thing standing in the way.
|
|
||||||
"""
|
|
||||||
import inspect
|
|
||||||
|
|
||||||
from scribe.services import plugin_context
|
|
||||||
|
|
||||||
for module in (svc, plugin_context):
|
|
||||||
src = inspect.getsource(module).lower()
|
|
||||||
assert "compaction" in src and "etag" in src, (
|
|
||||||
f"{module.__name__} no longer explains what the rules marker "
|
|
||||||
f"cannot see. Without it the next reader will treat an etag as a "
|
|
||||||
f"general staleness check and soften the SessionStart nudge."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── The arm that delivers the message (milestone 323 step 5) ───────────
|
|
||||||
#
|
|
||||||
# The marker is worth nothing until a session is actually TOLD. These drive
|
|
||||||
# the real `build_write_path_hint`, because the feature IS a line arriving in
|
|
||||||
# a hook's output — a test of the helper alone would prove the arithmetic and
|
|
||||||
# nothing about the delivery.
|
|
||||||
|
|
||||||
|
|
||||||
def _quiet_write_path(pc, rules):
|
|
||||||
"""Every other arm stubbed to silent, so the only line that can appear is
|
|
||||||
the one under test."""
|
|
||||||
return (
|
|
||||||
patch.object(pc, "get_writepath_config",
|
|
||||||
AsyncMock(return_value={"enabled": True, "threshold": 0.6,
|
|
||||||
"top_k": 3})),
|
|
||||||
patch.object(pc.snippets_svc, "list_snippets",
|
|
||||||
AsyncMock(return_value=([], 0))),
|
|
||||||
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])),
|
|
||||||
patch.object(pc, "semantic_search_rules", AsyncMock(return_value=[])),
|
|
||||||
patch.object(pc, "record_retrieval", MagicMock()),
|
|
||||||
patch.object(pc, "record_surfaced", MagicMock()),
|
|
||||||
patch.object(pc, "owner_names_for", AsyncMock(return_value={})),
|
|
||||||
patch.object(pc.rulebooks_svc, "list_always_on_rules",
|
|
||||||
AsyncMock(return_value=rules)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _hint(rules, held_etag):
|
|
||||||
from scribe.services import plugin_context as pc
|
|
||||||
|
|
||||||
patches = _quiet_write_path(pc, rules)
|
|
||||||
for p in patches:
|
|
||||||
p.start()
|
|
||||||
try:
|
|
||||||
out = await pc.build_write_path_hint(
|
|
||||||
1, "src/scribe/services/rulebooks.py", code="x" * 400,
|
|
||||||
rules_etag=held_etag,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
for p in patches:
|
|
||||||
p.stop()
|
|
||||||
return out["context"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_session_is_told_which_rule_moved():
|
|
||||||
"""The delivery, end to end through the real hint builder. Naming the rule
|
|
||||||
is the point — "something changed" sends the reader to re-read everything,
|
|
||||||
which is the cost the marker was meant to avoid."""
|
|
||||||
held = svc.rules_etag([_rule(rid=1), _rule(rid=2, title="dev is home")])
|
|
||||||
current = [_rule(rid=1), _rule(rid=2, title="dev is home", updated_at=LATER)]
|
|
||||||
|
|
||||||
ctx = await _hint(current, held)
|
|
||||||
assert "changed since this session started" in ctx
|
|
||||||
assert "#2" in ctx and "dev is home" in ctx
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_session_holding_the_current_rules_is_told_nothing():
|
|
||||||
"""The one that keeps the signal worth reading. A line on every write is a
|
|
||||||
line nobody reads."""
|
|
||||||
rules = [_rule(rid=1), _rule(rid=2)]
|
|
||||||
ctx = await _hint(rules, svc.rules_etag(rules))
|
|
||||||
assert "changed since this session started" not in ctx
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_session_that_sent_no_marker_is_told_nothing():
|
|
||||||
"""An install whose hook never reached /api/plugin/context has nothing
|
|
||||||
stored. Absent must read as silence, not as a mismatch — otherwise the
|
|
||||||
first thing a new install hears is that its rules changed."""
|
|
||||||
ctx = await _hint([_rule(updated_at=LATER)], "")
|
|
||||||
assert "changed since this session started" not in ctx
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_DELETED_rule_is_reported_even_though_it_has_no_row():
|
|
||||||
"""The count arm. A deleted rule leaves nothing to name, and it is the
|
|
||||||
change that takes an instruction OUT of force — so "no longer in force"
|
|
||||||
has to be sayable without a row to say it about."""
|
|
||||||
held = svc.rules_etag([_rule(rid=1), _rule(rid=2)])
|
|
||||||
ctx = await _hint([_rule(rid=1)], held)
|
|
||||||
assert "no longer in force" in ctx
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_the_arm_fails_open():
|
|
||||||
"""A staleness hint must never break a write. Every other arm here fails
|
|
||||||
open for the same reason, and this one runs a query that can fail."""
|
|
||||||
from scribe.services import plugin_context as pc
|
|
||||||
|
|
||||||
patches = _quiet_write_path(pc, [])
|
|
||||||
for p in patches:
|
|
||||||
p.start()
|
|
||||||
try:
|
|
||||||
with patch.object(pc.rulebooks_svc, "list_always_on_rules",
|
|
||||||
AsyncMock(side_effect=RuntimeError("database down"))):
|
|
||||||
out = await pc.build_write_path_hint(
|
|
||||||
1, "src/x.py", code="x" * 400, rules_etag="2026-01-01T00:00:00+00:00|3",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
for p in patches:
|
|
||||||
p.stop()
|
|
||||||
assert "changed since this session started" not in out["context"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_marker_cannot_break_the_payload_it_decorates():
|
|
||||||
"""It is computed on the SessionStart path. Raising there would cost the
|
|
||||||
whole context payload — every rule title, the project, the lot — to save
|
|
||||||
a hint, which is the wrong trade in every case.
|
|
||||||
|
|
||||||
A row with no usable timestamp is skipped; a set with none degrades to a
|
|
||||||
count-only marker. Count-only still catches a rule ADDED or DELETED and
|
|
||||||
only loses edits, which is the right way round to lose information.
|
|
||||||
|
|
||||||
Found by CI: `build_session_context` tests hand it MagicMock rules, and
|
|
||||||
`max()` over those raises TypeError rather than returning anything.
|
|
||||||
"""
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
assert svc.rules_etag([MagicMock(), MagicMock()]) == "unknown|2"
|
|
||||||
assert svc.rules_etag([SimpleNamespace()]) == "unknown|1"
|
|
||||||
# A count-only marker still moves when the set does.
|
|
||||||
assert svc.rules_etag([MagicMock()]) != svc.rules_etag([MagicMock(), MagicMock()])
|
|
||||||
# One usable stamp is enough to keep the real thing.
|
|
||||||
assert svc.rules_etag([_rule(), MagicMock()]).startswith("2026-")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_the_signal_arrives_even_when_nothing_else_matched():
|
|
||||||
"""THE BUG CI CAUGHT, and the one the task's acceptance criterion was
|
|
||||||
written to catch.
|
|
||||||
|
|
||||||
`build_write_path_hint` returns early when no prior art, stamp, divergence
|
|
||||||
or derive matched — which sat ABOVE this arm, so a session whose rules had
|
|
||||||
changed was told only if the file it happened to be editing also matched
|
|
||||||
something else. A staleness signal that fires on that coincidence is not a
|
|
||||||
staleness signal.
|
|
||||||
"""
|
|
||||||
held = svc.rules_etag([_rule(rid=1), _rule(rid=2, title="dev is home")])
|
|
||||||
current = [_rule(rid=1), _rule(rid=2, title="dev is home", updated_at=LATER)]
|
|
||||||
|
|
||||||
# Every other arm silent — which is exactly the case that used to return "".
|
|
||||||
ctx = await _hint(current, held)
|
|
||||||
assert "changed since this session started" in ctx
|
|
||||||
@@ -382,13 +382,13 @@ def test_rule_rows_carry_the_verification_fields():
|
|||||||
can rot — the exact blindness the fields were added to end.
|
can rot — the exact blindness the fields were added to end.
|
||||||
|
|
||||||
Column additions do not bump BACKUP_VERSION; only new SECTIONS do. Same
|
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).
|
call made for when_to_apply/arose_from_id in 0088 (commit 6ddb8bf).
|
||||||
"""
|
"""
|
||||||
checked = datetime(2026, 8, 27, 12, 0, tzinfo=timezone.utc)
|
checked = datetime(2026, 8, 27, 12, 0, tzinfo=timezone.utc)
|
||||||
row = SimpleNamespace(
|
row = SimpleNamespace(
|
||||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||||
why="w", how_to_apply="h", order_index=0,
|
why="w", how_to_apply="h", order_index=0,
|
||||||
when_to_apply="when", tier="conditional", kind="rule",
|
when_to_apply="when", kind="rule",
|
||||||
verify_with="cat some/file", expires_when="the file grows a shell",
|
verify_with="cat some/file", expires_when="the file grows a shell",
|
||||||
verified_at=checked, arose_from_id=99,
|
verified_at=checked, arose_from_id=99,
|
||||||
created_at=checked, updated_at=checked,
|
created_at=checked, updated_at=checked,
|
||||||
@@ -415,7 +415,7 @@ def test_rule_rows_keep_an_unverified_rule_unverified():
|
|||||||
row = SimpleNamespace(
|
row = SimpleNamespace(
|
||||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||||
why=None, how_to_apply=None, order_index=0,
|
why=None, how_to_apply=None, order_index=0,
|
||||||
when_to_apply=None, tier="always_on", kind="rule",
|
when_to_apply=None, kind="rule",
|
||||||
verify_with=None, expires_when=None, verified_at=None,
|
verify_with=None, expires_when=None, verified_at=None,
|
||||||
arose_from_id=None,
|
arose_from_id=None,
|
||||||
created_at=datetime(2026, 8, 27, tzinfo=timezone.utc),
|
created_at=datetime(2026, 8, 27, tzinfo=timezone.utc),
|
||||||
@@ -445,7 +445,7 @@ def test_rule_rows_carry_the_kind_so_a_preference_does_not_restore_as_a_rule():
|
|||||||
row = SimpleNamespace(
|
row = SimpleNamespace(
|
||||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||||
why=None, how_to_apply=None, order_index=0,
|
why=None, how_to_apply=None, order_index=0,
|
||||||
when_to_apply="when", tier="conditional", kind="preference",
|
when_to_apply="when", kind="preference",
|
||||||
verify_with=None, expires_when=None, verified_at=None,
|
verify_with=None, expires_when=None, verified_at=None,
|
||||||
arose_from_id=None, created_at=stamp, updated_at=stamp,
|
arose_from_id=None, created_at=stamp, updated_at=stamp,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,23 +4,9 @@ import pytest
|
|||||||
from tests.helpers import fake_note
|
from tests.helpers import fake_note
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _no_exclusions():
|
|
||||||
"""build_session_context asks for the bound project's always-on
|
|
||||||
exclusions (milestone 297); these tests script the rules only."""
|
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.excluded_always_on_rulebooks",
|
|
||||||
AsyncMock(return_value=[])):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.usefixtures("_no_supersession")
|
pytestmark = pytest.mark.usefixtures("_no_supersession")
|
||||||
|
|
||||||
|
|
||||||
def _rule(rid, title, topic_id):
|
|
||||||
r = MagicMock()
|
|
||||||
r.id, r.title, r.topic_id = rid, title, topic_id
|
|
||||||
r.statement = "FULL STATEMENT SHOULD NOT BE INJECTED"
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
# ─── knowledge auto-inject (Path A) ──────────────────────────────────────────
|
# ─── knowledge auto-inject (Path A) ──────────────────────────────────────────
|
||||||
@@ -106,30 +92,6 @@ async def test_build_autoinject_hint_blank_query_returns_empty():
|
|||||||
search.assert_not_called()
|
search.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_build_session_context_renders_titles_grouped_by_topic():
|
|
||||||
rules = [
|
|
||||||
_rule(1, "`dev` is home", 1),
|
|
||||||
_rule(2, "Release — never without explicit request", 1),
|
|
||||||
_rule(3, "No GitHub — Fabled-Git only", 2),
|
|
||||||
]
|
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
|
||||||
AsyncMock(return_value=rules)), \
|
|
||||||
patch("scribe.services.plugin_context._topic_titles",
|
|
||||||
AsyncMock(return_value={1: "git-workflow", 2: "fabled-git"})):
|
|
||||||
from scribe.services.plugin_context import build_session_context
|
|
||||||
out = await build_session_context(user_id=7, project_id=0)
|
|
||||||
|
|
||||||
ctx = out["context"]
|
|
||||||
assert out["rule_count"] == 3
|
|
||||||
assert out["project"] is None
|
|
||||||
# Titles present, grouped under topic headings
|
|
||||||
assert "### git-workflow" in ctx
|
|
||||||
assert "### fabled-git" in ctx
|
|
||||||
assert "- [1] `dev` is home" in ctx
|
|
||||||
assert "- [3] No GitHub — Fabled-Git only" in ctx
|
|
||||||
# Full statements must NOT be dumped (push channel injects titles only)
|
|
||||||
assert "FULL STATEMENT SHOULD NOT BE INJECTED" not in ctx
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -139,11 +101,7 @@ async def test_build_session_context_includes_project_when_scoped():
|
|||||||
# opposite of what this test is about.
|
# opposite of what this test is about.
|
||||||
project = MagicMock(id=2, title="FabledScribe", goal="ship it",
|
project = MagicMock(id=2, title="FabledScribe", goal="ship it",
|
||||||
design_system_id=None)
|
design_system_id=None)
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
with patch("scribe.services.plugin_context.projects_svc.get_project",
|
||||||
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
|
||||||
patch("scribe.services.plugin_context._topic_titles",
|
|
||||||
AsyncMock(return_value={1: "git-workflow"})), \
|
|
||||||
patch("scribe.services.plugin_context.projects_svc.get_project",
|
|
||||||
AsyncMock(return_value=project)), \
|
AsyncMock(return_value=project)), \
|
||||||
patch("scribe.services.plugin_context.notes_svc.list_notes",
|
patch("scribe.services.plugin_context.notes_svc.list_notes",
|
||||||
AsyncMock(return_value=([], 4))):
|
AsyncMock(return_value=([], 4))):
|
||||||
@@ -170,11 +128,7 @@ async def test_build_session_context_pushes_the_projects_design_system():
|
|||||||
"guidance": [], "token_count": 95,
|
"guidance": [], "token_count": 95,
|
||||||
"token_groups": ["accent", "surface", "type"],
|
"token_groups": ["accent", "surface", "type"],
|
||||||
}
|
}
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
with patch("scribe.services.plugin_context.projects_svc.get_project",
|
||||||
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
|
||||||
patch("scribe.services.plugin_context._topic_titles",
|
|
||||||
AsyncMock(return_value={1: "git-workflow"})), \
|
|
||||||
patch("scribe.services.plugin_context.projects_svc.get_project",
|
|
||||||
AsyncMock(return_value=project)), \
|
AsyncMock(return_value=project)), \
|
||||||
patch("scribe.services.plugin_context.notes_svc.list_notes",
|
patch("scribe.services.plugin_context.notes_svc.list_notes",
|
||||||
AsyncMock(return_value=([], 0))), \
|
AsyncMock(return_value=([], 0))), \
|
||||||
@@ -200,11 +154,7 @@ async def test_build_session_context_survives_an_unreadable_design_system():
|
|||||||
That must degrade to "no design block", not to a crash that costs the
|
That must degrade to "no design block", not to a crash that costs the
|
||||||
session its rules too."""
|
session its rules too."""
|
||||||
project = MagicMock(id=2, title="App", goal="", design_system_id=9)
|
project = MagicMock(id=2, title="App", goal="", design_system_id=9)
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
with patch("scribe.services.plugin_context.projects_svc.get_project",
|
||||||
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
|
||||||
patch("scribe.services.plugin_context._topic_titles",
|
|
||||||
AsyncMock(return_value={1: "git-workflow"})), \
|
|
||||||
patch("scribe.services.plugin_context.projects_svc.get_project",
|
|
||||||
AsyncMock(return_value=project)), \
|
AsyncMock(return_value=project)), \
|
||||||
patch("scribe.services.plugin_context.notes_svc.list_notes",
|
patch("scribe.services.plugin_context.notes_svc.list_notes",
|
||||||
AsyncMock(return_value=([], 0))), \
|
AsyncMock(return_value=([], 0))), \
|
||||||
@@ -219,10 +169,6 @@ async def test_build_session_context_survives_an_unreadable_design_system():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_build_session_context_unbound_repo_emits_bind_hint():
|
async def test_build_session_context_unbound_repo_emits_bind_hint():
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
|
||||||
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
|
||||||
patch("scribe.services.plugin_context._topic_titles",
|
|
||||||
AsyncMock(return_value={1: "git-workflow"})):
|
|
||||||
from scribe.services.plugin_context import build_session_context
|
from scribe.services.plugin_context import build_session_context
|
||||||
out = await build_session_context(
|
out = await build_session_context(
|
||||||
user_id=7, project_id=0, unbound_repo="host/owner/repo",
|
user_id=7, project_id=0, unbound_repo="host/owner/repo",
|
||||||
@@ -289,16 +235,29 @@ async def test_build_process_manifest_truncates_long_preview():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_build_session_context_caps_length():
|
async def test_build_session_context_caps_length():
|
||||||
many = [_rule(i, "x" * 200, 1) for i in range(200)]
|
"""The cap still binds, and now has to be provoked rather than tripped.
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
|
||||||
AsyncMock(return_value=many)), \
|
|
||||||
patch("scribe.services.plugin_context._topic_titles",
|
|
||||||
AsyncMock(return_value={1: "git-workflow"})):
|
|
||||||
from scribe.services.plugin_context import build_session_context
|
|
||||||
out = await build_session_context(user_id=7)
|
|
||||||
|
|
||||||
assert len(out["context"]) <= 9000 + 60 # cap + truncation note
|
This used to hand the block 200 long rule titles, because the preload made
|
||||||
assert "truncated" in out["context"]
|
overflow the easy case. Milestone 394 removed that block, so nothing this
|
||||||
|
function assembles on its own is big enough any more — which is a reason
|
||||||
|
to drive the cap directly, not a reason to drop it. The project and design
|
||||||
|
blocks are still unbounded in principle, and the hook passes this text
|
||||||
|
through verbatim.
|
||||||
|
|
||||||
|
Patching the cap rather than manufacturing 9,000 characters keeps the test
|
||||||
|
about the TRUNCATION PATH — that it cuts, and that it says it cut — which
|
||||||
|
is the part a reader depends on.
|
||||||
|
"""
|
||||||
|
from scribe.services import plugin_context as pc
|
||||||
|
|
||||||
|
with patch.object(pc, "_MAX_CHARS", 120):
|
||||||
|
out = await pc.build_session_context(user_id=7)
|
||||||
|
|
||||||
|
assert len(out["context"]) <= 120 + 60 # cap + truncation note
|
||||||
|
assert "truncated" in out["context"], (
|
||||||
|
"the block was cut without saying so — a reader cannot tell a "
|
||||||
|
"truncated context from a short one"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- the reuse slot (#2246) --------------------------------------------------
|
# --- the reuse slot (#2246) --------------------------------------------------
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ def _no_exclusions():
|
|||||||
"""get_applicable_rules asks for the project's always-on exclusions
|
"""get_applicable_rules asks for the project's always-on exclusions
|
||||||
(milestone 297) through its own session; these mocked-session tests
|
(milestone 297) through its own session; these mocked-session tests
|
||||||
script the rule queries only, so the exclusions lookup is stubbed empty."""
|
script the rule queries only, so the exclusions lookup is stubbed empty."""
|
||||||
with patch("scribe.services.rulebooks.excluded_always_on_rulebooks", AsyncMock(return_value=[])):
|
if True:
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@@ -345,7 +345,7 @@ async def test_get_applicable_rules_surfaces_suppressed_with_context():
|
|||||||
assert result["suppressed_topics"][0]["title"] == "design-system"
|
assert result["suppressed_topics"][0]["title"] == "design-system"
|
||||||
|
|
||||||
|
|
||||||
# ── rule_brief + tier (milestone 307) ───────────────────────────────────
|
# ── rule_brief (milestone 307) ──────────────────────────────────────────
|
||||||
|
|
||||||
def test_rule_brief_carries_age_but_not_the_deep_fields():
|
def test_rule_brief_carries_age_but_not_the_deep_fields():
|
||||||
"""The shape a SURFACED rule takes, and the reason it exists.
|
"""The shape a SURFACED rule takes, and the reason it exists.
|
||||||
@@ -362,7 +362,6 @@ def test_rule_brief_carries_age_but_not_the_deep_fields():
|
|||||||
updated_at=datetime(2026, 6, 1, 14, 30, tzinfo=timezone.utc),
|
updated_at=datetime(2026, 6, 1, 14, 30, tzinfo=timezone.utc),
|
||||||
))
|
))
|
||||||
assert out["when_to_apply"] == "before any git push"
|
assert out["when_to_apply"] == "before any git push"
|
||||||
assert out["tier"] == "always_on"
|
|
||||||
# A DATE, not a stamp: the question is "how old is this", and a full ISO
|
# A DATE, not a stamp: the question is "how old is this", and a full ISO
|
||||||
# string across the always-on set is ~2k characters of payload.
|
# string across the always-on set is ~2k characters of payload.
|
||||||
assert out["updated_at"] == "2026-06-01"
|
assert out["updated_at"] == "2026-06-01"
|
||||||
@@ -381,17 +380,6 @@ def test_rule_brief_omits_keys_a_rule_has_no_value_for():
|
|||||||
assert "arose_from_id" not in out
|
assert "arose_from_id" not in out
|
||||||
|
|
||||||
|
|
||||||
def test_an_unknown_tier_falls_back_to_binding():
|
|
||||||
"""The asymmetry that decides the direction: a rule that preloads when it
|
|
||||||
needn't costs context; a rule that quietly stops preloading costs the
|
|
||||||
behaviour it was written for. So a typo binds."""
|
|
||||||
from scribe.services.rulebooks import _valid_tier
|
|
||||||
|
|
||||||
assert _valid_tier("conditional") == "conditional"
|
|
||||||
assert _valid_tier("always_on") == "always_on"
|
|
||||||
assert _valid_tier("Conditional") == "always_on"
|
|
||||||
assert _valid_tier("") == "always_on"
|
|
||||||
assert _valid_tier("occasionally") == "always_on"
|
|
||||||
|
|
||||||
|
|
||||||
# ── verify_with / expires_when (milestone 312) ──────────────────────────
|
# ── verify_with / expires_when (milestone 312) ──────────────────────────
|
||||||
@@ -467,7 +455,6 @@ def test_a_sweep_row_carries_the_check_in_full():
|
|||||||
assert row["verify_with"] == "cat CI-runner/renovate/config.js"
|
assert row["verify_with"] == "cat CI-runner/renovate/config.js"
|
||||||
assert row["expires_when"] == "dependencyDashboardApproval is turned off"
|
assert row["expires_when"] == "dependencyDashboardApproval is turned off"
|
||||||
assert row["when_to_apply"] == "when a dependency bump is in play"
|
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():
|
def test_never_verified_reports_no_day_count_rather_than_zero():
|
||||||
@@ -495,13 +482,3 @@ def test_a_verified_row_counts_the_days():
|
|||||||
assert row["days_since_verified"] == 74
|
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