CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Failing after 12s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Failing after 35s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Milestone 394, steps 5-8. Operator: "remove the always on rule functionality as the goal was to not have it at all since it didn't seem to work as expected." Unconditional preload had three failures the retrieval arms do not. It could not be MEASURED — a resident rule is in the context whether or not it mattered, so nothing distinguished "this governed the act" from "this was scenery", and it was the one surface structurally exempt from the scoreboard judging every other. It was SUMMARISED AWAY by compaction while the session went on believing it held the rules. And it CROWDED OUT the few rules that applied with the thirty that did not. WHAT GOES Schema (0100): rules.tier + ck_rules_tier, rule_versions.tier, rulebooks.always_on, and project_rulebook_exclusions — a table recording a project's opt-out of something that no longer binds it unasked. Tools: list_always_on_rules, exclude_always_on_rulebook, include_always_on_rulebook. Service: the same three plus rules_etag_for, _valid_tier and the whole etag family. The SessionStart preload and the write-path staleness arm go with them: nothing is resident, so nothing can have drifted since a session loaded it. THREE CALLS WORTH REVIEWING enter_project got NARROWER, not wider. Its filter was `always_on OR area-tagged`; dropping the tier arm leaves the deterministic half, so a project with no canonical-tagged Systems gets no bulk rules and reaches them by retrieval instead. Dropping the whole clause would have made that payload bigger than the preload this milestone deletes. Backups import tolerantly. A pre-394 archive carries tier, always_on and the retired inception choice; none is read, and the exclusion key is DROPPED rather than remapped, because restoring it would write data that validate_inception now rejects as unknown. The migration is irreversible in the way that matters and says so: downgrade recreates the columns at their defaults and cannot restore which rules were always-on. A value invented to fill a hole is not a measurement. THE INSTRUCTION SURFACES SAY THE HARDER THING Deleting "call list_always_on_rules()" is easy; replacing it is not, because the new model asks a session to trust something it cannot see. All three surfaces now say a session holds nothing, that rules arrive when work matches them, and — the half that got dangerous — that "no rule arrived" means "nothing matched", never "there is no rule". Under residency an empty session was rare and suspicious; it is now the ordinary state of most turns, so reading it as permission is wrong on nearly every turn rather than occasionally. That is #3720's defect at session scale. test_instruction_surfaces_agree is repointed rather than retired: its two halves collapsed into one instruction, and it gains a guard that every surface states what absence means. _INSTRUCTIONS is back at 1999/2000 — the inception clause paid for the longer HOW line. UI (rule 27, and the opportunity step 8 named) The tier selector is gone, and what replaces it is the point: `when_to_apply` is now the field that decides whether a rule is ever seen, so the editor marks it required, warns while it is empty, and both rule lists badge a trigger-less rule "never surfaces". A rule without one is not quiet, it is unreachable. TESTS Two files deleted outright — test_rules_etag.py and test_inception_rules.py tested subsystems that no longer exist. Elsewhere obsolete cases were removed and the rest repointed. One deserves naming: the wiring test asserted the act arms pass no `tier`, which had become an assertion that could not fail. It is repointed onto `kind`, which does still exist and where the same claim is live — a preference must reach a write exactly as a rule does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
367 lines
13 KiB
TypeScript
367 lines
13 KiB
TypeScript
import type { RecordUsage } from "@/types/usage";
|
|
|
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
|
|
|
/** 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;
|
|
}
|
|
|
|
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;
|
|
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;
|
|
/** 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[];
|
|
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). */
|
|
}
|
|
|
|
// ── 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;
|
|
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);
|
|
}
|
|
|
|
/** 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 restoreRuleVersion, 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.
|
|
|
|
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}`);
|
|
}
|
|
|
|
|
|
/**
|
|
* 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 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;
|
|
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 });
|
|
}
|