Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/opa-parity.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: OPA Parity (Scheduled)

# Scheduled full Native/OPA semantic parity run (GT-149, criterion 4).
# Compiles topology policies to WASM with the pinned opa toolchain, then runs
# the full parity gate across every accepted topology. Per-commit scoping runs
# via the ci-runner; this guarantees a periodic full sweep.

on:
schedule:
- cron: '0 6 * * *' # daily at 06:00 UTC
workflow_dispatch:

permissions:
contents: read

jobs:
parity:
name: Native/OPA Semantic Parity (full)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Compile OPA policies to WASM (pinned opa)
run: npm run build:policy
- name: Run full Native/OPA parity gate
env:
EVOLITH_PARITY_FULL: 'true'
run: node .harness/scripts/ci/16-opa-parity-gate.mjs
135 changes: 135 additions & 0 deletions .harness/scripts/ci/16-opa-parity-gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* @file 16-opa-parity-gate.mjs
* @description CI Step: Executable OPA tests + Native/OPA semantic parity (GT-149)
*
* For each accepted topology with a compiled `<id>.wasm` bundle and a
* `parity-fixtures/` directory, evaluates every fixture through the pinned
* opa-wasm runtime (no host binary), compares the decisions against the
* fixture's declared Native decisions, and fails closed on verdict/rule-ID/
* severity/evidence drift or any evaluator/parse failure. Emits a versioned,
* machine-readable report with aggregate duration telemetry.
*
* Dry-run-safe: when bundles/fixtures are not yet compiled/present locally, the
* gate defers to the scheduled full parity run (compile-opa-wasm) and exits 0.
*
* Fixture shape: { "input": {…}, "expectedNative": [ { ruleId, severity, file } ] }
*/

import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { execSync } from 'node:child_process';
import { evaluateWasm, normalizeOpaDecisions } from './opa-eval.mjs';
import { parityReport, scopeTopologies, contentVersion } from './parity-gate.mjs';

const ROOT = process.cwd();
const TOPO_ROOT = 'reference/architecture/topologies';
// Full/scheduled run evaluates all accepted topologies; otherwise scope to changed.
const FULL_RUN = process.env.EVOLITH_PARITY_FULL === 'true';

function changedPaths() {
try {
return execSync('git diff --name-only HEAD~1 HEAD', { encoding: 'utf8' }).split('\n').filter(Boolean);
} catch {
return null; // no diff context — treat as full
}
}

function readIfExists(rel) {
const p = resolve(ROOT, rel);
return existsSync(p) ? readFileSync(p, 'utf8') : '';
}

function acceptedTopologies() {
const out = [];
const walk = (dir) => {
for (const e of readdirSync(resolve(ROOT, dir), { withFileTypes: true })) {
const rel = `${dir}/${e.name}`;
if (e.isDirectory()) walk(rel);
else if (e.name === 'topology.manifest.json') {
try {
const m = JSON.parse(readFileSync(resolve(ROOT, rel), 'utf8'));
if (m?.metadata?.status === 'accepted') {
out.push({ dir, id: m.metadata.id, version: m.metadata.version });
}
} catch {
/* manifest parse issues are covered by the drift audit (GT-147) */
}
}
}
};
if (existsSync(resolve(ROOT, TOPO_ROOT))) walk(TOPO_ROOT);
return out;
}

async function main() {
console.log('⚖️ Executable OPA Tests & Native/OPA Parity Gate (GT-149)');
const topologies = scopeTopologies(acceptedTopologies(), FULL_RUN ? null : changedPaths(), FULL_RUN);
console.log(` Scope: ${FULL_RUN ? 'FULL (scheduled)' : 'changed topologies'} — ${topologies.length} accepted topology(ies).`);
const reports = [];
let missingInputs = 0;
let drifting = 0;
let totalDurationMs = 0;

for (const t of topologies) {
const wasmRel = `${t.dir}/${t.id}.wasm`;
const fixturesDir = `${t.dir}/parity-fixtures`;
if (!existsSync(resolve(ROOT, wasmRel)) || !existsSync(resolve(ROOT, fixturesDir))) {
missingInputs += 1;
continue;
}
const wasm = readFileSync(resolve(ROOT, wasmRel));
for (const file of readdirSync(resolve(ROOT, fixturesDir)).filter((f) => f.endsWith('.json'))) {
let report;
try {
const fixture = JSON.parse(readFileSync(resolve(ROOT, fixturesDir, file), 'utf8'));
const { result, durationMs } = await evaluateWasm(wasm, fixture.input || {});
totalDurationMs += durationMs;
report = parityReport({
topology: t.id,
fixture: file,
nativeDecisions: fixture.expectedNative || [],
opaDecisions: normalizeOpaDecisions(result),
versions: {
topology: t.version,
ruleset: contentVersion(readIfExists(`${t.dir}/${t.id}.rules.json`)),
policy: contentVersion(readIfExists(`${t.dir}/${t.id}.rego`)),
},
durationMs,
});
} catch (e) {
report = { topology: t.id, fixture: file, parity: false, error: String(e.message) };
}
reports.push(report);
if (!report.parity) drifting += 1;
}
}

const out = {
schemaVersion: '1.0',
accepted: topologies.length,
evaluated: reports.length,
missingInputs,
drifting,
telemetry: { totalDurationMs },
reports,
};

if (reports.length === 0) {
console.log(
` ℹ️ No compiled OPA bundles / parity-fixtures present for ${topologies.length} accepted topology(ies). ` +
`Deferred to the scheduled full parity run (compile-opa-wasm).`,
);
console.log(`PARITY ${JSON.stringify(out)}`);
process.exit(0);
}

console.log(` ${reports.length} fixture(s) across ${topologies.length} accepted topology(ies); ${drifting} drift/failure(s).`);
console.log(`PARITY ${JSON.stringify(out)}`);
process.exit(drifting > 0 ? 1 : 0);
}

main().catch((err) => {
console.error('❌ OPA parity gate failed:', err.message);
process.exit(1);
});
40 changes: 40 additions & 0 deletions .harness/scripts/ci/opa-eval.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* GT-149 — Pinned, reproducible OPA evaluator (no undeclared host binary).
*
* Evaluates a pre-compiled OPA policy WASM bundle through the in-process
* `@open-policy-agent/opa-wasm` runtime. The bundle is produced by the pinned
* `compile-opa-wasm` build step, so evaluation needs no host `opa` binary.
* Returns normalized decisions plus execution duration for parity comparison
* and aggregate efficiency telemetry.
*/

import { loadPolicy } from '@open-policy-agent/opa-wasm';

/** Evaluate a WASM policy against `input`. Returns `{ result, durationMs }`. */
export async function evaluateWasm(wasmBytes, input = {}, { entrypoint } = {}) {
const policy = await loadPolicy(wasmBytes);
const start = process.hrtime.bigint();
const raw = policy.evaluate(input, entrypoint);
const durationMs = Number(process.hrtime.bigint() - start) / 1e6;
const result = Array.isArray(raw) && raw.length ? raw[0].result : raw;
return { result, durationMs };
}

/**
* Normalize a policy result into the canonical decision contract used by the
* parity gate: `{ ruleId, severity, message, file }`. Accepts the common shapes
* (array of violations, or `{ violations | result }`).
*/
export function normalizeOpaDecisions(result) {
let items = result;
if (!Array.isArray(items)) {
items = result?.violations ?? result?.result ?? result?.deny ?? [];
}
if (!Array.isArray(items)) return [];
return items.map((v) => ({
ruleId: v.id ?? v.ruleId ?? v.rule_id ?? null,
severity: v.severity ?? 'error',
message: v.message ?? v.msg ?? '',
file: v.file ?? v.evidence ?? v.location ?? null,
}));
}
78 changes: 78 additions & 0 deletions .harness/scripts/ci/parity-gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* GT-149 — Native/OPA semantic parity gate.
*
* Compares the decisions of the Native and OPA evaluators for the same
* canonical input and fails on verdict, rule-ID, severity, or evidence-location
* drift. Pure and deterministic; the runner feeds it real evaluator output.
*
* Decision contract: `{ ruleId, severity, message, file }`.
*/

import { createHash } from 'node:crypto';

export const PARITY_SCHEMA_VERSION = '1.0';

/** Short content version for a policy/ruleset artifact (criterion 3). */
export function contentVersion(text) {
return createHash('sha256').update(String(text ?? '')).digest('hex').slice(0, 12);
}

/**
* Scope topologies for a CI run (criterion 4). A full/scheduled run or a run
* with no change signal evaluates everything; otherwise only topologies whose
* directory contains a changed policy/manifest/ruleset.
*/
export function scopeTopologies(topologies, changedPaths, full = false) {
if (full || changedPaths == null) return topologies;
return topologies.filter((t) => changedPaths.some((p) => p === t.dir || p.startsWith(`${t.dir}/`)));
}

const keyOf = (d) => String(d?.ruleId ?? '∅');

/** Differential between two decision lists. Returns an array of drift records. */
export function diffDecisions(nativeDecisions = [], opaDecisions = []) {
const nativeMap = new Map(nativeDecisions.map((d) => [keyOf(d), d]));
const opaMap = new Map(opaDecisions.map((d) => [keyOf(d), d]));
const drift = [];

for (const [k, n] of nativeMap) {
const o = opaMap.get(k);
if (!o) {
drift.push({ ruleId: n.ruleId, kind: 'rule-id', title: 'rule fired in Native but not OPA' });
continue;
}
if ((n.severity ?? null) !== (o.severity ?? null)) {
drift.push({ ruleId: n.ruleId, kind: 'severity', title: `severity drift: native=${n.severity} opa=${o.severity}` });
}
if ((n.file ?? null) !== (o.file ?? null)) {
drift.push({ ruleId: n.ruleId, kind: 'evidence', title: `evidence-location drift: native=${n.file} opa=${o.file}` });
}
}
for (const [k, o] of opaMap) {
if (!nativeMap.has(k)) {
drift.push({ ruleId: o.ruleId, kind: 'rule-id', title: 'rule fired in OPA but not Native' });
}
}
return drift;
}

/** Build a versioned, machine-readable parity report for one fixture. */
export function parityReport({ topology, fixture, nativeDecisions, opaDecisions, versions = {}, durationMs }) {
const drift = diffDecisions(nativeDecisions, opaDecisions);
// Verdict = "deny" if either engine reports any decision.
const nativeVerdict = nativeDecisions.length ? 'deny' : 'allow';
const opaVerdict = opaDecisions.length ? 'deny' : 'allow';
if (nativeVerdict !== opaVerdict) {
drift.unshift({ ruleId: null, kind: 'verdict', title: `verdict drift: native=${nativeVerdict} opa=${opaVerdict}` });
}
return {
schemaVersion: PARITY_SCHEMA_VERSION,
topology,
fixture,
versions,
parity: drift.length === 0,
drift,
counts: { native: nativeDecisions.length, opa: opaDecisions.length, drift: drift.length },
telemetry: { durationMs: durationMs ?? null },
};
}
87 changes: 87 additions & 0 deletions .harness/scripts/ci/parity-gate.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync, existsSync } from 'node:fs';
import { evaluateWasm, normalizeOpaDecisions } from './opa-eval.mjs';
import { diffDecisions, parityReport, scopeTopologies, contentVersion, PARITY_SCHEMA_VERSION } from './parity-gate.mjs';

// --- Executable OPA evaluator (pinned WASM, no host binary) -----------------

test('evaluateWasm executes a compiled policy bundle via opa-wasm (no host binary)', async () => {
const wasmPath = 'sdk/cli/rulesets/opa/policy.wasm';
if (!existsSync(wasmPath)) {
// Bundle is produced by the pinned compile-opa-wasm step (CI). Skip locally.
return;
}
const { result, durationMs } = await evaluateWasm(readFileSync(wasmPath), {});
const decisions = normalizeOpaDecisions(result);
assert.ok(Array.isArray(decisions), 'decisions should be an array');
assert.ok(durationMs >= 0, 'duration telemetry present');
if (decisions.length) {
assert.ok('ruleId' in decisions[0] && 'severity' in decisions[0]);
}
});

test('normalizeOpaDecisions maps common shapes to the decision contract', () => {
const d = normalizeOpaDecisions([{ id: 'EVD-01', message: 'x', file: 'a.json' }]);
assert.deepEqual(d, [{ ruleId: 'EVD-01', severity: 'error', message: 'x', file: 'a.json' }]);
assert.deepEqual(normalizeOpaDecisions({ violations: [] }), []);
assert.deepEqual(normalizeOpaDecisions(null), []);
});

// --- Native/OPA differential parity -----------------------------------------

const NATIVE = [{ ruleId: 'F1-01', severity: 'error', message: 'm', file: 'src/a.ts' }];

test('identical decisions yield full parity', () => {
assert.deepEqual(diffDecisions(NATIVE, [{ ...NATIVE[0] }]), []);
const report = parityReport({ topology: 't', fixture: 'pos', nativeDecisions: NATIVE, opaDecisions: [{ ...NATIVE[0] }] });
assert.equal(report.parity, true);
assert.equal(report.schemaVersion, PARITY_SCHEMA_VERSION);
});

test('rule-id drift is detected in both directions', () => {
assert.ok(diffDecisions(NATIVE, []).some((d) => d.kind === 'rule-id'));
assert.ok(diffDecisions([], NATIVE).some((d) => d.kind === 'rule-id'));
});

test('severity and evidence-location drift are detected', () => {
const opaSev = [{ ...NATIVE[0], severity: 'warning' }];
assert.ok(diffDecisions(NATIVE, opaSev).some((d) => d.kind === 'severity'));
const opaFile = [{ ...NATIVE[0], file: 'src/b.ts' }];
assert.ok(diffDecisions(NATIVE, opaFile).some((d) => d.kind === 'evidence'));
});

test('verdict drift fails the gate', () => {
const report = parityReport({ topology: 't', fixture: 'neg', nativeDecisions: NATIVE, opaDecisions: [] });
assert.equal(report.parity, false);
assert.ok(report.drift.some((d) => d.kind === 'verdict'));
});

test('a malformed policy bundle fails closed (evaluator-failure fixture)', async () => {
await assert.rejects(() => evaluateWasm(Buffer.from([0, 1, 2, 3, 4])), Error);
});

// --- CI scoping + versions (criteria 3 & 4) ---------------------------------

const TOPOS = [
{ dir: 'reference/architecture/topologies/ai/agentic-ai', id: 'agentic-ai' },
{ dir: 'reference/architecture/topologies/progressive-axis/microservices', id: 'microservices' },
];

test('scopeTopologies returns all on a full/scheduled run or no change signal', () => {
assert.equal(scopeTopologies(TOPOS, ['x'], true).length, 2);
assert.equal(scopeTopologies(TOPOS, null, false).length, 2);
});

test('scopeTopologies limits to topologies with a changed file', () => {
const changed = ['reference/architecture/topologies/ai/agentic-ai/agentic-ai.rego'];
const scoped = scopeTopologies(TOPOS, changed, false);
assert.equal(scoped.length, 1);
assert.equal(scoped[0].id, 'agentic-ai');
});

test('contentVersion is a stable short hash', () => {
assert.equal(contentVersion('abc'), contentVersion('abc'));
assert.equal(contentVersion('abc').length, 12);
assert.notEqual(contentVersion('abc'), contentVersion('abd'));
});
Loading