Add plain-language status doc and minimal decision store contract

This commit is contained in:
Eve
2026-05-08 13:08:50 +08:00
parent e13355cd40
commit 354c00dea1
9 changed files with 531 additions and 1 deletions

View File

@@ -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';

View File

@@ -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,
};

View 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,
};
},
};
}

View File

@@ -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';