Add plain-language status doc and minimal decision store contract
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
"./adapters/orchestrator": "./src/adapters/orchestrator.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/package-structure.test.mjs test/policy-evaluator.test.mjs test/compatibility-preflight.test.mjs test/profile-artifact.test.mjs test/profile-generator.test.mjs test/decision-runner.test.mjs test/governance-contract.integration.test.mjs test/watchdog-chain.integration.test.mjs test/runtime-integrated.integration.test.mjs test/exports-boundary.integration.test.mjs"
|
||||
"test": "node --test test/package-structure.test.mjs test/policy-evaluator.test.mjs test/compatibility-preflight.test.mjs test/profile-artifact.test.mjs test/profile-generator.test.mjs test/decision-runner.test.mjs test/decision-store.test.mjs test/governance-contract.integration.test.mjs test/watchdog-chain.integration.test.mjs test/runtime-integrated.integration.test.mjs test/exports-boundary.integration.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export const packageName = '@openclaw/plugin-reporting-governance';
|
||||
export const packageVersion = '0.1.0-mainline';
|
||||
|
||||
export const artifactKinds = {
|
||||
deploymentProfile: 'DeploymentProfileArtifact',
|
||||
decisionRecord: 'DecisionRecordArtifact',
|
||||
};
|
||||
|
||||
export const packageBoundaries = {
|
||||
core: [
|
||||
'event normalization',
|
||||
@@ -46,3 +51,9 @@ export {
|
||||
runOrchestratorAdapter,
|
||||
} from './adapters/index.mjs';
|
||||
export { runOrchestratorAdapter as runWatchdogChain } from './adapters/orchestrator.mjs';
|
||||
export {
|
||||
createDecisionRecordArtifact,
|
||||
createDecisionRecordFileName,
|
||||
createFileDecisionStore,
|
||||
validateDecisionRecordArtifact,
|
||||
} from './storage/index.mjs';
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const EXPECTED_KIND = 'DecisionRecordArtifact';
|
||||
const EXPECTED_API_VERSION = 'reporting-governance/v1alpha1';
|
||||
|
||||
function assertNonEmptyString(value, label) {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`${label} must be a non-empty string`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function assertObjectRecord(value, label) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object record`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sanitizeFileSegment(value, fallback) {
|
||||
const normalized = String(value ?? '').trim().replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
export function validateDecisionRecordArtifact(artifact) {
|
||||
if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
|
||||
throw new Error('decision record artifact must be an object');
|
||||
}
|
||||
if (artifact.kind !== EXPECTED_KIND) {
|
||||
throw new Error(`decision record artifact kind must be ${EXPECTED_KIND}`);
|
||||
}
|
||||
if (artifact.apiVersion !== EXPECTED_API_VERSION) {
|
||||
throw new Error(`decision record artifact apiVersion must be ${EXPECTED_API_VERSION}`);
|
||||
}
|
||||
|
||||
const metadata = assertObjectRecord(artifact.metadata, 'decision record artifact metadata');
|
||||
const spec = assertObjectRecord(artifact.spec, 'decision record artifact spec');
|
||||
const decision = assertObjectRecord(spec.decision, 'decision record artifact spec.decision');
|
||||
const receipt = assertObjectRecord(spec.receipt, 'decision record artifact spec.receipt');
|
||||
|
||||
assertNonEmptyString(metadata.recorded_at, 'decision record artifact metadata.recorded_at');
|
||||
assertNonEmptyString(metadata.policy_id, 'decision record artifact metadata.policy_id');
|
||||
assertNonEmptyString(metadata.decision, 'decision record artifact metadata.decision');
|
||||
assertNonEmptyString(decision.policy_id, 'decision record artifact spec.decision.policy_id');
|
||||
assertNonEmptyString(decision.decision, 'decision record artifact spec.decision.decision');
|
||||
assertNonEmptyString(receipt.delivery_state, 'decision record artifact spec.receipt.delivery_state');
|
||||
|
||||
return artifact;
|
||||
}
|
||||
|
||||
export function createDecisionRecordArtifact({ decision, receipt, recordedAt = new Date().toISOString(), source = {} } = {}) {
|
||||
const normalizedDecision = assertObjectRecord(decision, 'decision');
|
||||
const normalizedReceipt = assertObjectRecord(receipt, 'receipt');
|
||||
const policyId = assertNonEmptyString(normalizedDecision.policy_id, 'decision.policy_id');
|
||||
const decisionName = assertNonEmptyString(normalizedDecision.decision, 'decision.decision');
|
||||
|
||||
return validateDecisionRecordArtifact({
|
||||
kind: EXPECTED_KIND,
|
||||
apiVersion: EXPECTED_API_VERSION,
|
||||
metadata: {
|
||||
record_id: `dec_${crypto.randomUUID()}`,
|
||||
recorded_at: assertNonEmptyString(recordedAt, 'recordedAt'),
|
||||
policy_id: policyId,
|
||||
decision: decisionName,
|
||||
correlation_id: source.correlation_id ?? null,
|
||||
task_id: source.task_id ?? null,
|
||||
event_id: source.event_id ?? null,
|
||||
},
|
||||
spec: {
|
||||
decision: normalizedDecision,
|
||||
receipt: normalizedReceipt,
|
||||
source: {
|
||||
event_id: source.event_id ?? null,
|
||||
task_id: source.task_id ?? null,
|
||||
correlation_id: source.correlation_id ?? null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createDecisionRecordFileName(artifact) {
|
||||
const validatedArtifact = validateDecisionRecordArtifact(artifact);
|
||||
return [
|
||||
validatedArtifact.metadata.recorded_at.replace(/[.:]/g, '-'),
|
||||
sanitizeFileSegment(validatedArtifact.metadata.policy_id, 'policy'),
|
||||
sanitizeFileSegment(validatedArtifact.metadata.decision, 'decision'),
|
||||
`${validatedArtifact.metadata.record_id}.json`,
|
||||
].join('-');
|
||||
}
|
||||
|
||||
export const __testables = {
|
||||
EXPECTED_KIND,
|
||||
EXPECTED_API_VERSION,
|
||||
sanitizeFileSegment,
|
||||
};
|
||||
43
plugins/reporting-governance/src/storage/decision-store.mjs
Normal file
43
plugins/reporting-governance/src/storage/decision-store.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
createDecisionRecordArtifact,
|
||||
createDecisionRecordFileName,
|
||||
validateDecisionRecordArtifact,
|
||||
} from './decision-artifact.mjs';
|
||||
import { assertUseTimePathWithinRepoRoot } from './profile-artifact.mjs';
|
||||
|
||||
export function createFileDecisionStore({ decisionsDir, repoRootOverride } = {}) {
|
||||
const resolvedDecisionsDir = assertUseTimePathWithinRepoRoot(
|
||||
path.resolve(decisionsDir ?? 'state/reporting-governance-decisions'),
|
||||
'decision store decisionsDir',
|
||||
{ repoRootOverride, allowMissingLeaf: true }
|
||||
);
|
||||
|
||||
return {
|
||||
decisionsDir: resolvedDecisionsDir,
|
||||
write({ decision, receipt, recordedAt, source } = {}) {
|
||||
fs.mkdirSync(resolvedDecisionsDir, { recursive: true });
|
||||
const artifact = createDecisionRecordArtifact({ decision, receipt, recordedAt, source });
|
||||
const artifactPath = path.join(resolvedDecisionsDir, createDecisionRecordFileName(artifact));
|
||||
fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
|
||||
return {
|
||||
artifact,
|
||||
artifactPath,
|
||||
};
|
||||
},
|
||||
load(artifactPath) {
|
||||
const resolvedPath = assertUseTimePathWithinRepoRoot(
|
||||
path.resolve(artifactPath),
|
||||
'decision store artifactPath',
|
||||
{ repoRootOverride }
|
||||
);
|
||||
const artifact = validateDecisionRecordArtifact(JSON.parse(fs.readFileSync(resolvedPath, 'utf8')));
|
||||
return {
|
||||
artifact,
|
||||
artifactPath: resolvedPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,3 +8,9 @@ export {
|
||||
generateDeploymentProfileArtifact,
|
||||
generateDeploymentProfileArtifactFromFile,
|
||||
} from './profile-generator.mjs';
|
||||
export {
|
||||
createDecisionRecordArtifact,
|
||||
createDecisionRecordFileName,
|
||||
validateDecisionRecordArtifact,
|
||||
} from './decision-artifact.mjs';
|
||||
export { createFileDecisionStore } from './decision-store.mjs';
|
||||
|
||||
133
plugins/reporting-governance/test/decision-store.test.mjs
Normal file
133
plugins/reporting-governance/test/decision-store.test.mjs
Normal file
@@ -0,0 +1,133 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
createDecisionRecordArtifact,
|
||||
createDecisionRecordFileName,
|
||||
createFileDecisionStore,
|
||||
validateDecisionRecordArtifact,
|
||||
} from '../src/storage/index.mjs';
|
||||
import { planDecisionExecution } from '../src/core/decision-runner.mjs';
|
||||
|
||||
const packageRoot = path.resolve(import.meta.dirname, '..');
|
||||
const repoRoot = path.resolve(packageRoot, '..', '..');
|
||||
|
||||
const capabilityDescriptor = {
|
||||
capabilities: {
|
||||
enforcement: {
|
||||
force_checkpoint: { supported: true, level: 'partial' },
|
||||
escalate: { supported: true, level: 'full' }
|
||||
},
|
||||
notification_path: {
|
||||
queue_items: { supported: true, level: 'full' },
|
||||
spool_handoff: { supported: true, level: 'full' },
|
||||
sender_binding: { supported: true, level: 'full' },
|
||||
direct_send: { supported: false, level: 'none' },
|
||||
truth_model: {
|
||||
delivery_states: ['prepared', 'queued', 'dispatched', 'pending_external_send', 'acked', 'blocked'],
|
||||
ack_requires_proven_send: true,
|
||||
pending_external_send_supported: true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function createPlannedDecision() {
|
||||
return planDecisionExecution({
|
||||
decision: {
|
||||
decision: 'force_checkpoint',
|
||||
policy_id: 'no-silence.missed-checkpoint',
|
||||
severity: 'high',
|
||||
reason: 'checkpoint overdue',
|
||||
required_actions: [
|
||||
{ action: 'notify_operator', target: 'operator_channel', mandatory: true },
|
||||
{ action: 'emit_event', target: 'event_stream', mandatory: true }
|
||||
],
|
||||
operator_notice: {
|
||||
required: true,
|
||||
channel: 'telegram',
|
||||
urgency: 'high',
|
||||
message: 'Required update.',
|
||||
deadline: '2026-01-01T00:00:00.000Z'
|
||||
}
|
||||
},
|
||||
capabilityDescriptor,
|
||||
});
|
||||
}
|
||||
|
||||
test('decision artifact validates minimal package-owned contract', () => {
|
||||
const planned = createPlannedDecision();
|
||||
const artifact = createDecisionRecordArtifact({
|
||||
decision: planned.decision,
|
||||
receipt: planned.receipt,
|
||||
recordedAt: '2026-05-08T04:00:00.000Z',
|
||||
source: {
|
||||
event_id: 'evt_watchdog_001',
|
||||
task_id: 'task-reporting-governance',
|
||||
correlation_id: 'corr-001',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(artifact.kind, 'DecisionRecordArtifact');
|
||||
assert.equal(artifact.apiVersion, 'reporting-governance/v1alpha1');
|
||||
assert.equal(artifact.metadata.policy_id, 'no-silence.missed-checkpoint');
|
||||
assert.equal(artifact.spec.receipt.delivery_state, 'pending_external_send');
|
||||
assert.equal(validateDecisionRecordArtifact(artifact), artifact);
|
||||
});
|
||||
|
||||
test('decision artifact filename is stable and readable', () => {
|
||||
const planned = createPlannedDecision();
|
||||
const artifact = createDecisionRecordArtifact({
|
||||
decision: planned.decision,
|
||||
receipt: planned.receipt,
|
||||
recordedAt: '2026-05-08T04:00:00.000Z',
|
||||
});
|
||||
|
||||
const fileName = createDecisionRecordFileName(artifact);
|
||||
assert.match(fileName, /^2026-05-08T04-00-00-000Z-no-silence\.missed-checkpoint-force_checkpoint-dec_[a-f0-9-]+\.json$/);
|
||||
});
|
||||
|
||||
test('file decision store writes and reloads a validated decision artifact inside repo root', async (t) => {
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'reporting-governance-decision-store-'));
|
||||
t.after(() => fs.rmSync(sandbox, { recursive: true, force: true }));
|
||||
|
||||
const fakeRepoRoot = path.join(sandbox, 'repo');
|
||||
fs.mkdirSync(fakeRepoRoot, { recursive: true });
|
||||
|
||||
const planned = createPlannedDecision();
|
||||
const store = createFileDecisionStore({
|
||||
decisionsDir: path.join(fakeRepoRoot, 'state', 'decisions'),
|
||||
repoRootOverride: fakeRepoRoot,
|
||||
});
|
||||
|
||||
const written = store.write({
|
||||
decision: planned.decision,
|
||||
receipt: planned.receipt,
|
||||
recordedAt: '2026-05-08T04:00:00.000Z',
|
||||
source: {
|
||||
event_id: 'evt_watchdog_001',
|
||||
task_id: 'task-reporting-governance',
|
||||
correlation_id: 'corr-001',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(fs.existsSync(written.artifactPath), true);
|
||||
assert.match(path.basename(written.artifactPath), /^2026-05-08T04-00-00-000Z-no-silence\.missed-checkpoint-force_checkpoint-dec_[a-f0-9-]+\.json$/);
|
||||
|
||||
const loaded = store.load(written.artifactPath);
|
||||
assert.equal(loaded.artifact.metadata.event_id, 'evt_watchdog_001');
|
||||
assert.equal(loaded.artifact.spec.receipt.delivery_state, 'pending_external_send');
|
||||
});
|
||||
|
||||
test('file decision store rejects decision directory escaping repo root', () => {
|
||||
assert.throws(
|
||||
() => createFileDecisionStore({
|
||||
decisionsDir: path.resolve(repoRoot, '..', 'escape'),
|
||||
repoRootOverride: repoRoot,
|
||||
}),
|
||||
/decision store decisionsDir must stay within repo root/
|
||||
);
|
||||
});
|
||||
@@ -66,18 +66,27 @@ test('package root export resolves public package surface only', () => {
|
||||
import * as plugin from '@openclaw/plugin-reporting-governance';
|
||||
process.stdout.write(JSON.stringify({
|
||||
packageName: plugin.packageName,
|
||||
artifactKinds: plugin.artifactKinds,
|
||||
hasRunWatchdogChain: typeof plugin.runWatchdogChain,
|
||||
hasPlanDecisionExecution: typeof plugin.planDecisionExecution,
|
||||
hasExecuteGovernanceContract: typeof plugin.executeGovernanceContract,
|
||||
hasExecuteRuntimeIntegratedGovernance: typeof plugin.executeRuntimeIntegratedGovernance,
|
||||
hasCreateDecisionRecordArtifact: typeof plugin.createDecisionRecordArtifact,
|
||||
hasCreateFileDecisionStore: typeof plugin.createFileDecisionStore,
|
||||
}));
|
||||
`);
|
||||
|
||||
assert.equal(result.packageName, '@openclaw/plugin-reporting-governance');
|
||||
assert.deepEqual(result.artifactKinds, {
|
||||
deploymentProfile: 'DeploymentProfileArtifact',
|
||||
decisionRecord: 'DecisionRecordArtifact',
|
||||
});
|
||||
assert.equal(result.hasRunWatchdogChain, 'function');
|
||||
assert.equal(result.hasPlanDecisionExecution, 'function');
|
||||
assert.equal(result.hasExecuteGovernanceContract, 'function');
|
||||
assert.equal(result.hasExecuteRuntimeIntegratedGovernance, 'function');
|
||||
assert.equal(result.hasCreateDecisionRecordArtifact, 'function');
|
||||
assert.equal(result.hasCreateFileDecisionStore, 'function');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ const requiredPaths = [
|
||||
'src/adapters/orchestrator.mjs',
|
||||
'src/storage',
|
||||
'src/storage/profile-artifact.mjs',
|
||||
'src/storage/decision-artifact.mjs',
|
||||
'src/storage/decision-store.mjs',
|
||||
'src/reference/openclaw-watchdog-chain.md',
|
||||
'capabilities/openclaw-watchdog-reference.json',
|
||||
'examples/openclaw-watchdog-reference.descriptor.example.json',
|
||||
|
||||
Reference in New Issue
Block a user