BID · Console
Baseline · Intelligence · Decision
src/decision/library.ts 8,594 bytes · typescript
/**
 * 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),
  };
}