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 { const data = await apiGet<{ rulebooks: Rulebook[] }>("/api/rulebooks"); return data.rulebooks; } export async function getRulebook(id: number): Promise { return apiGet(`/api/rulebooks/${id}`); } export async function createRulebook(data: { title: string; description?: string }): Promise { return apiPost("/api/rulebooks", data); } export async function updateRulebook(id: number, data: Partial<{ title: string; description: string; always_on: boolean }>): Promise { return apiPatch(`/api/rulebooks/${id}`, data); } export async function deleteRulebook(id: number): Promise { return apiDelete(`/api/rulebooks/${id}`); } // ── Topics ───────────────────────────────────────────────────────── export async function listTopics(rulebookId: number): Promise { 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 { return apiPost(`/api/rulebooks/${rulebookId}/topics`, data); } export async function updateTopic(id: number, data: Partial<{ title: string; description: string; order_index: number }>): Promise { return apiPatch(`/api/rulebook-topics/${id}`, data); } export async function deleteTopic(id: number): Promise { return apiDelete(`/api/rulebook-topics/${id}`); } // ── Rules ────────────────────────────────────────────────────────── export async function listRules(filters: { rulebook_id?: number; topic_id?: number; project_id?: number } = {}): Promise { 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 { 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 & { title: string; statement: string }): Promise { return apiPost(`/api/rulebook-topics/${topicId}/rules`, data); } export async function updateRule(id: number, data: Partial): Promise { 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 { return apiDelete(`/api/rule-relations/${relationId}`); } export async function deleteRule(id: number): Promise { return apiDelete(`/api/rules/${id}`); } // ── Subscriptions ────────────────────────────────────────────────── export async function subscribeProject(projectId: number, rulebookId: number): Promise { await apiPost(`/api/projects/${projectId}/rulebook-subscriptions`, { rulebook_id: rulebookId }); } export async function unsubscribeProject(projectId: number, rulebookId: number): Promise { return apiDelete(`/api/projects/${projectId}/rulebook-subscriptions/${rulebookId}`); } export async function getProjectApplicableRules(projectId: number): Promise { return apiGet(`/api/projects/${projectId}/rules`); } export async function createProjectRule( projectId: number, data: Partial & { statement: string }, ): Promise { return apiPost(`/api/projects/${projectId}/rules`, data); } // ── Suppressions ─────────────────────────────────────────────────── export async function suppressRuleForProject(projectId: number, ruleId: number): Promise { await apiPost(`/api/projects/${projectId}/suppressions/rules/${ruleId}`, {}); } export async function unsuppressRuleForProject(projectId: number, ruleId: number): Promise { return apiDelete(`/api/projects/${projectId}/suppressions/rules/${ruleId}`); } export async function suppressTopicForProject(projectId: number, topicId: number): Promise { await apiPost(`/api/projects/${projectId}/suppressions/topics/${topicId}`, {}); } export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise { return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`); } // ── Always-on exclusions (milestone 297) ──────────────────────────────────── export async function excludeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise { await apiPost(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`, {}); } export async function includeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise { 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 { return apiPost(`/api/rules/${id}/verify`, { still_true: stillTrue }); }