Files
FabledScribe/frontend/src/api/rulebooks.ts
T
bvandeusenandClaude Opus 5 b97f57ee7f feat(rules): the staleness sweep — which standing rules assert a fact nobody has confirmed (#3097, milestone 312 step 3)
The query the last two steps were storage for. `rules_due_for_verification`
returns every rule carrying a `verify_with`, ordered by `verified_at` ASC
NULLS FIRST, each row carrying the check IN FULL — the opposite call from
rule_brief, because the reader is about to go and run it.

NULLS FIRST is the ordering this turns on. Postgres sorts NULLs last on an
ASC ordering, which would put the rules nobody has ever confirmed BEHIND
every rule someone once looked at. Exactly backwards: a claim with no
evidence at all outranks an old one.

Rules with no check never appear, and that is the property that keeps the
list worth reading. Most rules are decisions — no truth value, nothing to go
and check. If they appeared here the sweep would be the rulebook.

`mark_rule_verified(rule_id, still_true)` closes the loop, asymmetrically:
passing writes a stamp, FAILING WRITES NOTHING. There is no "verified false"
state because a rule whose check failed is not in a special condition, it is
wrong — and recording the failure as a flag would let it sit there being
false with the sweep satisfied that someone had looked. So it stays at the
top until someone corrects or retires it, and the response says so.

An unrecognised `tier` filter raises rather than falling back. _valid_tier's
silent always_on default is right for a WRITE — a typo should leave a rule
binding — and wrong for a FILTER, where the same fallback quietly answers a
different question and returns a short list that reads as good news.

Deliberately NOT filterable by project: a project reaches rules through
project scope, subscriptions, always-on rulebooks and exclusions, and a
filter missing one of those paths would UNDER-report — the exact failure
this surface exists to prevent. Said so in the docstring rather than
shipping a half-correct filter.

Ownership-scoped like every other rule read (owned rulebook, or owned
project), in ONE statement with an OR across the XOR rather than two queries
merged in Python, so the ordering is the database's and cannot disagree with
itself. Note that rules have no sharing ACL in this schema — no rule_shares,
no rulebook_shares — so there is no wider set for access.py to consult here.

Also fixes a test title that had been lying for ten tools: "all sixteen
tools" asserted 26. The number now lives only in the assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 13:43:57 -04:00

340 lines
12 KiB
TypeScript

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
* a workaround: merging two rules into one row, writing an override as a
* near-copy, or leaving a local addendum with nothing to say it is one.
*/
export type RuleRelationKind = "co_surfaces" | "overrides" | "elaborates";
export interface RuleRelation {
id: number;
kind: RuleRelationKind;
/** The rule at the OTHER end. */
rule_id: number;
direction: "outgoing" | "incoming";
note: string;
}
export interface Rulebook {
id: number;
owner_user_id: number;
title: string;
description: string;
always_on: boolean;
created_at: string | null;
updated_at: string | null;
}
export interface RulebookTopic {
id: number;
rulebook_id: number;
title: string;
description: string;
order_index: number;
created_at: string | null;
updated_at: string | null;
}
export interface Rule {
id: number;
topic_id: number | null;
project_id: number | null;
title: string;
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;
/**
* How to check the rule is still true, and the state that ends it. Set
* only on a rule that asserts a fact about something outside the
* operator's control; empty on a rule that is a decision, which is most
* of them. Empty is meaningful, not missing.
*/
verify_with: string;
expires_when: string;
/** When the check last passed. Null means never checked. */
verified_at: string | null;
/** The note or task that caused this rule, if one was recorded. */
arose_from_id: number | null;
order_index: number;
created_at: string | null;
updated_at: string | null;
/** Present only when the rule has them (the server omits empty keys). */
systems?: { id: number; name: string }[];
relations?: RuleRelation[];
}
/**
* A rule as a LIST ROW — services.rulebooks.rule_brief's output. Carries the
* age deliberately: a rule written before the capability it duplicates is
* otherwise indistinguishable, at a glance, from one still doing work.
*/
export interface RuleHeader {
id: number;
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;
arose_from_id?: number;
/**
* Present ONLY on a rule that carries a check — the presence of the key
* is itself the signal that this rule asserts a fact that can go false.
* A date (YYYY-MM-DD), or the literal "never".
*/
last_verified?: string;
}
export interface ApplicableRules {
// Both lists are rule_brief's output — the SAME builder, so they are
// described the same way here rather than as two hand-written shapes that
// drift from it and from each other (which is what the server side had).
rules: (RuleHeader & {
topic_title: string;
rulebook_id: number;
rulebook_title: string;
})[];
project_rules: RuleHeader[];
suppressed_rules: {
id: number;
title: string;
topic_id: number;
topic_title: string;
rulebook_id: number;
rulebook_title: string;
}[];
suppressed_topics: {
id: number;
title: string;
rulebook_id: number;
rulebook_title: string;
}[];
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 ───────────────────────────────────────────────────────
export async function listRulebooks(): Promise<Rulebook[]> {
const data = await apiGet<{ rulebooks: Rulebook[] }>("/api/rulebooks");
return data.rulebooks;
}
export async function getRulebook(id: number): Promise<Rulebook & { topics: RulebookTopic[] }> {
return apiGet(`/api/rulebooks/${id}`);
}
export async function createRulebook(data: { title: string; description?: string }): Promise<Rulebook> {
return apiPost("/api/rulebooks", data);
}
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string; always_on: boolean }>): Promise<Rulebook> {
return apiPatch(`/api/rulebooks/${id}`, data);
}
export async function deleteRulebook(id: number): Promise<void> {
return apiDelete(`/api/rulebooks/${id}`);
}
// ── Topics ─────────────────────────────────────────────────────────
export async function listTopics(rulebookId: number): Promise<RulebookTopic[]> {
const data = await apiGet<{ topics: RulebookTopic[] }>(`/api/rulebooks/${rulebookId}/topics`);
return data.topics;
}
export async function createTopic(rulebookId: number, data: { title: string; description?: string; order_index?: number }): Promise<RulebookTopic> {
return apiPost(`/api/rulebooks/${rulebookId}/topics`, data);
}
export async function updateTopic(id: number, data: Partial<{ title: string; description: string; order_index: number }>): Promise<RulebookTopic> {
return apiPatch(`/api/rulebook-topics/${id}`, data);
}
export async function deleteTopic(id: number): Promise<void> {
return apiDelete(`/api/rulebook-topics/${id}`);
}
// ── Rules ──────────────────────────────────────────────────────────
export async function listRules(filters: { rulebook_id?: number; topic_id?: number; project_id?: number } = {}): Promise<Rule[]> {
const params = new URLSearchParams();
if (filters.rulebook_id) params.set("rulebook_id", String(filters.rulebook_id));
if (filters.topic_id) params.set("topic_id", String(filters.topic_id));
if (filters.project_id) params.set("project_id", String(filters.project_id));
const qs = params.toString();
const data = await apiGet<{ rules: Rule[] }>(`/api/rules${qs ? `?${qs}` : ""}`);
return data.rules;
}
export async function getRule(id: number): Promise<Rule> {
return apiGet(`/api/rules/${id}`);
}
/**
* The fields both write paths accept. `system_ids` REPLACES a rule's areas.
*
* Sending "" for a nullable text field CLEARS it here — the server maps an
* empty string to NULL, so an emptied form input does what it looks like it
* does. (The MCP door reads "" as "leave unchanged" and needs an explicit
* clear_fields list instead; the two idioms reach the same state.)
*/
export interface RuleWrite {
title: string;
statement: string;
when_to_apply: string;
tier: RuleTier;
why: string;
how_to_apply: string;
order_index: number;
system_ids: number[];
arose_from_id: number | null;
verify_with: string;
expires_when: string;
}
export async function createRule(topicId: number, data: Partial<RuleWrite> & { title: string; statement: string }): Promise<Rule> {
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
}
export async function updateRule(id: number, data: Partial<RuleWrite>): Promise<Rule> {
return apiPatch(`/api/rules/${id}`, data);
}
/** Draw a typed edge from one rule to another. Idempotent. */
export async function relateRules(
fromRuleId: number,
data: { to_rule_id: number; kind: RuleRelationKind; note?: string },
): Promise<{ id: number }> {
return apiPost(`/api/rules/${fromRuleId}/relations`, data);
}
export async function unrelateRules(relationId: number): Promise<void> {
return apiDelete(`/api/rule-relations/${relationId}`);
}
export async function deleteRule(id: number): Promise<void> {
return apiDelete(`/api/rules/${id}`);
}
// ── Subscriptions ──────────────────────────────────────────────────
export async function subscribeProject(projectId: number, rulebookId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/rulebook-subscriptions`, { rulebook_id: rulebookId });
}
export async function unsubscribeProject(projectId: number, rulebookId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/rulebook-subscriptions/${rulebookId}`);
}
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
return apiGet(`/api/projects/${projectId}/rules`);
}
export async function createProjectRule(
projectId: number,
data: Partial<RuleWrite> & { statement: string },
): Promise<Rule> {
return apiPost(`/api/projects/${projectId}/rules`, data);
}
// ── Suppressions ───────────────────────────────────────────────────
export async function suppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/suppressions/rules/${ruleId}`, {});
}
export async function unsuppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/rules/${ruleId}`);
}
export async function suppressTopicForProject(projectId: number, topicId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/suppressions/topics/${topicId}`, {});
}
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
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
* in full — the reader is about to go and run it, so the text is the point
* of the payload rather than the bloat a listing avoids.
*/
export interface RuleVerificationRow {
id: number;
title: string;
statement: string;
tier: RuleTier;
topic_id: number | null;
project_id: number | null;
when_to_apply: string;
verify_with: string;
expires_when: string;
/** A date (YYYY-MM-DD), or the literal "never". */
last_verified: string | null;
/** Null when never verified — "never" is not zero days ago. */
days_since_verified: number | null;
}
/**
* Rules asserting a fact that may have gone false, oldest verification
* first, never-checked at the top. Rules without a check never appear:
* they are decisions, and there is nothing to go and check.
*
* Not filterable by project — a project reaches rules through project
* scope, subscriptions, always-on rulebooks and exclusions, and a filter
* missing one of those paths would under-report.
*/
export async function listRulesDueForVerification(opts: {
olderThanDays?: number;
tier?: RuleTier;
neverOnly?: boolean;
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
const q = new URLSearchParams();
if (opts.olderThanDays) q.set("older_than_days", String(opts.olderThanDays));
if (opts.tier) q.set("tier", opts.tier);
if (opts.neverOnly) q.set("never_only", "true");
const qs = q.toString();
return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`);
}
/**
* Record that a rule's check was RUN, and what it said.
*
* `stillTrue: false` writes nothing on purpose — a rule whose check failed
* is not in a recordable state, it is wrong — so it stays at the top of the
* sweep until someone corrects or retires it.
*/
export async function markRuleVerified(
id: number, stillTrue = true,
): Promise<Rule & { verified: boolean }> {
return apiPost(`/api/rules/${id}/verify`, { still_true: stillTrue });
}