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
53 changes: 52 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,18 @@ jobs:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '20.x'
node-version: '24.x'
cache: 'npm'
- name: Clone verdict-core (canonical contracts pkg)
run: git clone --depth 1 https://github.com/mrnicholasbcarter-code/verdict-core.git /tmp/verdict-core
- name: Build canonical contracts package from verdict-core
run: ( cd /tmp/verdict-core/contracts && npm ci && npm run build && npm link )
- name: Install dependencies from lockfile
run: npm ci
- name: Link canonical contracts package (source of truth over stale npm release)
run: |
npm link @bodanglin/verdict-contracts
node --input-type=module -e "const s=(await import('@bodanglin/verdict-contracts')).contractSchemas; if(!('execution_envelope' in s)) throw new Error('execution_envelope schema missing from linked contracts package')"
- name: Assert supported TypeScript/ts-jest boundary
run: node -e "const p=require('./node_modules/typescript/package.json'); const peer=require('./node_modules/ts-jest/package.json').peerDependencies.typescript; if (!p.version.startsWith('5.9.')) throw new Error('Unsupported TypeScript '+p.version); if (peer !== '>=4.3 <7') throw new Error('Unexpected ts-jest TypeScript peer '+peer)"
- name: Typecheck
Expand All @@ -36,3 +44,46 @@ jobs:
run: pip install "git+https://github.com/mrnicholasbcarter-code/verdict-core.git@main"
- name: Verify compat manifest gate
run: verdict compat check --declared .verdict/compat-manifest.json --json
contract-parity:
# NOD-002 / ADR-025: fails if TypeScript accepts an ExecutionEnvelope that
# Python rejects (or vice versa) on the shared invalid-envelope fixtures.
#
# The envelope Zod schema lives in verdict-core's contracts/ subpackage.
# The published @bodanglin/verdict-contracts npm package may lag behind
# main, so we build the contracts package from the verdict-core checkout
# and `npm link` it to guarantee the TS side tests against the canonical
# (source-of-truth) Zod schemas, not a stale npm release.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '24.x'
cache: 'npm'
- name: Clone verdict-core (Python source of truth + contracts pkg)
run: git clone --depth 1 https://github.com/mrnicholasbcarter-code/verdict-core.git /tmp/verdict-core
- name: Build canonical contracts package from verdict-core
run: ( cd /tmp/verdict-core/contracts && npm ci && npm run build && npm link )
- name: Install dependencies from lockfile
run: npm ci
- name: Link canonical contracts package (source of truth over stale npm release)
run: |
npm link @bodanglin/verdict-contracts
node --input-type=module -e "const s=(await import('@bodanglin/verdict-contracts')).contractSchemas; if(!('execution_envelope' in s)) throw new Error('execution_envelope schema missing from linked contracts package')"
- name: TypeScript fixture expectations (jest, ESM)
run: node --experimental-vm-modules ./node_modules/jest/bin/jest.js tests/contract-parity.test.ts
- uses: actions/setup-python@v7
with:
python-version: '3.11'
- name: Install verdict-core (Python source of truth)
run: pip install "git+https://github.com/mrnicholasbcarter-code/verdict-core.git@main"
- name: Fixture set matches verdict-core canonical copy
run: diff -r --exclude='*.py' /tmp/verdict-core/test_fixtures/envelopes test_fixtures/envelopes
- name: Cross-runtime verdict comparison
run: |
node scripts/envelope-parity-verdicts.mjs > /tmp/ts-verdicts.json
python scripts/envelope_parity_verdicts.py > /tmp/py-verdicts.json
if ! diff -u /tmp/py-verdicts.json /tmp/ts-verdicts.json; then
echo "::error::ExecutionEnvelope enforcement divergence between Python and TypeScript"
exit 1
fi
15 changes: 12 additions & 3 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,17 @@ jobs:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '20.x'
node-version: '24.x'
cache: 'npm'
- run: npm ci
- name: Clone verdict-core (canonical contracts pkg)
run: git clone --depth 1 https://github.com/mrnicholasbcarter-code/verdict-core.git /tmp/verdict-core
- name: Build canonical contracts package from verdict-core
run: ( cd /tmp/verdict-core/contracts && npm ci && npm run build && npm link )
- name: Install dependencies from lockfile
run: npm ci
- name: Link canonical contracts package (source of truth over stale npm release)
run: |
npm link @bodanglin/verdict-contracts
node -e "const s=require('@bodanglin/verdict-contracts').contractSchemas; if(!('execution_envelope' in s)) throw new Error('execution_envelope schema missing from linked contracts package')"
- run: npm run lint
- run: npm run format:check
- run: npm run format:check
4 changes: 2 additions & 2 deletions .verdict/compat-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
"contracts": {
"AvailabilitySnapshot": "sha256:9fdcdbe78f23e583001ed8f2292ce5668061f7dbc5d839f3b2536f92630e2b0e",
"OutcomeEvent": "sha256:e993fb10752ee36e1a77fe38c85345f77f5975a93eaad78ccb0560004d64013f",
"RoutingDecisionContract": "sha256:d893d5c55f520733bc6b117efa050a188f7450fb045aa386370687c429d8edfc",
"RoutingDecisionContract": "sha256:c1bd1b4ff2503e59c74737a85c7c6c470592f36d7b999b4c5d67a39d06f79892",
"RuntimeCandidate": "sha256:8fc6ab26992374dc8642df68219b3a68a912a5087b61547c1be2032746f4f8cb",
"SwarmTaskEnvelope": "sha256:d5247469d33ea404acd5b6c676329d0ef25fbbe0f2831bdf633b0a843cc90293",
"TaskSpec": "sha256:161837be44785ac6057373eac32d1c0c2ba53b1b7e706f57e235c5e0f75c56d2",
"WorkflowPlan": "sha256:f1f18b28116f1ec4b833c8fc580d5b1935a3ecc689a0b2f48b5ba3cbad5e00c4"
},
"manifest_hash": "sha256:a608860555863e94af19efb28568d7d3abac87ea0c88c79bf5464f9e8346ef43",
"manifest_hash": "sha256:7bbd4bf9b833b45116a3baa9af0f2d3e8c5fced6ca3ad2a26c315235bd93b0fa",
"schema_version": "1"
}
8 changes: 5 additions & 3 deletions jest.config.js → jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@ module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
testPathIgnorePatterns: ['/tests/contract-parity.test.ts'],
testPathIgnorePatterns: [],
moduleFileExtensions: ['ts', 'js', 'json', 'node'],
extensionsToTreatAsEsm: ['.ts'],
transform: {
'^.+\\.(ts|js)$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.test.json', useESM: true, extensionsToTreatAsEsm: ['.js'] }],
'^.+\\.(ts|js)$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.test.json', useESM: true }],
},
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'^@bodanglin/verdict-contracts$': '<rootDir>/node_modules/@bodanglin/verdict-contracts',
},
transformIgnorePatterns: ['/node_modules/(?!@bodanglin/verdict-contracts)'],
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/*.test.ts'],
coverageDirectory: 'coverage',
verbose: true,
testTimeout: 30000,
};
};
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@bodanglin/verdict-node",
"version": "0.1.0",
"type": "module",
"description": "OpenAI-compatible gateway adapter with execution-envelope validation for Express and Next.js",
"license": "MIT",
"main": "dist/index.js",
Expand Down Expand Up @@ -38,7 +39,7 @@
},
"scripts": {
"build": "tsc",
"test": "jest",
"test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js",
"lint": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\" \"*.md\" \"*.json\" \".github/workflows/*.yml\"",
Expand Down
28 changes: 28 additions & 0 deletions scripts/envelope-parity-verdicts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Emit the TypeScript accept/reject verdict for every shared envelope fixture.
*
* Part of the NOD-002 contract-parity CI gate: the JSON output is diffed
* against the Python runner (`scripts/envelope_parity_verdicts.py`); any
* difference means the two runtimes no longer enforce the same
* ExecutionEnvelope invariants and the gate fails.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { parseContract } from '@bodanglin/verdict-contracts';

const fixturesDir = join(process.cwd(), 'test_fixtures', 'envelopes');
const verdicts = {};

for (const name of readdirSync(fixturesDir)
.filter(entry => entry.endsWith('.json'))
.sort()) {
const payload = JSON.parse(readFileSync(join(fixturesDir, name), 'utf-8'));
try {
parseContract('execution_envelope', payload);
verdicts[name] = 'accept';
} catch {
verdicts[name] = 'reject';
}
}

process.stdout.write(`${JSON.stringify(verdicts, null, 2)}\n`);
38 changes: 38 additions & 0 deletions scripts/envelope_parity_verdicts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Emit the Python accept/reject verdict for every shared envelope fixture.

Part of the NOD-002 contract-parity CI gate: the JSON output is diffed against
the TypeScript runner (``scripts/envelope-parity-verdicts.mjs``); any
difference means the two runtimes no longer enforce the same ExecutionEnvelope
invariants and the gate fails.

Requires ``verdict-core`` to be installed (the CI job installs it from git).
"""

from __future__ import annotations

import json
import sys
from pathlib import Path

from verdict.contracts import ContractValidationError, ExecutionEnvelope

FIXTURES_DIR = Path(__file__).resolve().parent.parent / "test_fixtures" / "envelopes"


def main() -> int:
verdicts: dict[str, str] = {}
for path in sorted(FIXTURES_DIR.glob("*.json")):
payload = json.loads(path.read_text(encoding="utf-8"))
try:
ExecutionEnvelope.from_dict(payload)
verdicts[path.name] = "accept"
except ContractValidationError:
verdicts[path.name] = "reject"
json.dump(verdicts, sys.stdout, indent=2)
sys.stdout.write("\n")
return 0


if __name__ == "__main__":
raise SystemExit(main())
2 changes: 2 additions & 0 deletions src/adapters/contract-to-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ export function createFallbackRoutingDecision(
request_id: null,
policy_version: '1',
schema_version: '1',
decision_id: null,
receipt: null,
};
}

Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { z } from 'zod';
import * as http from 'http';
import * as https from 'https';
import type { RoutingDecision as CanonicalRoutingDecision } from '@bodanglin/verdict-contracts';
import { adaptRoutingDecision } from './adapters/contract-to-middleware';
import { enforceExecutionEnvelope } from './middleware/forwarder';
import { adaptRoutingDecision } from './adapters/contract-to-middleware.js';
import { enforceExecutionEnvelope } from './middleware/forwarder.js';

const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor']);

Expand Down
52 changes: 25 additions & 27 deletions src/middleware/forwarder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { Request, Response as ExpressResponse, NextFunction } from 'express';
import { z } from 'zod';
import {
parseContract,
contractSchemas,
type ExecutionEnvelope,
ContractValidationError,
} from '@bodanglin/verdict-contracts';

// Use global fetch Response type
type FetchResponse = Response;
Expand Down Expand Up @@ -28,17 +34,8 @@ type FetchResponse = Response;
// Configuration Types
// ============================================================================

export interface ExecutionEnvelope {
schema_version: '1';
policy_digest: string;
execution_constraints?: {
allowed_models?: string[];
allowed_tools?: string[];
budget_usd?: number;
max_request_usd?: number;
};
expires_at?: string;
}
// Re-export canonical ExecutionEnvelope type from @bodanglin/verdict-contracts
export type { ExecutionEnvelope };

const KNOWN_ENVELOPE_FIELDS = new Set([
'schema_version',
Expand Down Expand Up @@ -117,25 +114,33 @@ export function enforceExecutionEnvelope(
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
throw new ExecutionEnvelopeError('envelope_missing', 'Core execution envelope is required');
}
const candidate = envelope as Record<string, unknown>;
if (candidate.schema_version !== '1' || typeof candidate.policy_digest !== 'string') {
throw new ExecutionEnvelopeError('envelope_invalid', 'Core execution envelope is invalid');
}
for (const key of Object.keys(candidate)) {
if (!KNOWN_ENVELOPE_FIELDS.has(key)) {
// Extract expires_at before validation (canonical schema uses created_at)
const candidateRaw = envelope as Record<string, unknown>;
const expiresAt = candidateRaw.expires_at;
// Strip expires_at before canonical validation (not part of canonical schema)
const { expires_at: _expiresAt, ...canonicalEnvelope } = candidateRaw;
// Validate against canonical ExecutionEnvelope schema from @bodanglin/verdict-contracts
let candidate: ExecutionEnvelope & { execution_constraints: Record<string, unknown> };
try {
candidate = parseContract('execution_envelope', canonicalEnvelope) as ExecutionEnvelope & {
execution_constraints: Record<string, unknown>;
};
} catch (error) {
if (error instanceof ContractValidationError) {
throw new ExecutionEnvelopeError(
'envelope_invalid',
`Core execution envelope contains unknown field: ${key}`
`Core execution envelope is invalid: ${error.message}`
);
}
throw error;
}
if (
options.expectedPolicyDigest !== undefined &&
candidate.policy_digest !== options.expectedPolicyDigest
) {
throw new ExecutionEnvelopeError('envelope_tampered', 'Core policy digest does not match');
}
const expiresAt = candidate.expires_at;
// Check expiration using expires_at (forwarder-specific extension)
if (
typeof expiresAt === 'string' &&
(!Number.isFinite(Date.parse(expiresAt)) || Date.parse(expiresAt) <= Date.now())
Expand All @@ -151,14 +156,7 @@ export function enforceExecutionEnvelope(
);
}
const bounded = constraints as Record<string, unknown>;
for (const key of Object.keys(bounded)) {
if (!KNOWN_CONSTRAINT_FIELDS.has(key)) {
throw new ExecutionEnvelopeError(
'envelope_invalid',
`Core execution envelope constraints contain unknown field: ${key}`
);
}
}
// Note: canonical schema allows arbitrary constraint fields; we only enforce known ones
const allowedModels = bounded.allowed_models;
if (
Array.isArray(allowedModels) &&
Expand Down
29 changes: 29 additions & 0 deletions test_fixtures/envelopes/invalid_empty_allowed_capabilities.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"schema_version": "1",
"task_spec": {
"objective": "test task",
"task_type": "chat",
"effort": "medium",
"reasoning": "medium",
"privacy": "unknown",
"risk": "unknown",
"parallelism": "serial",
"degraded_mode_policy": "deny",
"capabilities": [],
"required_capabilities": [],
"tools": [],
"approvals": [],
"budget": {},
"latency": {},
"workflow": null,
"metadata": {}
},
"eligibility_decision": { "admitted": ["gpt-4"], "reason": "test" },
"policy_digest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"allowed_capabilities": [],
"execution_constraints": { "allowed_models": ["gpt-4"] },
"verification_requirements": { "checks": [] },
"evidence_ids": ["evidence-1"],
"routing_decision": null,
"created_at": "2024-01-01T00:00:00Z"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"schema_version": "1",
"task_spec": {
"objective": "test task",
"task_type": "chat",
"effort": "medium",
"reasoning": "medium",
"privacy": "unknown",
"risk": "unknown",
"parallelism": "serial",
"degraded_mode_policy": "deny",
"capabilities": [],
"required_capabilities": [],
"tools": [],
"approvals": [],
"budget": {},
"latency": {},
"workflow": null,
"metadata": {}
},
"eligibility_decision": { "admitted": ["gpt-4"], "reason": "test" },
"policy_digest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"allowed_capabilities": ["", "chat"],
"execution_constraints": { "allowed_models": ["gpt-4"] },
"verification_requirements": { "checks": [] },
"evidence_ids": ["evidence-1"],
"routing_decision": null,
"created_at": "2024-01-01T00:00:00Z"
}
Loading