diff --git a/src/cots.ts b/src/cots.ts index dee466a..9c70c3e 100644 --- a/src/cots.ts +++ b/src/cots.ts @@ -22,7 +22,7 @@ export interface Cots { inputs: { source_artifacts: SourceArtifact[] }; context: Context; decisions: DecisionRecord[]; - evidence: { evidence_chain: EvidenceChainEntry[] }; + evidence: Evidence; validation: { feedback_surfaces: FeedbackSurface[] }; } @@ -104,6 +104,21 @@ export interface AuditEntry { notes: string; } +export type SourceType = "human-authored" | "human-curated" | "model-generated" | "mixed"; + +export type ChainTerminationPolicy = "terminal" | "bounded_recursion" | "unbounded_recursion"; + +export interface ChainTermination { + policy: ChainTerminationPolicy; + max_levels?: number; +} + +export interface Evidence { + /** RFC-0002 (COTS schema v1.2.0). Optional; the discipline's default is terminal. */ + chain_termination?: ChainTermination; + evidence_chain: EvidenceChainEntry[]; +} + export interface EvidenceChainEntry { decision_id: URI; supporting_evidence: EvidenceItem[]; @@ -127,6 +142,8 @@ export interface EvidenceItem { transformation: { operation: string; inputs: string[]; output: string; recorded_at: ISO8601 }; uncertainty: { calibrated_score: CalibratedScore; basis: string }; polarity: Polarity; + /** RFC-0002 (COTS schema v1.2.0). Optional; absent on pre-RFC-0002 COTSs. */ + source_type?: SourceType; } export interface FeedbackSurface { diff --git a/src/layer2/layer2.ts b/src/layer2/layer2.ts index 79d2781..cf22f74 100644 --- a/src/layer2/layer2.ts +++ b/src/layer2/layer2.ts @@ -11,7 +11,7 @@ * Layer 3, which is the layer that can reject. */ -import type { Cots, DecisionRecord, Context, ValidationRecord } from "../cots.js"; +import type { Cots, DecisionRecord, Context, ValidationRecord, DecisionOnValidation } from "../cots.js"; export type FlagType = | "drift-detected" @@ -101,6 +101,29 @@ const validateDecision = ( }; }; +const validateRecursiveChains = (cots: Cots, now: string): ValidationRecord["flags"] => { + const flags: ValidationRecord["flags"] = []; + const termination = cots.evidence.chain_termination; + + for (const chain of cots.evidence.evidence_chain) { + const items = [...chain.supporting_evidence, ...chain.contradicting_evidence]; + const recursive = items.filter((item) => item.source_type === "model-generated"); + if (recursive.length > 0) { + const policy = termination?.policy ?? "terminal"; + const admitted = policy === "bounded_recursion" || policy === "unbounded_recursion"; + flags.push({ + flag_type: "segment-signal-divergence", + message: admitted + ? `Recursive evidence chain: ${recursive.length} model-generated evidence item(s) under policy '${policy}'. The chain does not terminate at a human-readable source. (RFC-0002)` + : `Recursive evidence chain under terminal policy: ${recursive.length} model-generated evidence item(s). The chain must resolve to human-authored sources. (RFC-0002)`, + detected_at: now, + }); + } + } + + return flags; +}; + export class Layer2 { constructor(private readonly calibration: CalibrationData) {} @@ -118,9 +141,17 @@ export class Layer2 { const now = options.now ?? new Date().toISOString(); const validatedBy = options.validatedBy ?? "layer2-runtime-validation"; + const recursiveFlags = validateRecursiveChains(cots, now); + const decisions = cots.decisions.map((decision) => { const record = validateDecision(decision, cots.context, this.calibration, now); - return { ...decision, validation: { ...record, validated_by: validatedBy } }; + const flags = [...record.flags, ...recursiveFlags]; + const decision_on_validation: DecisionOnValidation = + flags.length > 0 ? (record.decision_on_validation === "escalated" ? "escalated" : "flagged") : "passed"; + return { + ...decision, + validation: { ...record, flags, decision_on_validation, validated_by: validatedBy }, + }; }); return { ...cots, decisions }; diff --git a/tests/layer2.test.ts b/tests/layer2.test.ts index 2596e64..2b4d530 100644 --- a/tests/layer2.test.ts +++ b/tests/layer2.test.ts @@ -100,3 +100,38 @@ describe("Layer 2 (Runtime Validation)", () => { expect(cots.decisions[0].validation).toBeUndefined(); }); }); + +describe("Layer 2 recursive chain flagging (RFC-0002)", () => { + const buildRecursiveCots = async () => { + const cots = await buildCots(); + cots.evidence.chain_termination = { policy: "bounded_recursion", max_levels: 1 }; + cots.evidence.evidence_chain[0].supporting_evidence[0].source_type = "model-generated"; + return cots; + }; + + it("flags a COTS with model-generated evidence (bounded policy)", async () => { + const cots = await buildRecursiveCots(); + const layer2 = new Layer2({}); + const validated = layer2.validate(cots, { now: NOW }); + const v = validated.decisions[0].validation!; + expect(v.decision_on_validation).toBe("flagged"); + expect(v.flags.some((f) => f.message.includes("Recursive evidence chain"))).toBe(true); + }); + + it("flags a model-generated item even under the terminal default", async () => { + const cots = await buildRecursiveCots(); + cots.evidence.chain_termination = undefined; + const layer2 = new Layer2({}); + const validated = layer2.validate(cots, { now: NOW }); + expect(validated.decisions[0].validation!.flags.some((f) => f.message.includes("terminal policy"))).toBe(true); + }); + + it("does not flag a COTS whose evidence is human-authored", async () => { + const cots = await buildCots(); + cots.evidence.chain_termination = { policy: "terminal" }; + cots.evidence.evidence_chain[0].supporting_evidence[0].source_type = "human-authored"; + const layer2 = new Layer2({}); + const validated = layer2.validate(cots, { now: NOW }); + expect(validated.decisions[0].validation!.decision_on_validation).toBe("passed"); + }); +});