From 37f5407ef64dfdb6e7275cc85499c664f8dbfa7a Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 31 Oct 2026 01:05:40 +0000 Subject: [PATCH] feat(layer3): implement guardrail DSL parser and evaluator per RFC-0003 and the v0.4 contract Co-authored-by: emmanuel-a-otchere --- src/cots.ts | 13 +++ src/layer3/layer3.ts | 215 +++++++++++++++++++++++++++++++++++++++++++ tests/layer3.test.ts | 171 ++++++++++++++++++++++++++++++++++ 3 files changed, 399 insertions(+) create mode 100644 src/layer3/layer3.ts create mode 100644 tests/layer3.test.ts diff --git a/src/cots.ts b/src/cots.ts index 9c70c3e..38771d9 100644 --- a/src/cots.ts +++ b/src/cots.ts @@ -94,6 +94,19 @@ export interface DecisionRecord { created_at: ISO8601; /** Layer 2 Validation record (COTS schema v1.1.0, RFC-0001). Optional: producers that have not floored Layer 2 do not emit it. */ validation?: ValidationRecord; + /** Layer 3 GuardrailDecision (COTS schema v1.3.0, RFC-0003). Optional: producers that have not floored Layer 3 do not emit it. */ + 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/layer3/layer3.ts b/src/layer3/layer3.ts new file mode 100644 index 0000000..8dbd3c6 --- /dev/null +++ b/src/layer3/layer3.ts @@ -0,0 +1,215 @@ +/** + * Layer 3 (Business Guardrails). Parses the guardrail DSL, evaluates the + * domain's rules against a post-Layer-2 COTS, and attaches a + * GuardrailDecision to each DecisionRecord. + * + * Implements the v0.4 contract at + * https://github.com/Source-Engineering/source-engineering-architecture/blob/main/layer-contracts/layer-3-business-guardrails.md + * and the COTS schema v1.3.0 `decisions[*].guardrail` field per RFC-0003. + * + * Layer 3 is the layer that can reject. A binding rule's rejection stops the + * response and is recorded with the rule and rationale: no silent rejection. + */ + +import type { Cots, DecisionRecord, GuardrailDecision, GuardrailOutcome } from "../cots.js"; + +export type GuardrailAction = "reject" | "annotate" | "escalate"; + +export interface GuardrailRule { + rule_id: string; + /** The condition, referencing COTS fields. Evaluated per the condition grammar below. */ + when: (cots: Cots, decision: DecisionRecord) => boolean; + then: GuardrailAction; + rationale: string; +} + +export type Layer3ErrorReason = "guardrail-rules-malformed" | "domain-rules-missing"; + +export class Layer3Error extends Error { + constructor(public readonly reason: Layer3ErrorReason) { + super(reason); + } +} + +/** + * Parse the guardrail DSL: + * + * rule { + * when + * then + * with rationale "" + * } + * + * The condition language (v0.4) supports: + * constraint() == "" the COTS carries a constraint of scope with value + * constraint() != "" the COTS does not carry such a constraint + * flag() any decision carries a Layer 2 flag of this type + * source_expired() any source artifact's validity has expired + * decision_type == "" the COTS's decisions were produced by this decision-type + * + * Conditions compose with `and` / `or` and may be parenthesised. + */ +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 Layer3Error("guardrail-rules-malformed"); + } + const ruleBlocks = dsl.match(/rule\s+[^\s{]+\s*\{[^}]*\}/g); + if (ruleBlocks === null || ruleBlocks.length === 0) { + throw new Layer3Error("guardrail-rules-malformed"); + } + + 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 Layer3Error("guardrail-rules-malformed"); + } + const condition = whenMatch[1].trim(); + if (!CONDITION_RE.test(condition.replace(/\s+/g, " "))) { + throw new Layer3Error("guardrail-rules-malformed"); + } + + 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 Layer3Error("guardrail-rules-malformed"); +}; + +const OUTCOME_FOR_ACTION: Record = { + reject: "rejected", + annotate: "annotated", + escalate: "escalated", +}; + +export interface Layer3Options { + now?: string; + evaluatedBy?: string; +} + +export interface Layer3Result { + cots: Cots; + /** Decisions stopped by a binding rule, with the rule that fired. */ + rejected: { decision_id: string; rule_id: string; rationale: string }[]; + /** Decisions escalated to a human or organisational authority. */ + escalated: { decision_id: string; rule_id: string; rationale: string }[]; +} + +export class Layer3 { + constructor(private readonly rules: GuardrailRule[]) { + if (rules === undefined || rules === null) { + throw new Layer3Error("domain-rules-missing"); + } + if (!Array.isArray(rules) || rules.length === 0) { + throw new Layer3Error("domain-rules-missing"); + } + } + + /** Evaluate every rule against every decision. Returns the guarded COTS. */ + evaluate(cots: Cots, options: Layer3Options = {}): Layer3Result { + const now = options.now ?? new Date().toISOString(); + const evaluatedBy = options.evaluatedBy ?? "layer3-business-guardrails"; + const rejected: Layer3Result["rejected"] = []; + const escalated: Layer3Result["escalated"] = []; + + const decisions = cots.decisions.map((decision): DecisionRecord => { + let outcome: GuardrailOutcome = "accepted"; + let fired: GuardrailRule | undefined; + 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: evaluatedBy, + }; + + let next: DecisionRecord = { ...decision, guardrail }; + if (outcome === "rejected") { + next = { + ...next, + validation_status: { + status: "superseded", + last_changed_at: now, + last_changed_by: evaluatedBy, + }, + }; + rejected.push({ decision_id: decision.id, rule_id: fired!.rule_id, rationale: fired!.rationale }); + } else if (outcome === "escalated") { + next = { + ...next, + validation_status: { + status: "appealed", + last_changed_at: now, + last_changed_by: evaluatedBy, + }, + }; + 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/layer3.test.ts b/tests/layer3.test.ts new file mode 100644 index 0000000..ba0f40b --- /dev/null +++ b/tests/layer3.test.ts @@ -0,0 +1,171 @@ +/** + * Tests for the Layer 3 (Business Guardrails) module: DSL parsing, rule + * evaluation, GuardrailDecision attachment, and the contract's guarantees + * (no silent rejection, binding/advisory distinction, status updates). + * + * See: RFC-0003 and the Layer 3 contract. + */ + +import { describe, it, expect } from "vitest"; +import { Layer1 } from "../src/layer1/layer1.js"; +import { Layer2 } from "../src/layer2/layer2.js"; +import { Layer3, Layer3Error, parseRules } from "../src/layer3/layer3.js"; +import { MockModelAdapter } from "../src/adapters/mock-adapter.js"; +import { LegalSummarizationDecisionType } from "../src/decision-types/legal-summarization.js"; +import type { SourceArtifact } from "../src/cots.js"; + +const SAMPLE_SOURCE: SourceArtifact = { + id: "urn:source-engineering:source:test-001", + origin: { system_name: "Test", system_version: "1.0.0", configuration_hash: "0".repeat(64) }, + version: { content_hash: "1".repeat(64), issued_at: "2026-10-10T08:00:00Z" }, + authority: { asserted_by: "test", trust_score: 0.9 }, + temporal_validity: { valid_from: "2026-10-10T08:00:00Z", valid_until: null }, + content_reference: { uri: "https://example.com/test.pdf", media_type: "application/pdf" }, +}; + +const NOW = "2026-10-30T12:00:00Z"; + +const buildCots = async () => { + const layer1 = new Layer1(LegalSummarizationDecisionType, new MockModelAdapter()); + return layer1.invoke( + { document: { uri: "https://example.com/test.pdf", media_type: "application/pdf" } }, + [SAMPLE_SOURCE], + { + constraints: [ + { id: "urn:source-engineering:constraint:c1", type: "jurisdiction", scope: "all_decisions", value: "US-NY" }, + ], + } + ); +}; + +describe("Layer 3 rule parsing", () => { + it("parses a well-formed rule block", () => { + const rules = parseRules(` + rule ny-jurisdiction-reject { + when constraint(all_decisions) != "US-NY" + then reject + with rationale "Out-of-jurisdiction summaries require human sign-off." + } + `); + expect(rules).toHaveLength(1); + expect(rules[0].rule_id).toBe("ny-jurisdiction-reject"); + expect(rules[0].then).toBe("reject"); + }); + + it("returns guardrail-rules-malformed for a malformed rule block", () => { + expect(() => parseRules("rule broken { when ??? then reject }")).toThrow(Layer3Error); + expect(() => parseRules("")).toThrow(Layer3Error); + }); + + it("returns guardrail-rules-malformed for an unknown condition", () => { + expect(() => + parseRules(`rule bad { + when something_weird("x") + then reject + with rationale "No." + }`) + ).toThrow(/guardrail-rules-malformed/); + }); +}); + +describe("Layer 3 evaluation", () => { + it("rejects on a violated binding jurisdiction rule and updates validation_status", async () => { + const cots = await buildCots(); + const layer3 = new Layer3( + parseRules(` + rule ny-only { + when constraint(all_decisions) == "US-NY" + then reject + with rationale "The declared jurisdiction does not admit automated summarisation." + } + `) + ); + const result = layer3.evaluate(cots, { now: NOW }); + const d = result.cots.decisions[0]; + expect(d.guardrail!.decision).toBe("rejected"); + expect(d.guardrail!.binding).toBe(true); + expect(d.validation_status.status).toBe("superseded"); + expect(result.rejected).toHaveLength(1); + expect(result.rejected[0].rule_id).toBe("ny-only"); + }); + + it("annotates on an advisory rule without binding force", async () => { + const cots = await buildCots(); + const layer3 = new Layer3( + parseRules(` + rule flag-advisory { + when flag(drift-detected) + then annotate + with rationale "Calibration drift was flagged at runtime; the reviewer should weigh this summary accordingly." + } + `) + ); + const layer2 = new Layer2({ drift: [{ segment: "contract-law", divergence: 0.2, threshold: 0.1 }] }); + const flagged = layer2.validate(cots, { now: NOW }); + const result = layer3.evaluate(flagged, { now: NOW }); + const d = result.cots.decisions[0]; + expect(d.guardrail!.decision).toBe("annotated"); + expect(d.guardrail!.binding).toBe(false); + expect(d.validation_status.status).toBe("pending"); + expect(result.rejected).toHaveLength(0); + }); + + it("escalates and sets the status to appealed", async () => { + const cots = await buildCots(); + const layer3 = new Layer3( + parseRules(` + rule escalate-drift { + when flag(drift-detected) + then escalate + with rationale "Drift detected; a human reviewer must sign off." + } + `) + ); + const layer2 = new Layer2({ drift: [{ segment: "contract-law", divergence: 0.2, threshold: 0.1 }] }); + const flagged = layer2.validate(cots, { now: NOW }); + const result = layer3.evaluate(flagged, { now: NOW }); + const d = result.cots.decisions[0]; + expect(d.guardrail!.decision).toBe("escalated"); + expect(d.validation_status.status).toBe("appealed"); + expect(result.escalated).toHaveLength(1); + }); + + it("accepts when no rule matches, with a recorded rationale", async () => { + const cots = await buildCots(); + const layer3 = new Layer3( + parseRules(` + rule eu-only { + when constraint(all_decisions) == "EU-DE" + then reject + with rationale "Wrong jurisdiction for this rule to fire." + } + `) + ); + const result = layer3.evaluate(cots, { now: NOW }); + const d = result.cots.decisions[0]; + expect(d.guardrail!.decision).toBe("accepted"); + expect(d.guardrail!.rationale).toContain("No guardrail rule matched"); + expect(result.rejected).toHaveLength(0); + }); + + it("returns domain-rules-missing for an empty rule set", () => { + expect(() => new Layer3([])).toThrow(/domain-rules-missing/); + }); + + it("no silent rejection: the rejected decision is still present with its guardrail record", async () => { + const cots = await buildCots(); + const layer3 = new Layer3( + parseRules(` + rule always { + when constraint(all_decisions) == "US-NY" + then reject + with rationale "This rule fires for the fixture's jurisdiction." + } + `) + ); + const result = layer3.evaluate(cots, { now: NOW }); + expect(result.cots.decisions).toHaveLength(cots.decisions.length); + expect(result.cots.decisions[0].guardrail!.decision).toBe("rejected"); + expect(result.cots.decisions[0].guardrail!.rationale).toContain("jurisdiction"); + }); +});