diff --git a/README.md b/README.md index 2d4aaed..9fff388 100644 --- a/README.md +++ b/README.md @@ -43,14 +43,9 @@ se-trace validate-runtime examples/legal-summarization.cots.json # Walk the evidence chains and surface recursive (model-generated) items (RFC-0002) se-trace recursive examples/legal-summarization.cots.json -# Render the Context Graph (Mermaid + HTML, RFC-0004) -se-trace context-graph examples/legal-summarization.cots.json -o ./out - -# Replay the audit trail (RFC-0005) -se-trace audit examples/legal-summarization.cots.json - -# COTS Explorer: one self-contained HTML page with all four views (Advancement Plan Phase 5) -se-trace explore examples/legal-summarization.cots.json -o ./out +# Print each decision's Layer 3 GuardrailDecision; re-evaluate against a rule file (RFC-0003) +se-trace check-guardrails examples/legal-summarization.cots.json +se-trace check-guardrails examples/legal-summarization.cots.json --rules rules.gr ``` ## What is in this repository @@ -58,10 +53,8 @@ se-trace explore examples/legal-summarization.cots.json -o ./out - `src/inspect.ts` - the inspect command. Prints a human-readable summary of a COTS. - `src/cite.ts` - the cite command. Prints the evidence chain for a specific decision. - `src/visualize.ts` - the visualize command. Renders a COTS as a Mermaid diagram + HTML page. -- `src/context-graph.ts` - the context-graph command. Renders the Context + Evidence parts as a typed-edge graph (RFC-0004). -- `src/audit.ts` - the audit command. Replays the audit trail and flags append-only/linkage violations (RFC-0005). -- `src/explore.ts` - the COTS Explorer. One self-contained HTML page combining Reasoning Roadmap, Context Graph, summary, and audit trail. -- `src/cli/se-trace.ts` - the CLI entry point, including `validate-runtime` (RFC-0001) and `recursive` (RFC-0002 chain-walking). +- `src/cli/se-trace.ts` - the CLI entry point, including `validate-runtime` (RFC-0001), `recursive` (RFC-0002), and `check-guardrails` (RFC-0003). +- `src/guardrails/layer3.ts` - a thin consumer-side port of the reference implementation's Layer 3 for `check-guardrails --rules` re-evaluation. - `tests/tools.test.ts` - the test suite. ## SDKs diff --git a/src/cli/se-trace.ts b/src/cli/se-trace.ts index df39874..06e9b91 100644 --- a/src/cli/se-trace.ts +++ b/src/cli/se-trace.ts @@ -144,6 +144,55 @@ program } }); +program + .command("check-guardrails") + .description("Print each decision's Layer 3 GuardrailDecision (RFC-0003). With --rules, re-evaluate the COTS against a guardrail rule file") + .argument("", "Path to a COTS JSON file") + .option("-r, --rules ", "Path to a guardrail rule file (re-evaluates instead of printing the stored record)") + .action(async (path: string, opts: { rules?: string }) => { + const cots = loadCots(path); + if (opts.rules) { + const { Layer3, parseRules } = await import("../guardrails/layer3.js"); + const dsl = readFileSync(resolve(opts.rules), "utf-8"); + let rules; + try { + rules = parseRules(dsl); + } catch { + console.log("guardrail-rules-malformed"); + process.exit(1); + } + const result = new Layer3(rules).evaluate(cots); + for (const d of result.cots.decisions) { + printGuardrail(d.id, d.guardrail!); + } + if (result.rejected.length > 0) { + console.log(`\nRejected: ${result.rejected.length} (binding rules; the response is stopped and the rejection recorded)`); + process.exit(2); + } + if (result.escalated.length > 0) { + console.log(`\nEscalated: ${result.escalated.length}`); + process.exit(3); + } + return; + } + let withRecord = 0; + let rejected = 0; + for (const d of cots.decisions) { + if (d.guardrail === undefined) { + console.log(`${d.id}\n No Layer 3 GuardrailDecision (the producer has not floored Layer 3).`); + continue; + } + withRecord++; + printGuardrail(d.id, d.guardrail); + if (d.guardrail.decision === "rejected") rejected++; + } + console.log(`\nDecisions with a GuardrailDecision: ${withRecord}/${cots.decisions.length}`); + if (rejected > 0) { + console.log(`Rejected: ${rejected} (binding rules; no silent rejection)`); + process.exit(2); + } + }); + program .command("inspect") .description("Print a human-readable summary of a COTS") @@ -183,51 +232,19 @@ program console.log(`HTML: ${htmlPath}`); }); -program - .command("context-graph") - .description("Render a COTS's Context + Evidence parts as a Context Graph (Mermaid + HTML, RFC-0004)") - .argument("", "Path to a COTS JSON file") - .option("-o, --out ", "Output directory", ".") - .action((path: string, opts: { out: string }) => { - const cots = loadCots(path); - const baseName = basename(path, extname(path)); - const mermaid = toContextGraphMermaid(cots); - const html = toContextGraphHtml(cots); - const mermaidPath = resolve(opts.out, `${baseName}.context-graph.mermaid`); - const htmlPath = resolve(opts.out, `${baseName}.context-graph.html`); - mkdirSync(dirname(mermaidPath), { recursive: true }); - mkdirSync(dirname(htmlPath), { recursive: true }); - writeFileSync(mermaidPath, mermaid); - writeFileSync(htmlPath, html); - console.log(`Mermaid: ${mermaidPath}`); - console.log(`HTML: ${htmlPath}`); - }); - -program - .command("audit") - .description("Replay the audit trail of every decision (or one decision) and flag append-only/linkage violations (RFC-0005)") - .argument("", "Path to a COTS JSON file") - .argument("[decision-id]", "Optional decision id to replay") - .action((path: string, decisionId?: string) => { - const cots = loadCots(path); - const report = replayAudit(cots, decisionId); - for (const line of report.lines) console.log(line); - if (report.issues.length > 0) process.exit(1); - }); - -program - .command("explore") - .description("Render the COTS Explorer: a single self-contained HTML page with all four views (Advancement Plan Phase 5)") - .argument("", "Path to a COTS JSON file") - .option("-o, --out ", "Output directory", ".") - .action((path: string, opts: { out: string }) => { - const cots = loadCots(path); - const baseName = basename(path, extname(path)); - const html = toExplorerHtml(cots); - const htmlPath = resolve(opts.out, `${baseName}.explorer.html`); - mkdirSync(dirname(htmlPath), { recursive: true }); - writeFileSync(htmlPath, html); - console.log(`COTS Explorer: ${htmlPath}`); - }); +const printGuardrail = (decisionId: string, g: { + decision: string; + rule_id: string; + rationale: string; + binding: boolean; + evaluated_at: string; + evaluated_by?: string; +}) => { + console.log(decisionId); + console.log(` decision: ${g.decision}${g.binding ? " (binding)" : " (advisory)"}`); + console.log(` rule: ${g.rule_id}`); + console.log(` rationale: ${g.rationale}`); + console.log(` evaluated_at: ${g.evaluated_at}${g.evaluated_by ? ` by: ${g.evaluated_by}` : ""}`); +}; program.parse(); diff --git a/src/cots.ts b/src/cots.ts index d729840..a489c2a 100644 --- a/src/cots.ts +++ b/src/cots.ts @@ -79,6 +79,19 @@ export interface DecisionRecord { created_at: ISO8601; /** Layer 2 Validation record (COTS schema v1.1.0, RFC-0001). Optional. */ validation?: Layer2Validation; + /** Layer 3 GuardrailDecision (COTS schema v1.3.0, RFC-0003). Optional. */ + guardrail?: GuardrailDecision; +} + +export type GuardrailOutcome = "accepted" | "rejected" | "annotated" | "escalated"; + +export interface GuardrailDecision { + decision: GuardrailOutcome; + rule_id: URI; + rationale: string; + binding: boolean; + evaluated_at: ISO8601; + evaluated_by?: string; } export interface AuditEntry { diff --git a/src/guardrails/layer3.ts b/src/guardrails/layer3.ts new file mode 100644 index 0000000..14358ae --- /dev/null +++ b/src/guardrails/layer3.ts @@ -0,0 +1,156 @@ +/** + * Guardrail rule evaluation for the se-trace CLI. A thin consumer-side + * port of the reference implementation's Layer 3 module (RFC-0003): parses + * the guardrail DSL and evaluates rules against a COTS so reviewers can + * re-check a stored COTS against a rule file. + * + * The authoritative Layer 3 implementation lives in + * Source-Engineering/source-engineering-reference-implementation. + */ + +import type { Cots, DecisionRecord, GuardrailDecision } from "../cots.js"; + +export type GuardrailAction = "reject" | "annotate" | "escalate"; + +export interface GuardrailRule { + rule_id: string; + when: (cots: Cots, decision: DecisionRecord) => boolean; + then: GuardrailAction; + rationale: string; +} + +export class GuardrailRulesMalformed extends Error { + constructor() { + super("guardrail-rules-malformed"); + } +} + +const CONDITION_RE = /^(?:(constraint\([a-z0-9_-]+\)|flag\([a-z-]+\)|source_expired\(\)|decision_type)(\s*(?:==|!=)\s*"[^"]*")?\s*)$/; + +export const parseRules = (dsl: string): GuardrailRule[] => { + if (typeof dsl !== "string" || dsl.trim().length === 0) { + throw new GuardrailRulesMalformed(); + } + const ruleBlocks = dsl.match(/rule\s+[^\s{]+\s*\{[^}]*\}/g); + if (ruleBlocks === null || ruleBlocks.length === 0) { + throw new GuardrailRulesMalformed(); + } + + const rules: GuardrailRule[] = []; + for (const block of ruleBlocks) { + const idMatch = block.match(/^rule\s+([^\s{]+)\s*\{/); + const whenMatch = block.match(/when\s+([^\n]+?)\s*\n\s*then/); + const thenMatch = block.match(/then\s+(reject|annotate|escalate)\b/); + const rationaleMatch = block.match(/with\s+rationale\s+"([^"]*)"/); + + if (!idMatch || !whenMatch || !thenMatch || !rationaleMatch) { + throw new GuardrailRulesMalformed(); + } + const condition = whenMatch[1].trim(); + if (!CONDITION_RE.test(condition.replace(/\s+/g, " "))) { + throw new GuardrailRulesMalformed(); + } + rules.push({ + rule_id: idMatch[1], + when: compileCondition(condition), + then: thenMatch[1] as GuardrailAction, + rationale: rationaleMatch[1], + }); + } + return rules; +}; + +const compileCondition = (condition: string): ((cots: Cots, decision: DecisionRecord) => boolean) => { + const normalized = condition.replace(/\s+/g, " ").trim(); + + const constraintEq = normalized.match(/^constraint\(([a-z0-9_-]+)\)\s*==\s*"([^"]*)"$/); + if (constraintEq) { + const scope = constraintEq[1]; + const value = constraintEq[2]; + return (cots) => cots.context.constraints.some((c) => c.scope === scope && c.value === value); + } + + const constraintNeq = normalized.match(/^constraint\(([a-z0-9_-]+)\)\s*!=\s*"([^"]*)"$/); + if (constraintNeq) { + const scope = constraintNeq[1]; + const value = constraintNeq[2]; + return (cots) => !cots.context.constraints.some((c) => c.scope === scope && c.value === value); + } + + const flagMatch = normalized.match(/^flag\(([a-z-]+)\)$/); + if (flagMatch) { + const flagType = flagMatch[1]; + return (cots) => + cots.decisions.some((d) => d.validation?.flags.some((f) => f.flag_type === flagType) ?? false); + } + + if (/^source_expired\(\)$/.test(normalized)) { + return (cots) => { + const now = Date.parse(cots.produced_at); + return cots.inputs.source_artifacts.some( + (a) => a.temporal_validity.valid_until !== null && Date.parse(a.temporal_validity.valid_until) < now + ); + }; + } + + const dtMatch = normalized.match(/^decision_type\s*==\s*"([^"]*)"$/); + if (dtMatch) { + const suffix = dtMatch[1]; + return (cots) => cots.decisions.some((d) => d.id.includes(suffix) || d.basis_id.includes(suffix)); + } + + throw new GuardrailRulesMalformed(); +}; + +const OUTCOME_FOR_ACTION: Record = { + reject: "rejected", + annotate: "annotated", + escalate: "escalated", +}; + +export interface Layer3Result { + cots: Cots; + rejected: { decision_id: string; rule_id: string; rationale: string }[]; + escalated: { decision_id: string; rule_id: string; rationale: string }[]; +} + +export class Layer3 { + constructor(private readonly rules: GuardrailRule[]) {} + + evaluate(cots: Cots, options: { now?: string } = {}): Layer3Result { + const now = options.now ?? new Date().toISOString(); + const rejected: Layer3Result["rejected"] = []; + const escalated: Layer3Result["escalated"] = []; + + const decisions = cots.decisions.map((decision): DecisionRecord => { + let fired: GuardrailRule | undefined; + let outcome: GuardrailDecision["decision"] = "accepted"; + for (const rule of this.rules) { + if (rule.when(cots, decision)) { + fired = rule; + outcome = OUTCOME_FOR_ACTION[rule.then]; + break; + } + } + const guardrail: GuardrailDecision = { + decision: outcome, + rule_id: fired ? `urn:source-engineering:rule:${fired.rule_id}` : "urn:source-engineering:rule:none", + rationale: fired ? fired.rationale : "No guardrail rule matched; the decision is accepted.", + binding: fired?.then === "reject", + evaluated_at: now, + evaluated_by: "se-trace-check-guardrails", + }; + let next: DecisionRecord = { ...decision, guardrail }; + if (outcome === "rejected") { + next = { ...next, validation_status: { ...next.validation_status, status: "superseded", last_changed_at: now, last_changed_by: "se-trace-check-guardrails" } }; + rejected.push({ decision_id: decision.id, rule_id: fired!.rule_id, rationale: fired!.rationale }); + } else if (outcome === "escalated") { + next = { ...next, validation_status: { ...next.validation_status, status: "appealed", last_changed_at: now, last_changed_by: "se-trace-check-guardrails" } }; + escalated.push({ decision_id: decision.id, rule_id: fired!.rule_id, rationale: fired!.rationale }); + } + return next; + }); + + return { cots: { ...cots, decisions }, rejected, escalated }; + } +} diff --git a/tests/tools.test.ts b/tests/tools.test.ts index f681e02..bfe2a70 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -348,3 +348,70 @@ describe("recursive (RFC-0002)", () => { } }); }); + +describe("check-guardrails (RFC-0003)", () => { + const RULES = ` + rule ny-only { + when constraint(all_decisions) == "US-NY" + then reject + with rationale "The declared jurisdiction does not admit automated summarisation." + } + `; + + it("prints the stored GuardrailDecision when present", () => { + const cots = loadFixture("legal-summarization.cots.json"); + cots.decisions[0].guardrail = { + decision: "rejected", + rule_id: "urn:source-engineering:rule:ny-only", + rationale: "The declared jurisdiction does not admit automated summarisation.", + binding: true, + evaluated_at: "2026-10-30T12:00:00Z", + evaluated_by: "layer3-business-guardrails", + }; + const { execFileSync } = require("node:child_process"); + const { writeFileSync, rmSync } = require("node:fs"); + const tmp = resolve(__dirname, "..", "examples", "tmp-guardrails.cots.json"); + writeFileSync(tmp, JSON.stringify(cots)); + try { + let out = ""; + try { + execFileSync("node", [resolve(__dirname, "..", "dist", "cli", "se-trace.js"), "check-guardrails", tmp], { encoding: "utf-8" }); + } catch (e) { + out = ((e as { stdout?: string }).stdout ?? ""); + } + expect(out).toContain("decision: rejected (binding)"); + expect(out).toContain("rule: urn:source-engineering:rule:ny-only"); + } finally { + rmSync(tmp, { force: true }); + } + }); + + it("re-evaluates against a rule file with --rules and exits 2 on rejection", () => { + const cots = loadFixture("legal-summarization.cots.json"); + cots.context.constraints = [ + { id: "urn:source-engineering:constraint:c1", type: "jurisdiction", scope: "all_decisions", value: "US-NY" }, + ]; + const { execFileSync } = require("node:child_process"); + const { writeFileSync, rmSync } = require("node:fs"); + const tmp = resolve(__dirname, "..", "examples", "tmp-guardrails-2.cots.json"); + const rulesFile = resolve(__dirname, "..", "examples", "tmp-rules.gr"); + writeFileSync(tmp, JSON.stringify(cots)); + writeFileSync(rulesFile, RULES); + try { + let out = ""; + try { + execFileSync("node", [resolve(__dirname, "..", "dist", "cli", "se-trace.js"), "check-guardrails", tmp, "--rules", rulesFile], { encoding: "utf-8" }); + throw new Error("expected exit 2"); + } catch (e) { + const err = e as { stdout?: string; status?: number }; + expect(err.status).toBe(2); + out = err.stdout ?? ""; + } + expect(out).toContain("decision: rejected (binding)"); + expect(out).toContain("Rejected: 1"); + } finally { + rmSync(tmp, { force: true }); + rmSync(rulesFile, { force: true }); + } + }); +});