Skip to content

Commit f48f219

Browse files
committed
feat(canon): ADR-0005 Decision Canon evaluator runtime (WP-2).
Adds role-based P/D/F evaluation, freshness age checks, and conformant-provider gates — interfaces and execution only; no policy packs.
1 parent 4f83def commit f48f219

10 files changed

Lines changed: 507 additions & 0 deletions

File tree

packages/canon/LICENSE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Apache-2.0

packages/canon/package.json

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "@loop-engine/canon",
3+
"version": "0.1.0",
4+
"private": false,
5+
"description": "Decision Canon evaluator runtime — role requirements over P/D/F (ADR-0005; no policy packs)",
6+
"license": "Apache-2.0",
7+
"type": "module",
8+
"main": "./dist/index.cjs",
9+
"module": "./dist/index.mjs",
10+
"types": "./dist/index.d.ts",
11+
"exports": {
12+
".": {
13+
"types": "./dist/index.d.ts",
14+
"import": "./dist/index.mjs",
15+
"require": "./dist/index.cjs"
16+
}
17+
},
18+
"files": ["dist", "README.md", "LICENSE"],
19+
"scripts": {
20+
"build": "tsup",
21+
"typecheck": "tsc --noEmit",
22+
"lint": "node -e \"console.log('canon lint')\"",
23+
"test": "vitest run"
24+
},
25+
"dependencies": {
26+
"@loop-engine/evidence": "workspace:*"
27+
},
28+
"devDependencies": {
29+
"@types/node": "^20.14.10",
30+
"tsup": "^8.4.0",
31+
"typescript": "^5.8.2",
32+
"vitest": "^3.0.7"
33+
},
34+
"publishConfig": {
35+
"access": "public"
36+
}
37+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
import { describe, expect, it } from "vitest";
3+
import { evaluateCanonPolicy, evaluateRoleRequirement } from "../evaluate.js";
4+
import type { CanonPolicyDocument } from "../types.js";
5+
6+
const supplierInvoicePolicy: CanonPolicyDocument = {
7+
decisionSystem: "supplier-invoice-approval",
8+
requiresEvidence: [
9+
{
10+
role: "source_invoice",
11+
provenance: "system-attested",
12+
definition: "not-applicable",
13+
freshness: { applicable: "not-applicable" },
14+
},
15+
{
16+
role: "semantic_spend_validation",
17+
provenance: "system-attested",
18+
definition: "certified",
19+
freshness: { applicable: "applicable", maxAgeMs: 86_400_000 },
20+
requiresConformantProvider: true,
21+
},
22+
],
23+
};
24+
25+
describe("canon evaluator", () => {
26+
it("skips definition check when requirement axis is not-applicable", () => {
27+
const result = evaluateRoleRequirement(
28+
{
29+
role: "source_invoice",
30+
definition: "governed",
31+
},
32+
{
33+
role: "source_invoice",
34+
qualification: {
35+
provenance: "system-attested",
36+
definition: "not-applicable",
37+
freshness: { applicable: "not-applicable" },
38+
},
39+
},
40+
);
41+
expect(result.satisfied).toBe(true);
42+
});
43+
44+
it("rejects unreviewed when certified is required", () => {
45+
const result = evaluateRoleRequirement(
46+
supplierInvoicePolicy.requiresEvidence[1]!,
47+
{
48+
role: "semantic_spend_validation",
49+
qualification: {
50+
provenance: "system-attested",
51+
definition: "unreviewed",
52+
freshness: {
53+
applicable: "applicable",
54+
snapshotFrozenAt: "2026-07-07T12:00:00.000Z",
55+
valueAsOf: "2026-07-07T11:58:00.000Z",
56+
},
57+
},
58+
providerId: "provider-json",
59+
conformanceSuiteVersion: "provider-conformance-0.1.0",
60+
},
61+
);
62+
expect(result.satisfied).toBe(false);
63+
expect(result.failures[0]?.code).toBe("definition_below_minimum");
64+
});
65+
66+
it("evaluates freshness age against binding clock", () => {
67+
const result = evaluateRoleRequirement(
68+
{
69+
role: "telemetry",
70+
freshness: { applicable: "applicable", maxAgeMs: 60_000 },
71+
},
72+
{
73+
role: "telemetry",
74+
qualification: {
75+
provenance: "origin-attested",
76+
definition: "not-applicable",
77+
freshness: {
78+
applicable: "applicable",
79+
snapshotFrozenAt: "2026-07-07T12:05:00.000Z",
80+
valueAsOf: "2026-07-07T12:00:00.000Z",
81+
},
82+
},
83+
evaluatedAt: "2026-07-07T12:00:30.000Z",
84+
},
85+
);
86+
expect(result.satisfied).toBe(true);
87+
});
88+
89+
it("evaluates full policy with missing role as failure", () => {
90+
const report = evaluateCanonPolicy(supplierInvoicePolicy, {});
91+
expect(report.satisfied).toBe(false);
92+
expect(report.roles).toHaveLength(2);
93+
});
94+
95+
it("passes supplier invoice policy when roles meet floors", () => {
96+
const report = evaluateCanonPolicy(supplierInvoicePolicy, {
97+
source_invoice: {
98+
role: "source_invoice",
99+
qualification: {
100+
provenance: "system-attested",
101+
definition: "not-applicable",
102+
freshness: { applicable: "not-applicable" },
103+
},
104+
},
105+
semantic_spend_validation: {
106+
role: "semantic_spend_validation",
107+
qualification: {
108+
provenance: "system-attested",
109+
definition: "certified",
110+
freshness: {
111+
applicable: "applicable",
112+
snapshotFrozenAt: "2026-07-07T12:00:00.000Z",
113+
valueAsOf: "2026-07-07T11:58:00.000Z",
114+
},
115+
},
116+
providerId: "provider-json",
117+
conformanceSuiteVersion: "provider-conformance-0.1.0",
118+
evaluatedAt: "2026-07-07T12:00:00.000Z",
119+
},
120+
});
121+
expect(report.satisfied).toBe(true);
122+
});
123+
});

packages/canon/src/evaluate.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright (c) Better Data, Inc.
3+
4+
import {
5+
definitionMeetsMinimum,
6+
provenanceMeetsMinimum,
7+
type DefinitionRung,
8+
type ProvenanceRung,
9+
} from "@loop-engine/evidence";
10+
import { freshnessAgeMs } from "./freshness.js";
11+
import type {
12+
CanonEvaluationFailure,
13+
CanonPolicyDocument,
14+
CanonPolicyEvaluationResult,
15+
EvidenceRoleRequirement,
16+
RoleEvaluationInput,
17+
RoleEvaluationResult,
18+
} from "./types.js";
19+
20+
function isConformantProvider(input: RoleEvaluationInput): boolean {
21+
if (input.providerConformant !== undefined) {
22+
return input.providerConformant;
23+
}
24+
return Boolean(input.providerId && input.conformanceSuiteVersion);
25+
}
26+
27+
function evaluateProvenance(
28+
requirement: EvidenceRoleRequirement,
29+
input: RoleEvaluationInput,
30+
): CanonEvaluationFailure[] {
31+
const required = requirement.provenance;
32+
if (!required) return [];
33+
34+
const actual = input.qualification.provenance;
35+
if (provenanceMeetsMinimum(actual, required)) {
36+
return [];
37+
}
38+
39+
return [{
40+
code: "provenance_below_minimum",
41+
message: `provenance ${actual} does not meet required ${required}`,
42+
role: requirement.role,
43+
axis: "provenance",
44+
}];
45+
}
46+
47+
function evaluateDefinition(
48+
requirement: EvidenceRoleRequirement,
49+
input: RoleEvaluationInput,
50+
): CanonEvaluationFailure[] {
51+
const required = requirement.definition;
52+
if (!required) return [];
53+
54+
const actual = input.qualification.definition;
55+
if (definitionMeetsMinimum(actual, required)) {
56+
return [];
57+
}
58+
59+
return [{
60+
code: "definition_below_minimum",
61+
message: `definition ${actual} does not meet required ${required}`,
62+
role: requirement.role,
63+
axis: "definition",
64+
}];
65+
}
66+
67+
function evaluateFreshness(
68+
requirement: EvidenceRoleRequirement,
69+
input: RoleEvaluationInput,
70+
): CanonEvaluationFailure[] {
71+
const required = requirement.freshness;
72+
if (!required) return [];
73+
74+
const actual = input.qualification.freshness;
75+
if (required.applicable === "not-applicable") {
76+
return [];
77+
}
78+
79+
if (actual.applicable === "not-applicable") {
80+
return [{
81+
code: "freshness_required",
82+
message: "freshness is required for this role but snapshot reports not-applicable",
83+
role: requirement.role,
84+
axis: "freshness",
85+
}];
86+
}
87+
88+
const evaluatedAt =
89+
input.evaluatedAt ?? actual.snapshotFrozenAt ?? actual.valueAsOf;
90+
const ageMs = freshnessAgeMs(actual, evaluatedAt, required.bindingClock);
91+
if (ageMs <= required.maxAgeMs) {
92+
return [];
93+
}
94+
95+
return [{
96+
code: "freshness_stale",
97+
message: `freshness age ${ageMs}ms exceeds max ${required.maxAgeMs}ms`,
98+
role: requirement.role,
99+
axis: "freshness",
100+
}];
101+
}
102+
103+
function evaluateConformance(
104+
requirement: EvidenceRoleRequirement,
105+
input: RoleEvaluationInput,
106+
): CanonEvaluationFailure[] {
107+
if (!requirement.requiresConformantProvider) {
108+
return [];
109+
}
110+
if (isConformantProvider(input)) {
111+
return [];
112+
}
113+
return [{
114+
code: "provider_not_conformant",
115+
message: "role requires conformant provider metadata on snapshot",
116+
role: requirement.role,
117+
axis: "conformance",
118+
}];
119+
}
120+
121+
/** Evaluate one role requirement against a qualified snapshot. */
122+
export function evaluateRoleRequirement(
123+
requirement: EvidenceRoleRequirement,
124+
input: RoleEvaluationInput,
125+
): RoleEvaluationResult {
126+
const failures = [
127+
...evaluateProvenance(requirement, input),
128+
...evaluateDefinition(requirement, input),
129+
...evaluateFreshness(requirement, input),
130+
...evaluateConformance(requirement, input),
131+
];
132+
133+
return {
134+
role: requirement.role,
135+
satisfied: failures.length === 0,
136+
failures,
137+
};
138+
}
139+
140+
/** Evaluate all role requirements in a policy document. */
141+
export function evaluateCanonPolicy(
142+
policy: CanonPolicyDocument,
143+
snapshotsByRole: Record<string, RoleEvaluationInput | undefined>,
144+
): CanonPolicyEvaluationResult {
145+
const roles = policy.requiresEvidence.map((requirement) => {
146+
const input = snapshotsByRole[requirement.role];
147+
if (!input) {
148+
return {
149+
role: requirement.role,
150+
satisfied: false,
151+
failures: [{
152+
code: "role_snapshot_missing",
153+
message: `no snapshot supplied for role ${requirement.role}`,
154+
role: requirement.role,
155+
}],
156+
} satisfies RoleEvaluationResult;
157+
}
158+
return evaluateRoleRequirement(requirement, {
159+
...input,
160+
role: requirement.role,
161+
});
162+
});
163+
164+
return {
165+
decisionSystem: policy.decisionSystem,
166+
satisfied: roles.every((role) => role.satisfied),
167+
roles,
168+
};
169+
}
170+
171+
/** Helper for policy authors — minimum rung labels in requirement objects. */
172+
export const CanonMinimum = {
173+
provenance: (rung: ProvenanceRung) => rung,
174+
definition: (rung: DefinitionRung) => rung,
175+
} as const;

packages/canon/src/freshness.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright (c) Better Data, Inc.
3+
4+
import type { FreshnessBindingClock, FreshnessQualification } from "@loop-engine/evidence";
5+
6+
export function freshnessBindingTimestamp(
7+
freshness: Extract<FreshnessQualification, { applicable: "applicable" }>,
8+
bindingClock: FreshnessBindingClock = freshness.bindingClock ?? "valueAsOf",
9+
): string {
10+
if (bindingClock === "snapshotFrozenAt") {
11+
return freshness.snapshotFrozenAt;
12+
}
13+
if (bindingClock === "sourceDataFreshness") {
14+
return freshness.sourceDataFreshness ?? freshness.valueAsOf;
15+
}
16+
return freshness.valueAsOf;
17+
}
18+
19+
export function freshnessAgeMs(
20+
freshness: Extract<FreshnessQualification, { applicable: "applicable" }>,
21+
evaluatedAt: string,
22+
bindingClock?: FreshnessBindingClock,
23+
): number {
24+
const bindingIso = freshnessBindingTimestamp(freshness, bindingClock);
25+
const evaluated = Date.parse(evaluatedAt);
26+
const binding = Date.parse(bindingIso);
27+
if (Number.isNaN(evaluated) || Number.isNaN(binding)) {
28+
return Number.POSITIVE_INFINITY;
29+
}
30+
return Math.max(0, evaluated - binding);
31+
}

packages/canon/src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright (c) Better Data, Inc.
3+
4+
export type {
5+
CanonFreshnessRequirement,
6+
CanonEvaluationFailure,
7+
CanonPolicyDocument,
8+
CanonPolicyEvaluationResult,
9+
EvidenceRoleRequirement,
10+
RoleEvaluationInput,
11+
RoleEvaluationResult,
12+
} from "./types.js";
13+
export { freshnessAgeMs, freshnessBindingTimestamp } from "./freshness.js";
14+
export {
15+
evaluateRoleRequirement,
16+
evaluateCanonPolicy,
17+
CanonMinimum,
18+
} from "./evaluate.js";

0 commit comments

Comments
 (0)