reporting-governance: wire decision artifact schema into validator

This commit is contained in:
Eve
2026-05-08 13:49:08 +08:00
parent 37410c0be5
commit 0ef6fbbcc6
2 changed files with 168 additions and 9 deletions

View File

@@ -1,9 +1,14 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import Ajv2020 from 'ajv/dist/2020.js';
const EXPECTED_KIND = 'DecisionRecordArtifact'; const EXPECTED_KIND = 'DecisionRecordArtifact';
const EXPECTED_API_VERSION = 'reporting-governance/v1alpha1'; const EXPECTED_API_VERSION = 'reporting-governance/v1alpha1';
const CANONICAL_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; const CANONICAL_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const SOURCE_EVENT_ID_PATTERN = /^(?:evt_[A-Za-z0-9._:-]+|\/?[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+|[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i; const SOURCE_EVENT_ID_PATTERN = /^(?:evt_[A-Za-z0-9._:-]+|\/?[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+|[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i;
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const RECEIPT_DELIVERY_STATES = new Set(['prepared', 'queued', 'dispatched', 'pending_external_send', 'acked', 'blocked', 'failed', 'partial']); const RECEIPT_DELIVERY_STATES = new Set(['prepared', 'queued', 'dispatched', 'pending_external_send', 'acked', 'blocked', 'failed', 'partial']);
const RECEIPT_STATUS_RULES = { const RECEIPT_STATUS_RULES = {
planned: ['prepared', 'queued', 'dispatched', 'pending_external_send', 'partial'], planned: ['prepared', 'queued', 'dispatched', 'pending_external_send', 'partial'],
@@ -13,6 +18,51 @@ const RECEIPT_STATUS_RULES = {
failed: ['failed'] failed: ['failed']
}; };
const packageRoot = path.resolve(import.meta.dirname, '..', '..');
const repoRoot = path.resolve(packageRoot, '..', '..');
const decisionSchemaPath = path.resolve(repoRoot, 'schemas', 'reporting-governance', 'decision.schema.json');
const decisionRecordArtifactSchemaPath = path.resolve(repoRoot, 'schemas', 'reporting-governance', 'decision-record-artifact.schema.json');
const ajv = new Ajv2020({ allErrors: true, strict: false });
const decisionSchema = JSON.parse(fs.readFileSync(decisionSchemaPath, 'utf8'));
const decisionRecordArtifactSchema = JSON.parse(fs.readFileSync(decisionRecordArtifactSchemaPath, 'utf8'));
ajv.addSchema(decisionSchema);
const validateDecisionRecordArtifactSchema = ajv.compile(decisionRecordArtifactSchema);
function formatAjvErrors(errors = []) {
if (!Array.isArray(errors) || errors.length === 0) {
return 'schema validation failed';
}
return errors.map((error) => {
const keyword = error.keyword;
const instancePath = error.instancePath || '/';
if (keyword === 'required') {
const missing = error.params?.missingProperty ?? 'unknown';
return `${instancePath} missing required property ${missing}`;
}
if (keyword === 'additionalProperties') {
const extra = error.params?.additionalProperty ?? 'unknown';
return `${instancePath} must not include additional property ${extra}`;
}
if (keyword === 'const') {
return `${instancePath} must equal ${JSON.stringify(error.params?.allowedValue)}`;
}
if (keyword === 'enum') {
const allowed = Array.isArray(error.params?.allowedValues)
? error.params.allowedValues.join(', ')
: 'allowed set';
return `${instancePath} must be one of: ${allowed}`;
}
return `${instancePath} ${error.message}`.trim();
}).join('; ');
}
function assertNonEmptyString(value, label) { function assertNonEmptyString(value, label) {
if (typeof value !== 'string' || value.trim() === '') { if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${label} must be a non-empty string`); throw new Error(`${label} must be a non-empty string`);
@@ -54,6 +104,48 @@ function assertSourceEventId(value, label) {
return normalized; return normalized;
} }
function normalizeUuidLike(value) {
if (typeof value !== 'string') {
return value;
}
return UUID_PATTERN.test(value) ? value.toLowerCase() : value;
}
function projectCanonicalDecision(decision) {
if (!decision || typeof decision !== 'object' || Array.isArray(decision)) {
return decision;
}
return {
decision: decision.decision,
policy_id: decision.policy_id,
severity: decision.severity,
reason: decision.reason,
rewritten_message: decision.rewritten_message ?? null,
suggested_status: decision.suggested_status ?? null,
required_actions: decision.required_actions,
operator_notice: decision.operator_notice ?? null,
};
}
function normalizeArtifactShape(artifact) {
if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
return;
}
if (artifact.metadata && typeof artifact.metadata === 'object' && !Array.isArray(artifact.metadata)) {
artifact.metadata.event_id = normalizeUuidLike(artifact.metadata.event_id);
}
if (artifact.spec?.source && typeof artifact.spec.source === 'object' && !Array.isArray(artifact.spec.source)) {
artifact.spec.source.event_id = normalizeUuidLike(artifact.spec.source.event_id);
}
if (artifact.spec?.decision && typeof artifact.spec.decision === 'object' && !Array.isArray(artifact.spec.decision)) {
artifact.spec.decision = projectCanonicalDecision(artifact.spec.decision);
}
}
function validateReceiptContract(receipt) { function validateReceiptContract(receipt) {
const deliveryState = assertNonEmptyString(receipt.delivery_state, 'decision record artifact spec.receipt.delivery_state'); const deliveryState = assertNonEmptyString(receipt.delivery_state, 'decision record artifact spec.receipt.delivery_state');
if (!RECEIPT_DELIVERY_STATES.has(deliveryState)) { if (!RECEIPT_DELIVERY_STATES.has(deliveryState)) {
@@ -103,6 +195,17 @@ function validateReceiptContract(receipt) {
return { deliveryState, status }; return { deliveryState, status };
} }
function validateDecisionRecordArtifactSchemaContract(artifact) {
const candidate = structuredClone(artifact);
normalizeArtifactShape(candidate);
if (!validateDecisionRecordArtifactSchema(candidate)) {
throw new Error(`decision record artifact schema validation failed: ${formatAjvErrors(validateDecisionRecordArtifactSchema.errors)}`);
}
return candidate;
}
export function validateDecisionRecordArtifact(artifact) { export function validateDecisionRecordArtifact(artifact) {
if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) { if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
throw new Error('decision record artifact must be an object'); throw new Error('decision record artifact must be an object');
@@ -158,16 +261,17 @@ export function validateDecisionRecordArtifact(artifact) {
} }
} }
const schemaValidated = validateDecisionRecordArtifactSchemaContract(artifact);
metadata.recorded_at = recordedAt; metadata.recorded_at = recordedAt;
metadata.policy_id = metadataPolicyId; metadata.policy_id = metadataPolicyId;
metadata.decision = metadataDecision; metadata.decision = metadataDecision;
metadata.event_id = metadataEventId; metadata.event_id = schemaValidated.metadata.event_id;
decision.policy_id = decisionPolicyId; spec.decision = schemaValidated.spec.decision;
decision.decision = decisionName;
receipt.delivery_state = deliveryState; receipt.delivery_state = deliveryState;
receipt.status = status; receipt.status = status;
if (source != null) { if (source != null) {
source.event_id = sourceEventId; source.event_id = schemaValidated.spec.source.event_id;
} }
return artifact; return artifact;
@@ -204,12 +308,17 @@ export function createDecisionRecordArtifact({ decision, receipt, recordedAt = n
} }
export function createDecisionRecordFileName(artifact) { export function createDecisionRecordFileName(artifact) {
const validatedArtifact = validateDecisionRecordArtifact(artifact); const metadata = assertObjectRecord(artifact?.metadata, 'decision record artifact metadata');
const recordedAt = assertCanonicalIsoTimestamp(metadata.recorded_at, 'decision record artifact metadata.recorded_at');
const policyId = assertNonEmptyString(metadata.policy_id, 'decision record artifact metadata.policy_id');
const decision = assertNonEmptyString(metadata.decision, 'decision record artifact metadata.decision');
const recordId = assertNonEmptyString(metadata.record_id, 'decision record artifact metadata.record_id');
return [ return [
validatedArtifact.metadata.recorded_at.replace(/[.:]/g, '-'), recordedAt.replace(/[.:]/g, '-'),
sanitizeFileSegment(validatedArtifact.metadata.policy_id, 'policy'), sanitizeFileSegment(policyId, 'policy'),
sanitizeFileSegment(validatedArtifact.metadata.decision, 'decision'), sanitizeFileSegment(decision, 'decision'),
`${validatedArtifact.metadata.record_id}.json`, `${recordId}.json`,
].join('-'); ].join('-');
} }

View File

@@ -5,6 +5,7 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { executeRuntimeIntegratedGovernance, createFileDecisionStore } from '../src/index.mjs'; import { executeRuntimeIntegratedGovernance, createFileDecisionStore } from '../src/index.mjs';
import { validateDecisionRecordArtifact } from '../src/storage/decision-artifact.mjs';
import capabilityDescriptor from '../capabilities/openclaw-watchdog-reference.json' with { type: 'json' }; import capabilityDescriptor from '../capabilities/openclaw-watchdog-reference.json' with { type: 'json' };
const packageRoot = path.resolve(import.meta.dirname, '..'); const packageRoot = path.resolve(import.meta.dirname, '..');
@@ -207,3 +208,52 @@ test('decision record integrates planning output with runtime receipts and queue
fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(root, { recursive: true, force: true });
} }
}); });
test('persisted decision artifact is consumable by downstream validator without store context', () => {
const root = createFixtureRoot();
try {
mkdirs(root, ['evidence', 'events', 'queue', 'spool', 'receipts', 'repo']);
const statePath = writeState(root);
const fakeRepoRoot = path.join(root, 'repo');
const decisionsDir = path.join(fakeRepoRoot, 'state', 'decisions');
fs.mkdirSync(decisionsDir, { recursive: true });
const result = executeRuntimeIntegratedGovernance({
...createBaseArgs(),
runtime: {
state: statePath,
evidenceDir: path.join(root, 'evidence'),
eventDir: path.join(root, 'events'),
queueDir: path.join(root, 'queue'),
spoolDir: path.join(root, 'spool'),
receiptDir: path.join(root, 'receipts'),
senderCommand: `node -e "process.stdout.write(JSON.stringify({state:'sent'}))"`,
writeState: true,
now: '2026-05-07T08:20:00.000Z',
},
});
const queueItem = readSingleJson(path.join(root, 'queue'));
const runtimeEventRef = queueItem.evidence_refs.find((ref) => ref.label === 'watchdog_event');
const store = createFileDecisionStore({ decisionsDir, repoRootOverride: fakeRepoRoot });
const written = store.write({
decision: result.planning.decision,
receipt: result.planning.receipt,
recordedAt: '2026-05-08T04:00:00.000Z',
source: {
task_id: queueItem.governance.task_id,
correlation_id: queueItem.governance.correlation_id,
event_id: runtimeEventRef.path,
},
});
const persisted = JSON.parse(fs.readFileSync(written.artifactPath, 'utf8'));
const consumed = validateDecisionRecordArtifact(persisted);
assert.equal(consumed.metadata.record_id, written.artifact.metadata.record_id);
assert.equal(consumed.spec.receipt.delivery_state, 'acked');
assert.equal(consumed.spec.source.event_id, runtimeEventRef.path);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});