/**
* Rule library — runtime loader + query API for Pillar 3.
*
* Mirrors src/intelligence/library.ts. Scans every `*.yaml` file
* under `src/decision/rules/`, parses it into a structured Rule
* object, and indexes the results by id / type / domain / agent /
* status. Decision agents query the loaded library through the
* helpers exported here; they never touch the filesystem or the YAML
* directly. Adding or deprecating a rule means dropping/editing a
* YAML file — agent code does not change (per Pillar 3 spec §SME
* Rule Library).
*
* The library is loaded lazily on first query and cached for the
* process lifetime. `reloadLibrary()` clears the cache for tests.
*/
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import YAML from 'yaml';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const RULES_DIR = path.resolve(__dirname, 'rules');
/* -------------------------------------------------------------- *
* Types
* -------------------------------------------------------------- */
export type RuleType = 'interpretation' | 'visualization' | 'delivery';
export type RuleStatus = 'active' | 'deprecated' | 'under_review';
export type RuleAgent =
| 'output_ingestion'
| 'visualization'
| 'delivery';
export interface RuleAppliesTo {
readonly agent: RuleAgent | string;
readonly triggers: readonly string[];
}
export interface DisclosurePolicy {
readonly audience_clearance_check?: boolean | string;
readonly default_allowed_tiers?: readonly string[];
readonly default_restricted_tiers?: readonly string[];
readonly attachment_policy?: string;
}
export interface Rule {
readonly rule_id: string;
readonly name: string;
readonly type: RuleType;
readonly domain: string;
readonly applies_to: RuleAppliesTo;
readonly conditions: readonly unknown[];
readonly action: Readonly<Record<string, unknown>>;
readonly confidence_framework: Readonly<Record<string, unknown>>;
readonly disclosure_policy?: DisclosurePolicy;
readonly rationale: string;
readonly declared_by: string;
readonly declared_date: string;
readonly last_reviewed?: string;
readonly status: RuleStatus;
/** Filesystem path the entry came from, for the audit trail. */
readonly sourceFile: string;
}
export interface RuleQuery {
readonly type?: RuleType;
readonly domain?: string;
readonly agent?: RuleAgent | string;
/** Free-text triggers; matched as case-insensitive substring against
* each entry's `applies_to.triggers` lines. */
readonly triggers?: readonly string[];
/** Default 'active' only — pass 'any' to include deprecated. */
readonly statusFilter?: RuleStatus | 'any';
}
/* -------------------------------------------------------------- *
* Loader (lazy + cached)
* -------------------------------------------------------------- */
let cache: readonly Rule[] | null = null;
let cachePromise: Promise<readonly Rule[]> | null = null;
export async function loadLibrary(): Promise<readonly Rule[]> {
if (cache) return cache;
if (cachePromise) return cachePromise;
cachePromise = (async () => {
const files = await collectYamlFiles(RULES_DIR);
const out: Rule[] = [];
for (const file of files) {
try {
const raw = await readFile(file, 'utf8');
const parsed = YAML.parse(raw) as Partial<Rule> | null;
if (!parsed || typeof parsed !== 'object') continue;
const r = normalize(parsed, file);
if (r) out.push(r);
} catch (err) {
// eslint-disable-next-line no-console
console.warn(`[rule-library] skipped ${path.relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`);
}
}
cache = out;
return out;
})();
return cachePromise;
}
export function reloadLibrary(): void {
cache = null;
cachePromise = null;
}
/* -------------------------------------------------------------- *
* Query helpers
* -------------------------------------------------------------- */
export async function findRules(query: RuleQuery = {}): Promise<readonly Rule[]> {
const all = await loadLibrary();
const statusFilter = query.statusFilter ?? 'active';
return all.filter(r => {
if (statusFilter !== 'any' && r.status !== statusFilter) return false;
if (query.type && r.type !== query.type) return false;
if (query.domain && r.domain !== query.domain && r.domain !== 'all') return false;
if (query.agent && r.applies_to.agent !== query.agent) return false;
if (query.triggers && query.triggers.length > 0) {
const hay = r.applies_to.triggers.join(' \n ').toLowerCase();
const allMatch = query.triggers.every(t => hay.includes(t.toLowerCase()));
if (!allMatch) return false;
}
return true;
});
}
export async function getRule(id: string): Promise<Rule | null> {
const all = await loadLibrary();
return all.find(r => r.rule_id === id) ?? null;
}
export async function listIds(): Promise<readonly string[]> {
const all = await loadLibrary();
return all.map(r => r.rule_id).sort();
}
/* -------------------------------------------------------------- *
* Rule precedence resolution (spec §SME Rule Library §runtime).
*
* "If multiple rules match, resolves via rule-precedence (most
* specific match wins; domain-specific rules win over cross-
* domain; newer rules win over older if specificity matches)."
* -------------------------------------------------------------- */
export function resolvePrecedence(rules: readonly Rule[]): readonly Rule[] {
return [...rules].sort((a, b) => {
/* domain-specific beats domain="all" */
const ad = a.domain === 'all' ? 0 : 1;
const bd = b.domain === 'all' ? 0 : 1;
if (ad !== bd) return bd - ad;
/* more trigger specificity (more triggers declared) wins */
if (a.applies_to.triggers.length !== b.applies_to.triggers.length) {
return b.applies_to.triggers.length - a.applies_to.triggers.length;
}
/* newer declared_date wins */
if (a.declared_date !== b.declared_date) {
return a.declared_date < b.declared_date ? 1 : -1;
}
return a.rule_id.localeCompare(b.rule_id);
});
}
/* -------------------------------------------------------------- *
* Internals
* -------------------------------------------------------------- */
async function collectYamlFiles(dir: string): Promise<string[]> {
const out: string[] = [];
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return out;
}
for (const name of entries) {
const p = path.join(dir, name);
let s;
try {
s = await stat(p);
} catch {
continue;
}
if (s.isDirectory()) {
out.push(...(await collectYamlFiles(p)));
} else if (s.isFile() && /\.(yaml|yml)$/i.test(name)) {
out.push(p);
}
}
return out.sort();
}
function normalize(raw: Partial<Rule>, sourceFile: string): Rule | null {
const id = typeof raw.rule_id === 'string' ? raw.rule_id : '';
if (!id) return null;
const type = (raw.type as RuleType) ?? 'interpretation';
const status = (raw.status as RuleStatus) ?? 'active';
const applies = raw.applies_to ?? { agent: '', triggers: [] };
const triggers = Array.isArray(applies.triggers) ? applies.triggers.map(t => String(t)) : [];
return {
rule_id: id,
name: typeof raw.name === 'string' ? raw.name : id,
type,
domain: typeof raw.domain === 'string' ? raw.domain : 'all',
applies_to: {
agent: typeof applies.agent === 'string' ? applies.agent : '',
triggers,
},
conditions: Array.isArray(raw.conditions) ? raw.conditions : [],
action: (raw.action && typeof raw.action === 'object'
? raw.action
: {}) as Record<string, unknown>,
confidence_framework: (raw.confidence_framework && typeof raw.confidence_framework === 'object'
? raw.confidence_framework
: {}) as Record<string, unknown>,
disclosure_policy:
raw.disclosure_policy && typeof raw.disclosure_policy === 'object'
? (raw.disclosure_policy as DisclosurePolicy)
: undefined,
rationale: typeof raw.rationale === 'string' ? raw.rationale : '',
declared_by: typeof raw.declared_by === 'string' ? raw.declared_by : '',
declared_date: typeof raw.declared_date === 'string' ? raw.declared_date : '',
last_reviewed: typeof raw.last_reviewed === 'string' ? raw.last_reviewed : undefined,
status,
sourceFile: path.relative(process.cwd(), sourceFile),
};
}