From 0ef6fbbcc61c788b4a4397249be0912f6c88ba6a Mon Sep 17 00:00:00 2001 From: Eve Date: Fri, 8 May 2026 13:49:08 +0800 Subject: [PATCH] reporting-governance: wire decision artifact schema into validator --- .../src/storage/decision-artifact.mjs | 127 ++++++++++++++++-- ...ecision-store-runtime.integration.test.mjs | 50 +++++++ 2 files changed, 168 insertions(+), 9 deletions(-) diff --git a/plugins/reporting-governance/src/storage/decision-artifact.mjs b/plugins/reporting-governance/src/storage/decision-artifact.mjs index de628bf..6a85d55 100644 --- a/plugins/reporting-governance/src/storage/decision-artifact.mjs +++ b/plugins/reporting-governance/src/storage/decision-artifact.mjs @@ -1,9 +1,14 @@ 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_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 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_STATUS_RULES = { planned: ['prepared', 'queued', 'dispatched', 'pending_external_send', 'partial'], @@ -13,6 +18,51 @@ const RECEIPT_STATUS_RULES = { 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) { if (typeof value !== 'string' || value.trim() === '') { throw new Error(`${label} must be a non-empty string`); @@ -54,6 +104,48 @@ function assertSourceEventId(value, label) { 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) { const deliveryState = assertNonEmptyString(receipt.delivery_state, 'decision record artifact spec.receipt.delivery_state'); if (!RECEIPT_DELIVERY_STATES.has(deliveryState)) { @@ -103,6 +195,17 @@ function validateReceiptContract(receipt) { 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) { if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) { 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.policy_id = metadataPolicyId; metadata.decision = metadataDecision; - metadata.event_id = metadataEventId; - decision.policy_id = decisionPolicyId; - decision.decision = decisionName; + metadata.event_id = schemaValidated.metadata.event_id; + spec.decision = schemaValidated.spec.decision; receipt.delivery_state = deliveryState; receipt.status = status; if (source != null) { - source.event_id = sourceEventId; + source.event_id = schemaValidated.spec.source.event_id; } return artifact; @@ -204,12 +308,17 @@ export function createDecisionRecordArtifact({ decision, receipt, recordedAt = n } 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 [ - validatedArtifact.metadata.recorded_at.replace(/[.:]/g, '-'), - sanitizeFileSegment(validatedArtifact.metadata.policy_id, 'policy'), - sanitizeFileSegment(validatedArtifact.metadata.decision, 'decision'), - `${validatedArtifact.metadata.record_id}.json`, + recordedAt.replace(/[.:]/g, '-'), + sanitizeFileSegment(policyId, 'policy'), + sanitizeFileSegment(decision, 'decision'), + `${recordId}.json`, ].join('-'); } diff --git a/plugins/reporting-governance/test/decision-store-runtime.integration.test.mjs b/plugins/reporting-governance/test/decision-store-runtime.integration.test.mjs index feeb8fd..be8d9a9 100644 --- a/plugins/reporting-governance/test/decision-store-runtime.integration.test.mjs +++ b/plugins/reporting-governance/test/decision-store-runtime.integration.test.mjs @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; 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' }; 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 }); } }); + +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 }); + } +});