Files
FabledScribe/frontend/src/api/rulebooks.ts
T
bvandeusenandClaude Opus 5.5 75cefe60e4
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 41s
feat(lessons): both records show the link in the web UI (milestone 440 step 7, #4635)
The lesson page gains an "Instance of" panel that holds one of three answers.
Unjudged is the fall-through, so it is stated rather than left blank:
- the rule(s) it was judged an instance of;
- "No rule — <why>";
- "Not yet judged".

Suggested links show what they rest on (distinct situations, and projects
when more than one), with Confirm / Not an instance for a reader who can
write. Rejected links stay listed with their reason.

The rule slide-over lists the lessons that are instances of it, plus the
suggestions waiting on a judgment. Each entry links through to the other
record, and kind and state wear the existing .rule-chip.

The write check moves to utils/permission.ts. The copy on the snippet page
looked for "edit", which the server never sends, so shared editors saw a
read-only page (#4640 "The snippet page hid its edit controls from shared
editors"). Guards pin the client's unions and write levels to the
service's, the model's and access.py's own values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:46:27 -04:00

405 lines
14 KiB
TypeScript

import type { RecordUsage } from "@/types/usage";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
import type { LinkState } from "@/api/lessons";
/** How a rule reaches a session (milestone 307). */
/**
* 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;
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;
}
/**
* What kind of instruction this is, and it is about FORCE, not importance.
*
* A `rule` must be FOLLOWED — ignoring it breaks something. It is the
* operator's decision, so it changes when they change it. A `preference` is
* how they want work DONE — ignoring it costs consistency, not correctness —
* and the agent rewrites it in the ordinary course of working, which is what
* makes the drift surface necessary.
*
* Always sent by the server, never inferred from an absent key: "no kind
* field" and "kind is rule" must not be the same payload.
*/
export type RuleKind = "rule" | "preference";
export interface Rule {
id: number;
topic_id: number | null;
project_id: number | null;
title: string;
statement: string;
kind: RuleKind;
/** WHEN this rule fires — the trigger, not the instruction. */
when_to_apply: string;
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[];
/** The lessons that point at this rule (milestone 440) — the concrete
* situations judged instances of it, plus any suggested and awaiting a
* judgment. Readable lessons only; omitted when there are none. */
lessons?: { id: number; title: string; state: LinkState; note: string }[];
}
/**
* 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;
/** Unconditional on the wire (services.rulebooks.rule_brief). */
kind: RuleKind;
/** 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;
/**
* Surfaced-vs-opened counts from `rule_usage_events` (milestone 333).
* Zero-filled by the list route, so a rule predating the table reads as
* "never surfaced" rather than as a missing field — which for a while is
* every rule on every install.
*/
usage?: RecordUsage;
}
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[];
truncated: boolean;
}
// ── 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 }>): 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;
/** Writable from the editor: a preference is not a lesser rule, it is a
* different force, and the person writing it is the one who knows which. */
kind: RuleKind;
when_to_apply: string;
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);
}
/** Give a rule a new home: a topic makes it global, a project makes it that
* project's. Keeps its id, history, areas and relations (milestone 414). */
export async function moveRule(
id: number, to: { topic_id: number } | { project_id: number },
): Promise<Rule> {
return apiPost(`/api/rules/${id}/move`, to);
}
/** 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}`);
}
/**
* One entry in a rule's edit history.
*
* Each entry holds the text the edit REPLACED, not the text it introduced —
* so the newest entry is what the rule said before its most recent change,
* and what that change produced is the rule as it stands now. Read the other
* way round, every diff comes out backwards.
*
* The listing form omits the long fields; open one to get them.
*/
export interface RuleVersion {
id: number;
rule_id: number;
/** Who made the edit. Null when that account has since been deleted. */
user_id: number | null;
title: string;
created_at: string;
statement?: string;
why?: string;
how_to_apply?: string;
when_to_apply?: string;
verify_with?: string;
expires_when?: string;
}
export async function listRuleVersions(ruleId: number): Promise<RuleVersion[]> {
const data = await apiGet<{ versions: RuleVersion[] }>(
`/api/rules/${ruleId}/versions`,
);
return data.versions;
}
export async function getRuleVersion(
ruleId: number, versionId: number,
): Promise<RuleVersion> {
return apiGet<RuleVersion>(`/api/rules/${ruleId}/versions/${versionId}`);
}
// No restore FOR A RULE, deliberately (milestone 323). Putting an old wording
// back goes through updateRule, which snapshots what it replaces — so the
// undo stays visible in the history like any other edit.
//
// A preference is the exception and the server refuses anything else (409).
// 323's reasoning is that a rewrite is the operator's own decision; a
// preference's rewrite is the agent's, made mid-work without asking, so
// putting it back is a veto rather than an undo — and a veto that costs more
// than shrugging is not really supervision. Nothing is erased either way:
// the restore snapshots too, so the history GAINS the revert.
export async function restoreRuleVersion(
ruleId: number, versionId: number,
): Promise<Rule> {
return apiPost<Rule>(`/api/rules/${ruleId}/versions/${versionId}/restore`, {});
}
/**
* One preference that has been rewritten: what it said, what it says now, and
* what taught the change.
*
* Both texts ride along so the list can show the diff without a follow-up call
* per row — a listing that needs N round-trips to say what it means is one
* nobody scrolls, which would leave the drift as unsupervised as before.
*/
export interface PreferenceDrift {
rule: RuleHeader;
/** What it said BEFORE the latest rewrite. */
previous: {
id: number;
created_at: string | null;
title: string;
statement: string;
when_to_apply: string;
};
current: { title: string; statement: string; when_to_apply: string };
/**
* The record named by `arose_from_id` — what the change was learned from.
* Absent when the preference carries none, which is every one written
* before that field was required.
*/
taught_by?: { id: number; title: string };
}
export async function listPreferenceDrift(
limit?: number,
): Promise<PreferenceDrift[]> {
const qs = limit ? `?limit=${limit}` : "";
const data = await apiGet<{ drift: PreferenceDrift[] }>(`/api/rules/drift${qs}`);
return data.drift;
}
export async function deleteRule(id: number): Promise<void> {
return apiDelete(`/api/rules/${id}`);
}
// ── A project's rules ──────────────────────────────────────────────
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);
}
/**
* 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;
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 is bound by its own rules and by
* every global rule, and a filter that dropped the global ones would
* under-report.
*/
export async function listRulesDueForVerification(opts: {
olderThanDays?: number;
neverOnly?: boolean;
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
const q = new URLSearchParams();
if (opts.olderThanDays) q.set("older_than_days", String(opts.olderThanDays));
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 });
}