Retire the always-on tier — every rule arrives by retrieval (milestone 394) #152
@@ -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";
|
||||
|
||||
export interface InceptionChoices {
|
||||
exclude_always_on_rulebooks: number[];
|
||||
subscribe_rulebooks: number[];
|
||||
design_system_id: number | null;
|
||||
seed_systems: boolean;
|
||||
@@ -16,9 +15,7 @@ export interface InceptionRecord {
|
||||
}
|
||||
|
||||
export interface InceptionDefaults {
|
||||
always_on_rulebooks: { id: number; title: string }[];
|
||||
other_rulebooks: { id: number; title: string }[];
|
||||
excluded_always_on: { id: number; title: string }[];
|
||||
rulebooks: { id: number; title: string }[];
|
||||
subscribed_rulebooks: { id: number; title: string }[];
|
||||
design_system_id: number | null;
|
||||
design_systems: { id: number; title: string }[];
|
||||
@@ -32,7 +29,7 @@ export interface InceptionDecision {
|
||||
}
|
||||
|
||||
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) =>
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { RecordUsage } from "@/types/usage";
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
/** 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
|
||||
@@ -26,7 +25,6 @@ export interface Rulebook {
|
||||
owner_user_id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
always_on: boolean;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
@@ -49,12 +47,6 @@ export interface Rule {
|
||||
statement: string;
|
||||
/** WHEN this rule fires — the trigger, not the instruction. */
|
||||
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;
|
||||
how_to_apply: string;
|
||||
/**
|
||||
@@ -87,7 +79,6 @@ export interface RuleHeader {
|
||||
title: string;
|
||||
statement: string;
|
||||
topic_id: number | null;
|
||||
tier: RuleTier;
|
||||
/** A date (YYYY-MM-DD), not a timestamp. */
|
||||
updated_at: string | null;
|
||||
when_to_apply?: string;
|
||||
@@ -134,7 +125,6 @@ export interface ApplicableRules {
|
||||
truncated: boolean;
|
||||
subscribed_rulebooks: { id: number; title: string }[];
|
||||
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
|
||||
excluded_always_on: { id: number; title: string }[];
|
||||
}
|
||||
|
||||
// ── Rulebooks ───────────────────────────────────────────────────────
|
||||
@@ -152,7 +142,7 @@ export async function createRulebook(data: { title: string; description?: string
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -207,7 +197,6 @@ export interface RuleWrite {
|
||||
title: string;
|
||||
statement: string;
|
||||
when_to_apply: string;
|
||||
tier: RuleTier;
|
||||
why: string;
|
||||
how_to_apply: string;
|
||||
order_index: number;
|
||||
@@ -258,7 +247,6 @@ export interface RuleVersion {
|
||||
why?: string;
|
||||
how_to_apply?: string;
|
||||
when_to_apply?: string;
|
||||
tier?: string;
|
||||
verify_with?: string;
|
||||
expires_when?: string;
|
||||
}
|
||||
@@ -323,16 +311,6 @@ export async function unsuppressTopicForProject(projectId: number, topicId: numb
|
||||
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
|
||||
@@ -343,7 +321,6 @@ export interface RuleVerificationRow {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
tier: RuleTier;
|
||||
topic_id: number | null;
|
||||
project_id: number | null;
|
||||
when_to_apply: string;
|
||||
@@ -366,12 +343,10 @@ export interface RuleVerificationRow {
|
||||
*/
|
||||
export async function listRulesDueForVerification(opts: {
|
||||
olderThanDays?: number;
|
||||
tier?: RuleTier;
|
||||
neverOnly?: boolean;
|
||||
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.olderThanDays) q.set("older_than_days", String(opts.olderThanDays));
|
||||
if (opts.tier) q.set("tier", opts.tier);
|
||||
if (opts.neverOnly) q.set("never_only", "true");
|
||||
const qs = q.toString();
|
||||
return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`);
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
milestone (tier, then verification) and were byte-identical; a third would
|
||||
have drifted. The pane's italic serif title is inherited by anything inside
|
||||
it, so the chip resets family and style explicitly. */
|
||||
/* 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 {
|
||||
margin-left: 0.4rem;
|
||||
font-family: var(--fs-font-body);
|
||||
|
||||
@@ -28,7 +28,6 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
|
||||
const alwaysOn = ref<{ id: number; title: string }[]>([]);
|
||||
const others = ref<{ id: number; title: string }[]>([]);
|
||||
const designSystems = ref<{ id: number; title: string }[]>([]);
|
||||
const systemsCount = ref(0);
|
||||
@@ -47,21 +46,18 @@ async function load() {
|
||||
try {
|
||||
if (props.mode === "decide" && props.projectId) {
|
||||
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
|
||||
alwaysOn.value = d.always_on_rulebooks;
|
||||
others.value = d.other_rulebooks;
|
||||
others.value = d.rulebooks;
|
||||
designSystems.value = d.design_systems;
|
||||
systemsCount.value = d.systems;
|
||||
// Start from what stands today so "record" without changes is a true inherit-all.
|
||||
local.value = {
|
||||
exclude_always_on_rulebooks: d.excluded_always_on.map((r) => r.id),
|
||||
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
|
||||
design_system_id: d.design_system_id,
|
||||
seed_systems: false,
|
||||
};
|
||||
} else {
|
||||
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.filter((r) => !r.always_on).map((r) => ({ id: r.id, title: r.title }));
|
||||
others.value = rulebooks.map((r) => ({ id: r.id, title: r.title }));
|
||||
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
|
||||
}
|
||||
} 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 {
|
||||
return local.value.subscribe_rulebooks.includes(id);
|
||||
}
|
||||
@@ -87,7 +76,7 @@ function toggleSubscribe(id: number) {
|
||||
}
|
||||
|
||||
const nothingToDecide = computed(
|
||||
() => !alwaysOn.value.length && !others.value.length && !designSystems.value.length,
|
||||
() => !others.value.length && !designSystems.value.length,
|
||||
);
|
||||
|
||||
async function record() {
|
||||
@@ -118,16 +107,11 @@ onMounted(load);
|
||||
<p v-if="loading" class="inception-muted">Loading…</p>
|
||||
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||
<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">
|
||||
<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">
|
||||
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
|
||||
<span>{{ rb.title }}</span>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
unsuppressRuleForProject,
|
||||
suppressTopicForProject,
|
||||
unsuppressTopicForProject,
|
||||
includeAlwaysOnRulebook,
|
||||
} from "@/api/rulebooks";
|
||||
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
||||
|
||||
@@ -32,7 +31,7 @@ const ruleDetails = ref<Record<number, {
|
||||
const showProjectRuleForm = ref(false);
|
||||
const newProjectRule = ref({
|
||||
title: "", statement: "", why: "", how_to_apply: "",
|
||||
when_to_apply: "", tier: "always_on" as "always_on" | "conditional",
|
||||
when_to_apply: "",
|
||||
});
|
||||
|
||||
async function load() {
|
||||
@@ -49,11 +48,6 @@ async function subscribe(rulebookId: number) {
|
||||
await load();
|
||||
}
|
||||
|
||||
async function includeBack(rulebookId: number) {
|
||||
await includeAlwaysOnRulebook(props.projectId, rulebookId);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function unsubscribe(rulebookId: number) {
|
||||
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
||||
await unsubscribeProject(props.projectId, rulebookId);
|
||||
@@ -134,11 +128,10 @@ async function submitProjectRule() {
|
||||
why: newProjectRule.value.why.trim() || undefined,
|
||||
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
|
||||
when_to_apply: newProjectRule.value.when_to_apply.trim() || undefined,
|
||||
tier: newProjectRule.value.tier,
|
||||
});
|
||||
newProjectRule.value = {
|
||||
title: "", statement: "", why: "", how_to_apply: "",
|
||||
when_to_apply: "", tier: "always_on",
|
||||
when_to_apply: "",
|
||||
};
|
||||
showProjectRuleForm.value = false;
|
||||
await load();
|
||||
@@ -210,17 +203,6 @@ watch(() => props.projectId, load);
|
||||
</div>
|
||||
</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">
|
||||
<div class="section-head">
|
||||
<h3>Project rules</h3>
|
||||
@@ -246,22 +228,13 @@ watch(() => props.projectId, load);
|
||||
></textarea>
|
||||
<textarea
|
||||
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"
|
||||
></textarea>
|
||||
<div class="tier-row">
|
||||
<label>
|
||||
<input v-model="newProjectRule.tier" type="radio" value="always_on" />
|
||||
Always on
|
||||
</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>
|
||||
<p v-if="!newProjectRule.when_to_apply.trim()" class="trigger-hint">
|
||||
Without a trigger the rule will never reach a session — nothing is
|
||||
preloaded, so a rule arrives only when work matches what it names.
|
||||
</p>
|
||||
<textarea
|
||||
v-model="newProjectRule.why"
|
||||
placeholder="Why (optional) — the rationale"
|
||||
@@ -405,14 +378,9 @@ watch(() => props.projectId, load);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tier-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||||
.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); }
|
||||
.trigger-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; }
|
||||
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
|
||||
.chip-excluded .chip-remove { text-decoration: none; }
|
||||
.rules-tab { padding: 1rem; }
|
||||
h3 {
|
||||
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 { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||
import type { RuleTier } from "@/api/rulebooks";
|
||||
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
||||
|
||||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||
@@ -13,7 +12,6 @@ const canon = useCanonicalSystemsStore();
|
||||
const title = ref("");
|
||||
const statement = ref("");
|
||||
const whenToApply = ref("");
|
||||
const tier = ref<RuleTier>("always_on");
|
||||
const systemIds = ref<number[]>([]);
|
||||
const why = ref("");
|
||||
const howToApply = ref("");
|
||||
@@ -70,7 +68,6 @@ async function load() {
|
||||
title.value = r.title;
|
||||
statement.value = r.statement;
|
||||
whenToApply.value = r.when_to_apply || "";
|
||||
tier.value = r.tier || "always_on";
|
||||
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
||||
why.value = r.why || "";
|
||||
howToApply.value = r.how_to_apply || "";
|
||||
@@ -81,7 +78,6 @@ async function load() {
|
||||
title.value = "";
|
||||
statement.value = "";
|
||||
whenToApply.value = "";
|
||||
tier.value = "always_on";
|
||||
systemIds.value = [];
|
||||
why.value = "";
|
||||
howToApply.value = "";
|
||||
@@ -100,7 +96,6 @@ async function save() {
|
||||
title: title.value,
|
||||
statement: statement.value,
|
||||
when_to_apply: whenToApply.value,
|
||||
tier: tier.value,
|
||||
// Always sent, so clearing the last area actually clears it — the server
|
||||
// reads a list as "these ARE the areas now".
|
||||
system_ids: systemIds.value,
|
||||
@@ -147,38 +142,19 @@ watch(() => props.ruleId, load);
|
||||
Statement <span class="required">*</span>
|
||||
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||||
</label>
|
||||
<label>
|
||||
When to apply
|
||||
<label :class="{ 'trigger-missing': !whenToApply.trim() }">
|
||||
When to apply <span class="required">*</span>
|
||||
<textarea
|
||||
v-model="whenToApply"
|
||||
rows="2"
|
||||
placeholder="The trigger, not the instruction — “before any git push”, “when a release is being cut”."
|
||||
rows="3"
|
||||
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>
|
||||
|
||||
<fieldset class="tier">
|
||||
<legend>How it reaches a session</legend>
|
||||
<label class="tier-opt">
|
||||
<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>
|
||||
</fieldset>
|
||||
<p v-if="!whenToApply.trim()" class="trigger-warning">
|
||||
<strong>Without this, the rule will never reach a session.</strong>
|
||||
Nothing is preloaded: a rule arrives when what someone is doing matches
|
||||
its trigger, so an empty trigger leaves the rule findable by nobody.
|
||||
</p>
|
||||
|
||||
<fieldset v-if="canon.catalog.length" class="areas">
|
||||
<legend>Areas this rule is about</legend>
|
||||
@@ -190,14 +166,14 @@ watch(() => props.ruleId, load);
|
||||
/>
|
||||
<span>{{ entry.name }}</span>
|
||||
</label>
|
||||
<p class="tier-test">
|
||||
<p class="field-note">
|
||||
What lets this rule reach a project working in that area.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="check">
|
||||
<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
|
||||
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.
|
||||
@@ -226,7 +202,7 @@ watch(() => props.ruleId, load);
|
||||
<button type="button" :disabled="verifying" @click="verify(false)">No longer true</button>
|
||||
</span>
|
||||
</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
|
||||
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
|
||||
@@ -243,7 +219,7 @@ watch(() => props.ruleId, load);
|
||||
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="tier-test">
|
||||
<p class="field-note">
|
||||
Rules that <em>fail together</em> are linked, never merged — a merged rule cannot be
|
||||
cited, surfaced or suppressed a clause at a time.
|
||||
</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; }
|
||||
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; }
|
||||
.tier-opt input, .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; }
|
||||
.area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
|
||||
.area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||||
.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 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
|
||||
// row, and "How to apply" reads where "how_to_apply" has to be decoded.
|
||||
type TextField =
|
||||
| "title" | "statement" | "when_to_apply" | "tier"
|
||||
| "title" | "statement" | "when_to_apply"
|
||||
| "why" | "how_to_apply" | "verify_with" | "expires_when";
|
||||
|
||||
const FIELDS: Array<[TextField, string]> = [
|
||||
["title", "Title"],
|
||||
["statement", "Statement"],
|
||||
["when_to_apply", "When to apply"],
|
||||
["tier", "Tier"],
|
||||
["why", "Why"],
|
||||
["how_to_apply", "How to apply"],
|
||||
["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)">
|
||||
<div class="title">
|
||||
{{ r.title }}
|
||||
<!-- Only conditional is marked: always-on is the default and
|
||||
badging every row would say nothing. -->
|
||||
<span v-if="r.tier === 'conditional'" class="rule-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
|
||||
<!-- Marked only when something is WRONG: every rule arrives by
|
||||
retrieval now, so "conditional" stopped distinguishing anything.
|
||||
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
|
||||
presence says "this one asserts a fact that can go false". -->
|
||||
<span
|
||||
|
||||
@@ -10,19 +10,16 @@
|
||||
*/
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import type { RuleTier } from "@/api/rulebooks";
|
||||
|
||||
const emit = defineEmits<{ "open-rule": [id: number] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const neverOnly = ref(false);
|
||||
const tier = ref<RuleTier | "">("");
|
||||
const busyId = ref<number | null>(null);
|
||||
|
||||
function reload() {
|
||||
return store.fetchRulesDue({
|
||||
neverOnly: neverOnly.value || undefined,
|
||||
tier: tier.value || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,14 +50,6 @@ onMounted(reload);
|
||||
<input v-model="neverOnly" type="checkbox" @change="reload" />
|
||||
<span>Never checked only</span>
|
||||
</label>
|
||||
<label class="filter">
|
||||
<span>Tier</span>
|
||||
<select v-model="tier" @change="reload">
|
||||
<option value="">any</option>
|
||||
<option value="always_on">always on</option>
|
||||
<option value="conditional">conditional</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="store.loading" class="state">Loading…</p>
|
||||
@@ -68,14 +57,18 @@ onMounted(reload);
|
||||
<!-- An empty sweep is GOOD NEWS, and must not read like a broken page. -->
|
||||
<p v-else-if="!store.rulesDue.length" class="state empty">
|
||||
Nothing to check.
|
||||
{{ neverOnly || tier ? "No rule matches these filters." : "No rule carries a check yet — add one to a rule that asserts a fact." }}
|
||||
{{ neverOnly ? "No rule matches these filters." : "No rule carries a check yet — add one to a rule that asserts a fact." }}
|
||||
</p>
|
||||
|
||||
<ol v-else class="rows">
|
||||
<li v-for="r in store.rulesDue" :key="r.id" class="row">
|
||||
<div class="row-head">
|
||||
<button class="row-title" @click="emit('open-rule', r.id)">{{ r.title }}</button>
|
||||
<span v-if="r.tier === 'always_on'" class="rule-chip" title="Loaded into every session — a wrong one is wrong everywhere at once">always on</span>
|
||||
<span
|
||||
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 }">
|
||||
{{ r.days_since_verified === null
|
||||
? "never checked"
|
||||
|
||||
@@ -74,14 +74,6 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
||||
<section class="pane">
|
||||
<header>
|
||||
<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>
|
||||
<ul>
|
||||
<li
|
||||
@@ -124,12 +116,6 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
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; }
|
||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||
li.active { background: var(--fs-accent-soft); }
|
||||
|
||||
@@ -31,7 +31,6 @@ async function submitNew() {
|
||||
@click="emit('select', rb.id)"
|
||||
>
|
||||
<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>
|
||||
</ul>
|
||||
<!-- 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.active { background: var(--fs-accent-soft); }
|
||||
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 {
|
||||
display: block; width: 100%; text-align: left;
|
||||
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
|
||||
// looking at — re-fetching unfiltered would silently widen the list under
|
||||
// them at the moment they acted on it.
|
||||
const lastSweepOpts = ref<{ olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean }>({});
|
||||
const lastSweepOpts = ref<{ olderThanDays?: number; neverOnly?: boolean }>({});
|
||||
const loading = ref(false);
|
||||
|
||||
async function fetchRulebooks() {
|
||||
@@ -57,19 +57,13 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
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 idx = rulebooks.value.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) rulebooks.value[idx] = 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) {
|
||||
await api.deleteRulebook(id);
|
||||
rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
|
||||
@@ -112,7 +106,6 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
title: rule.title,
|
||||
statement: rule.statement,
|
||||
topic_id: rule.topic_id,
|
||||
tier: rule.tier,
|
||||
updated_at: rule.updated_at,
|
||||
when_to_apply: rule.when_to_apply || 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. */
|
||||
async function fetchRulesDue(opts: {
|
||||
olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean;
|
||||
olderThanDays?: number; neverOnly?: boolean;
|
||||
} = {}) {
|
||||
loading.value = true;
|
||||
lastSweepOpts.value = opts;
|
||||
@@ -206,7 +199,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
return {
|
||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
|
||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
||||
createRulebook, updateRulebook, deleteRulebook,
|
||||
createTopic, updateTopic, deleteTopic,
|
||||
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
||||
fetchRulesDue, verifyRule,
|
||||
|
||||
@@ -716,9 +716,6 @@ async function confirmDelete() {
|
||||
/>
|
||||
<p v-else-if="project.inception" class="inception-line">
|
||||
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">
|
||||
· subscribes {{ project.inception.choices.subscribe_rulebooks.length }}
|
||||
</template>
|
||||
|
||||
@@ -156,28 +156,10 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
|
||||
[ -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
|
||||
# where it has to be captured: the model receives one from
|
||||
# an MCP tool too, but a hook cannot see a tool's result. Stored
|
||||
# under the same state dir the prior-art hook already uses, keyed by session,
|
||||
# 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
|
||||
# The rules marker is gone with the resident set it described
|
||||
# (milestone 394). Nothing is preloaded, so there is no set whose
|
||||
# drift a later write could be told about — a rule is retrieved at
|
||||
# the moment it applies, which cannot be stale.
|
||||
[ -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
|
||||
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)),
|
||||
)
|
||||
|
||||
# A project's opt-out of a whole ALWAYS-ON rulebook (milestone 297): the
|
||||
# sibling of the two suppression tables below, one level up. Always-on
|
||||
# rulebooks bind every project implicitly; an inception decision can exclude
|
||||
# specific ones for this project, and get_applicable_rules /
|
||||
# get_applicable_rules(project_id) skips them. FKs CASCADE like the others.
|
||||
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_rulebook_exclusions` lived here until milestone 394. It recorded a
|
||||
# project's opt-out of a whole always-on rulebook — which only made sense
|
||||
# while a rulebook could bind a project WITHOUT being asked. Subscription is
|
||||
# now the only reach a rulebook has, so declining one is expressed by not
|
||||
# subscribing, and there is nothing left to opt out of.
|
||||
|
||||
project_topic_suppressions = Table(
|
||||
"project_topic_suppressions",
|
||||
|
||||
@@ -23,7 +23,6 @@ from scribe.models.rulebook import (
|
||||
Rulebook,
|
||||
RulebookTopic,
|
||||
project_rule_suppressions,
|
||||
project_rulebook_exclusions,
|
||||
project_rulebook_subscriptions,
|
||||
project_topic_suppressions,
|
||||
)
|
||||
@@ -82,7 +81,7 @@ _BACKED_UP = [
|
||||
"users", "projects", "milestones", "notes", "task_logs", "note_drafts",
|
||||
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
|
||||
"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.
|
||||
"systems", "record_systems", "design_systems", "design_tokens",
|
||||
"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]
|
||||
|
||||
|
||||
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
|
||||
@@ -652,9 +649,6 @@ async def export_full_backup() -> dict:
|
||||
topic_suppressions = (await session.execute(
|
||||
select(project_topic_suppressions)
|
||||
)).all()
|
||||
rulebook_exclusions = (await session.execute(
|
||||
select(project_rulebook_exclusions)
|
||||
)).all()
|
||||
|
||||
return {
|
||||
"version": BACKUP_VERSION,
|
||||
@@ -680,7 +674,6 @@ async def export_full_backup() -> dict:
|
||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
|
||||
"canonical_systems": _canonical_system_rows(canonical_systems),
|
||||
"rule_systems": _rule_system_rows(rule_system_rows),
|
||||
"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)
|
||||
)
|
||||
)).all()
|
||||
rulebook_exclusions = (await session.execute(
|
||||
select(project_rulebook_exclusions).where(
|
||||
project_rulebook_exclusions.c.project_id.in_(project_ids)
|
||||
)
|
||||
)).all()
|
||||
else:
|
||||
subscriptions = rule_suppressions = topic_suppressions = rulebook_exclusions = []
|
||||
subscriptions = rule_suppressions = topic_suppressions = []
|
||||
|
||||
return {
|
||||
"version": BACKUP_VERSION,
|
||||
@@ -882,7 +870,6 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
|
||||
"canonical_systems": _canonical_system_rows(canonical_systems),
|
||||
"rule_systems": _rule_system_rows(rule_system_rows),
|
||||
"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,
|
||||
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
|
||||
"rulebook_subscriptions": 0, "rule_suppressions": 0,
|
||||
"topic_suppressions": 0, "rulebook_exclusions": 0,
|
||||
"topic_suppressions": 0,
|
||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||
"design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0,
|
||||
"repo_bindings": 0,
|
||||
@@ -1347,15 +1334,11 @@ async def _restore_v2(data: dict) -> dict:
|
||||
stats["topic_suppressions"] += 1
|
||||
|
||||
# 14b. Always-on rulebook exclusions (v10, milestone 297)
|
||||
for exc in data.get("rulebook_exclusions", []):
|
||||
mapped_pid = project_id_map.get(exc.get("project_id", 0))
|
||||
mapped_rbid = rulebook_id_map.get(exc.get("rulebook_id", 0))
|
||||
if mapped_pid is None or mapped_rbid is None:
|
||||
continue
|
||||
await session.execute(project_rulebook_exclusions.insert().values(
|
||||
project_id=mapped_pid, rulebook_id=mapped_rbid,
|
||||
))
|
||||
stats["rulebook_exclusions"] += 1
|
||||
# `rulebook_exclusions` was a v10 section and is READ BY NOBODY since
|
||||
# milestone 394 removed the table. An archive carrying it still
|
||||
# imports — the key is simply not looked at — because refusing a
|
||||
# backup for remembering something we deleted would make every v10-v13
|
||||
# archive unrestorable.
|
||||
|
||||
# --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4
|
||||
# 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")
|
||||
if isinstance(inception, dict):
|
||||
choices = dict(inception.get("choices") or {})
|
||||
choices["exclude_always_on_rulebooks"] = [
|
||||
rulebook_id_map[i] for i in choices.get("exclude_always_on_rulebooks") or []
|
||||
if i in rulebook_id_map
|
||||
]
|
||||
# DROPPED, not remapped (milestone 394). A pre-394 archive
|
||||
# carries the retired exclusion choice; restoring it would put
|
||||
# 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"] = [
|
||||
rulebook_id_map[i] for i in choices.get("subscribe_rulebooks") or []
|
||||
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(
|
||||
|
||||
+1
-1
@@ -225,7 +225,7 @@ def fake_rule(**attrs) -> MagicMock:
|
||||
# Named for the note-2109 reason the whole helper exists: unnamed,
|
||||
# `when_to_apply` and `arose_from_id` would be truthy MagicMocks and
|
||||
# rule_brief would attach both keys on every stand-in.
|
||||
"when_to_apply": None, "tier": "always_on", "arose_from_id": None,
|
||||
"when_to_apply": None, "arose_from_id": None,
|
||||
# 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
|
||||
# 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():
|
||||
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({"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [2],
|
||||
assert validate_inception({"subscribe_rulebooks": [2],
|
||||
"design_system_id": 3, "seed_systems": True}) is None
|
||||
assert validate_inception({"design_system_id": None}) is None
|
||||
assert "must be an object" in validate_inception([])
|
||||
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": [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": True})
|
||||
assert "true or false" in validate_inception({"seed_systems": "yes"})
|
||||
|
||||
|
||||
def test_normalize_choices_is_canonical_and_complete():
|
||||
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3], "exclude_always_on_rulebooks": [2]})
|
||||
assert out == {"exclude_always_on_rulebooks": [2], "subscribe_rulebooks": [1, 3],
|
||||
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3]})
|
||||
assert out == {"subscribe_rulebooks": [1, 3],
|
||||
"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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
commit, push and other hard-to-reverse actions.
|
||||
|
||||
The asymmetry is the whole argument, and it is what these tests pin: pulling
|
||||
when a push also arrived costs one redundant call; not pulling when the push
|
||||
never came costs the operator's rules entirely.
|
||||
The asymmetry is the whole argument, and it is what these tests pin: asking
|
||||
when a rule had already arrived costs one redundant call; not asking when
|
||||
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
|
||||
|
||||
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
|
||||
without also stating the pull.
|
||||
without also stating how to ask.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -34,8 +41,33 @@ import pathlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
# The pull instruction, however a surface phrases the surrounding prose.
|
||||
PULL = "list_always_on_rules"
|
||||
# THE PULL IS NOW THE ASK (milestone 394). This was `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
|
||||
# "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
|
||||
|
||||
|
||||
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 = []
|
||||
for path in SESSION_START_SURFACES:
|
||||
assert path.exists(), (
|
||||
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"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)))
|
||||
assert not missing, (
|
||||
f"these surfaces no longer tell the agent to call {PULL}(): {missing}. "
|
||||
f"The rules are pull-only and the push is best-effort, so a surface "
|
||||
f"that omits this leaves a session bound by nothing (#2198, #2497)."
|
||||
f"these surfaces never tell the agent how to ask for a rule "
|
||||
f"({ASK}): {missing}. Nothing is pushed and nothing is resident, so a "
|
||||
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
|
||||
# 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():
|
||||
def test_no_surface_names_the_push_without_stating_the_ask():
|
||||
"""The exact shape #2497 took.
|
||||
|
||||
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 = [
|
||||
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, (
|
||||
f"these surfaces describe the SessionStart push but never state the "
|
||||
f"explicit pull: {offenders}. The push is a delivery optimisation, not "
|
||||
f"the bridge — it can be absent without saying so. Name it if it helps, "
|
||||
f"but say to call {PULL}() regardless."
|
||||
f"these surfaces describe the SessionStart push but never state how to "
|
||||
f"ask: {offenders}. The push is a delivery optimisation, not the "
|
||||
f"bridge — it can be absent without saying so, and since milestone 394 "
|
||||
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.",
|
||||
why="the image ships no bash",
|
||||
verify_with="read the workflow's shell setting",
|
||||
tier="always_on",
|
||||
),
|
||||
# The actor is already gone — what SET NULL leaves behind.
|
||||
RuleVersion(
|
||||
rule_id=rule.id, user_id=None,
|
||||
title="The runner has no bash",
|
||||
statement="Use POSIX sh in run steps.",
|
||||
tier="always_on",
|
||||
),
|
||||
])
|
||||
await s.commit()
|
||||
@@ -263,4 +261,3 @@ async def test_the_text_survives(restored):
|
||||
assert by_statement["Use sh."].verify_with == (
|
||||
"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.project import Project
|
||||
from scribe.models.rulebook import Rulebook
|
||||
from scribe.services import inception as inception_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import canonical_systems as canonical_svc
|
||||
@@ -31,12 +30,12 @@ async def seeded():
|
||||
await s.flush()
|
||||
ids = {"owner": owner.id, "pid": project.id}
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
@@ -48,20 +47,20 @@ async def seeded():
|
||||
@pytest.mark.integration
|
||||
async def test_decide_applies_every_effect_and_records_last(seeded):
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
# Undecided: the always-on rulebook binds, nothing subscribed, no Systems.
|
||||
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
|
||||
# Undecided: nothing binds, because nothing has been subscribed. Before
|
||||
# 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)
|
||||
assert [r["id"] for r in defaults["always_on_rulebooks"]] == [seeded["always"]]
|
||||
assert [r["id"] for r in defaults["other_rulebooks"]] == [seeded["other"]]
|
||||
assert sorted(r["id"] for r in defaults["rulebooks"]) == sorted(
|
||||
[seeded["always"], seeded["other"]])
|
||||
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={
|
||||
"exclude_always_on_rulebooks": [seeded["always"]],
|
||||
"subscribe_rulebooks": [seeded["other"]],
|
||||
"design_system_id": None,
|
||||
"seed_systems": True,
|
||||
})
|
||||
assert out["effects"]["excluded"] == [seeded["always"]]
|
||||
assert out["effects"]["subscribed"] == [seeded["other"]]
|
||||
catalog = await canonical_svc.list_canonical_systems()
|
||||
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)
|
||||
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
|
||||
# departure is named, the subscription binds.
|
||||
assert await rulebooks_svc.list_always_on_rules(owner, project_id=pid) == []
|
||||
assert len(await rulebooks_svc.list_always_on_rules(owner)) == 1 # user-wide unchanged
|
||||
# The subscription is what binds, and it is the ONLY thing that does —
|
||||
# the unsubscribed rulebook contributes nothing even though it used to
|
||||
# bind every project by default.
|
||||
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 "dev is home" not in [r["title"] for r in applicable["rules"]]
|
||||
# The record, written last, says why.
|
||||
async with async_session() as s:
|
||||
project = await s.get(Project, pid)
|
||||
assert inception_svc.is_decided(project)
|
||||
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
|
||||
assert project.inception["choices"]["exclude_always_on_rulebooks"] == [seeded["always"]]
|
||||
# Re-deciding with seed again mints nothing twice; include reverses the exclusion.
|
||||
assert project.inception["choices"]["subscribe_rulebooks"] == [seeded["other"]]
|
||||
# Re-deciding with seed again mints nothing twice.
|
||||
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
|
||||
assert again["effects"]["systems_seeded"] == []
|
||||
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
|
||||
async def test_a_bad_decision_applies_nothing(seeded):
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
# Excluding a rulebook that is not always-on is refused BEFORE any effect.
|
||||
with pytest.raises(ValueError, match="not always-on"):
|
||||
# A subscription to a rulebook that is not yours is refused BEFORE any
|
||||
# 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={
|
||||
"exclude_always_on_rulebooks": [seeded["other"]], "seed_systems": True,
|
||||
"subscribe_rulebooks": [999999], "seed_systems": True,
|
||||
})
|
||||
assert await systems_svc.list_systems(owner, pid) == []
|
||||
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
|
||||
behaves exactly as it did before tiers existed. That is the one failure this
|
||||
whole design must not produce, and it is asserted first.
|
||||
2. A conditional rule is invisible to a project that doesn't work in its area,
|
||||
and arrives — binding, not suggested — to one that does.
|
||||
3. A `co_surfaces` partner arrives with its other half, which is the failure
|
||||
1. A rule is invisible to a project that doesn't work in its area, and
|
||||
arrives — binding, not suggested — to one that does. Area matching is
|
||||
DETERMINISTIC: a tag comparison, never a similarity score.
|
||||
2. 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.
|
||||
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_asyncio
|
||||
|
||||
from scribe.models import async_session
|
||||
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 rulebooks as rulebooks_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
|
||||
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`
|
||||
covers SUBSCRIBED ones. Conflating them is easy and would make these tests
|
||||
assert nothing, so the fixture carries one of each and every test says
|
||||
which payload it is about.
|
||||
Both are ordinary rulebooks since milestone 394; the fixture used to flag
|
||||
one always-on because that was a second, separate way to reach a project.
|
||||
Keeping two is still worth it: a rulebook nobody subscribed to must
|
||||
contribute nothing, and a fixture with only the subscribed one could not
|
||||
tell "correctly scoped" from "returns everything".
|
||||
"""
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, "surfacing_owner")
|
||||
@@ -43,10 +49,6 @@ async def world():
|
||||
await s.commit()
|
||||
|
||||
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")
|
||||
await rulebooks_svc.create_rule(
|
||||
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"]}
|
||||
|
||||
|
||||
@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
|
||||
@@ -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(
|
||||
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])
|
||||
|
||||
@@ -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(
|
||||
world["topic"], world["owner"], "Version names are labels",
|
||||
"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(
|
||||
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."""
|
||||
partner = await rulebooks_svc.create_rule(
|
||||
world["topic"], world["owner"], "Muted partner", "Should not arrive.",
|
||||
tier="conditional",
|
||||
)
|
||||
await rulebooks_svc.add_rule_relation(
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
await rulebooks_svc.update_rule(
|
||||
@@ -162,7 +162,6 @@ async def rulebook_of_three():
|
||||
stale = await rulebooks_svc.create_rule(
|
||||
topic.id, uid, "Bumps need a dashboard tick", "Tick it first.",
|
||||
verify_with="cat CI-runner/renovate/config.js",
|
||||
tier="conditional",
|
||||
)
|
||||
async with async_session() as s:
|
||||
row = await s.get(Rule, stale.id)
|
||||
@@ -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
|
||||
|
||||
|
||||
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):
|
||||
|
||||
@@ -412,10 +412,10 @@ async def test_create_project_with_inception_args_decides_via_mcp():
|
||||
decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}}
|
||||
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:
|
||||
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
|
||||
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}
|
||||
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
|
||||
async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project():
|
||||
applicable = {"rules": [], "project_rules": [], "truncated": False,
|
||||
"subscribed_rulebooks": [], "excluded_always_on": []}
|
||||
"subscribed_rulebooks": []}
|
||||
ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"}
|
||||
|
||||
async def run(project):
|
||||
@@ -466,6 +466,6 @@ def test_inception_routes_and_tool_are_registered():
|
||||
mcp = build_mcp_server()
|
||||
assert mcp._tool_manager.get_tool("decide_project_inception") is not None
|
||||
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
|
||||
|
||||
|
||||
@@ -225,7 +225,9 @@ def test_register_attaches_every_tool():
|
||||
# 26 through milestone 307, +2 for the staleness sweep (milestone 312),
|
||||
# +1 for a rule's edit history (milestone 323), +2 for preferences
|
||||
# (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
|
||||
assert "list_rulebooks" 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 "update_preference" 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 "suppress_rule_for_project" in mcp.names
|
||||
# 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
|
||||
|
||||
|
||||
@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
|
||||
@@ -454,7 +407,7 @@ def _fake_version(**over):
|
||||
"id": 5, "rule_id": 100, "user_id": 1,
|
||||
"title": "The runner has no bash", "statement": "Use sh.",
|
||||
"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",
|
||||
"expires_when": None,
|
||||
"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_rule", "create_project_rule", "rule_detail",
|
||||
"set_rule_systems", "add_rule_relation", "remove_rule_relation",
|
||||
"list_rules", "list_always_on_rules",
|
||||
"list_rules",
|
||||
"get_rule", "update_rule", "delete_rule",
|
||||
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
|
||||
"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
|
||||
|
||||
|
||||
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():
|
||||
|
||||
@@ -5,8 +5,9 @@ WHY THIS EXISTS
|
||||
#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
|
||||
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
|
||||
moment are different properties), applied to time instead of to tier.
|
||||
argument #3702 made about tiers (present in context and salient at the
|
||||
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
|
||||
|
||||
|
||||
@@ -167,14 +167,21 @@ async def test_the_arm_searches_on_its_OWN_bar_not_the_code_one():
|
||||
kw = search.await_args.kwargs
|
||||
assert kw["threshold"] == 0.81, "the arm is still using the code threshold"
|
||||
assert kw["limit"] == pc.RULEHINT_LIMIT
|
||||
# NO tier filter (#3702). The arms search every rule the caller owns,
|
||||
# because "already in the session" is not the same as "in front of the
|
||||
# reader at the moment it applies" — and relevance is the threshold's
|
||||
# job, not a category's. If this assertion is failing because a tier
|
||||
# argument came back, read the block above RULEHINT_LIMIT first: the
|
||||
# filter may legitimately return, but only carrying a measured reason.
|
||||
assert "tier" not in kw or kw["tier"] is None, (
|
||||
"the arm is filtering the rule corpus by tier again"
|
||||
# NO CATEGORY FILTER (#3702, repointed by 394). This asserted that the arm
|
||||
# passed no `tier`. That parameter no longer exists, so the assertion had
|
||||
# become one that could not fail — which rule 167 rates below having none.
|
||||
#
|
||||
# The claim it was making is still live, on the axis that DOES still exist:
|
||||
# the act arms narrow by nothing, so a preference reaches a write exactly
|
||||
# as a rule does. "Already in the session" was never the same as "in front
|
||||
# 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.
|
||||
|
||||
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)
|
||||
row = SimpleNamespace(
|
||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||
why="w", how_to_apply="h", order_index=0,
|
||||
when_to_apply="when", tier="conditional", kind="rule",
|
||||
when_to_apply="when", kind="rule",
|
||||
verify_with="cat some/file", expires_when="the file grows a shell",
|
||||
verified_at=checked, arose_from_id=99,
|
||||
created_at=checked, updated_at=checked,
|
||||
@@ -415,7 +415,7 @@ def test_rule_rows_keep_an_unverified_rule_unverified():
|
||||
row = SimpleNamespace(
|
||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||
why=None, how_to_apply=None, order_index=0,
|
||||
when_to_apply=None, tier="always_on", kind="rule",
|
||||
when_to_apply=None, kind="rule",
|
||||
verify_with=None, expires_when=None, verified_at=None,
|
||||
arose_from_id=None,
|
||||
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(
|
||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||
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,
|
||||
arose_from_id=None, created_at=stamp, updated_at=stamp,
|
||||
)
|
||||
|
||||
@@ -4,23 +4,9 @@ import pytest
|
||||
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")
|
||||
|
||||
|
||||
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) ──────────────────────────────────────────
|
||||
@@ -106,30 +92,6 @@ async def test_build_autoinject_hint_blank_query_returns_empty():
|
||||
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
|
||||
@@ -139,11 +101,7 @@ async def test_build_session_context_includes_project_when_scoped():
|
||||
# opposite of what this test is about.
|
||||
project = MagicMock(id=2, title="FabledScribe", goal="ship it",
|
||||
design_system_id=None)
|
||||
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"})), \
|
||||
patch("scribe.services.plugin_context.projects_svc.get_project",
|
||||
with 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=([], 4))):
|
||||
@@ -170,11 +128,7 @@ async def test_build_session_context_pushes_the_projects_design_system():
|
||||
"guidance": [], "token_count": 95,
|
||||
"token_groups": ["accent", "surface", "type"],
|
||||
}
|
||||
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"})), \
|
||||
patch("scribe.services.plugin_context.projects_svc.get_project",
|
||||
with 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))), \
|
||||
@@ -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
|
||||
session its rules too."""
|
||||
project = MagicMock(id=2, title="App", goal="", design_system_id=9)
|
||||
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"})), \
|
||||
patch("scribe.services.plugin_context.projects_svc.get_project",
|
||||
with 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))), \
|
||||
@@ -219,14 +169,10 @@ async def test_build_session_context_survives_an_unreadable_design_system():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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
|
||||
out = await build_session_context(
|
||||
user_id=7, project_id=0, unbound_repo="host/owner/repo",
|
||||
)
|
||||
from scribe.services.plugin_context import build_session_context
|
||||
out = await build_session_context(
|
||||
user_id=7, project_id=0, unbound_repo="host/owner/repo",
|
||||
)
|
||||
|
||||
ctx = out["context"]
|
||||
assert out["project"] is None
|
||||
@@ -289,16 +235,29 @@ async def test_build_process_manifest_truncates_long_preview():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_session_context_caps_length():
|
||||
many = [_rule(i, "x" * 200, 1) for i in range(200)]
|
||||
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)
|
||||
"""The cap still binds, and now has to be provoked rather than tripped.
|
||||
|
||||
assert len(out["context"]) <= 9000 + 60 # cap + truncation note
|
||||
assert "truncated" in out["context"]
|
||||
This used to hand the block 200 long rule titles, because the preload made
|
||||
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) --------------------------------------------------
|
||||
|
||||
@@ -14,7 +14,7 @@ def _no_exclusions():
|
||||
"""get_applicable_rules asks for the project's always-on exclusions
|
||||
(milestone 297) through its own session; these mocked-session tests
|
||||
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
|
||||
|
||||
|
||||
@@ -345,7 +345,7 @@ async def test_get_applicable_rules_surfaces_suppressed_with_context():
|
||||
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():
|
||||
"""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),
|
||||
))
|
||||
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
|
||||
# string across the always-on set is ~2k characters of payload.
|
||||
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
|
||||
|
||||
|
||||
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) ──────────────────────────
|
||||
@@ -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["expires_when"] == "dependencyDashboardApproval is turned off"
|
||||
assert row["when_to_apply"] == "when a dependency bump is in play"
|
||||
assert row["tier"] == "always_on"
|
||||
|
||||
|
||||
def test_never_verified_reports_no_day_count_rather_than_zero():
|
||||
@@ -495,13 +482,3 @@ def test_a_verified_row_counts_the_days():
|
||||
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