feat: add governance evaluator and decision runner skeleton

This commit is contained in:
Eve
2026-05-07 23:38:46 +08:00
parent 7fd02348cf
commit c2a775b62c
8 changed files with 761 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
# Reporting Governance Plugin
This package is the emerging package boundary for the reporting-governance mainline.
Current purpose:
- give the plugin a real package home
- publish capability descriptors as package artifacts
- fix boundaries between `core/`, `adapters/`, `storage/`, and reference implementations
- prepare the next implementation round for evaluator / decision-runner extraction
- provide a minimal package-level policy evaluator and decision runner skeleton that can be verified in isolation
## Package skeleton
```text
plugins/reporting-governance/
package.json
README.md
capabilities/
docs/
examples/
src/
core/
index.mjs
policy-evaluator.mjs
decision-runner.mjs
adapters/
storage/
reference/
index.mjs
test/
```
## Boundary rules
### `src/core/`
Runtime-agnostic governance logic:
- canonical event normalization
- evidence building
- policy evaluation
- decision running
- capability/profile compatibility
### `src/adapters/`
Runtime-facing adapter modules:
- watchdog adapter
- dispatcher adapter
- bridge adapter
- sender-binding adapter
- orchestrator adapter
These may initially wrap existing repo scripts while extraction is still in progress.
### `src/storage/`
Durable I/O contracts for governance artifacts:
- events
- evidence
- queue items
- spool artifacts
- receipts
- future decisions / audit manifests
### `src/reference/`
Reference runtime compositions and migration notes.
**The watchdog reference runtime composition belongs here**, as a reference implementation for OpenClaw rather than as package core logic.
## Current reference composition
The current reference composition is the OpenClaw watchdog chain:
```text
watchdog -> queue -> dispatcher -> bridge -> sender binding -> acked|blocked|pending_external_send
```
Package-home documentation:
- `src/reference/openclaw-watchdog-chain.md`
- `capabilities/openclaw-watchdog-reference.json`
Mainline background specs remain in:
- `docs/specs/reporting-governance-capability-descriptor.md`
- `docs/specs/reporting-governance-adapter-interface.md`
- `docs/specs/reporting-governance-deployment-model.md`
## Minimal evaluator / decision runner now included
The current package now includes a small but runnable `core/` implementation:
- `src/core/policy-evaluator.mjs`
- `src/core/decision-runner.mjs`
- `src/core/index.mjs`
Current package-core responsibilities:
- normalize evaluator facts from canonical event payload + evidence + local context
- match policy-pack rules by trigger and structured conditions
- produce canonical decision-model shaped decision objects
- choose the highest-precedence decision when multiple rules match
- convert a canonical decision into an execution plan, enforcement intent, and receipt skeleton
- truthfully degrade unsupported enforcement paths based on the capability descriptor
Still **runtime-adapter responsibility** at this stage:
- intercepting real outgoing messages or status transitions inline
- actually sending operator notices
- acking final delivery to external channels
- persisting decisions/receipts into a production decision store
- installing schedulers / watchdog loops / bridge sender bindings
This means `core/` now owns evaluation and planning semantics, while adapters still own actual enforcement side effects.
## Not yet included
This package still does **not** claim full implementation of:
- generalized event normalization modules
- generalized evidence builder modules
- production decision persistence
- complete rewrite / placeholder / review / status-downgrade adapter execution
- non-watchdog full runtime governance interception
It now provides the first package-mainline evaluator / decision-runner core, but the remaining enforcement surface is still intentionally honest about adapter gaps.

View File

@@ -0,0 +1,13 @@
{
"name": "@openclaw/plugin-reporting-governance",
"version": "0.1.0-mainline",
"private": true,
"type": "module",
"description": "Reporting governance plugin package skeleton with capability descriptors and OpenClaw reference adapter boundaries.",
"exports": {
".": "./src/index.mjs"
},
"scripts": {
"test": "node --test test/package-structure.test.mjs test/policy-evaluator.test.mjs test/decision-runner.test.mjs"
}
}

View File

@@ -0,0 +1,112 @@
function safeArray(value) {
return Array.isArray(value) ? value : [];
}
function enforcementSupport(capabilityDescriptor = {}, actionName) {
return capabilityDescriptor?.capabilities?.enforcement?.[actionName] ?? { supported: false, level: 'none' };
}
function notificationTruthModel(capabilityDescriptor = {}) {
return capabilityDescriptor?.capabilities?.notification_path?.truth_model ?? {};
}
function isTruthySupportLevel(level) {
return level === 'partial' || level === 'full';
}
function createReceipt({ decision, actionPlans, blockedActions, delivery_state, notes = [] }) {
return {
policy_id: decision.policy_id,
decision: decision.decision,
status: blockedActions.length > 0 ? 'degraded' : 'planned',
delivery_state,
enforcement_intent: actionPlans,
blocked_actions: blockedActions,
operator_notice_required: Boolean(decision.operator_notice?.required),
notes
};
}
function mapActionToCapability(action) {
switch (action) {
case 'rewrite_message':
return 'rewrite_message';
case 'record_placeholder':
return 'annotate_placeholder';
case 'set_status':
return 'downgrade_status';
case 'request_review':
return 'request_review';
case 'raise_escalation':
return 'escalate';
case 'block_transition':
return 'block_transition';
case 'notify_operator':
case 'dispatch_message':
return 'force_checkpoint';
default:
return null;
}
}
export function planDecisionExecution({ decision, capabilityDescriptor = {} }) {
if (!decision) {
throw new Error('decision is required');
}
const truthModel = notificationTruthModel(capabilityDescriptor);
const deliveryStates = safeArray(truthModel.delivery_states);
const actionPlans = [];
const blockedActions = [];
const notes = [];
for (const requiredAction of safeArray(decision.required_actions)) {
const capabilityName = mapActionToCapability(requiredAction.action);
const capability = capabilityName ? enforcementSupport(capabilityDescriptor, capabilityName) : { supported: true, level: 'full' };
const supported = capabilityName ? capability.supported && isTruthySupportLevel(capability.level) : true;
if (supported) {
actionPlans.push({
...requiredAction,
execution_mode: requiredAction.action === 'notify_operator' || requiredAction.action === 'dispatch_message'
? 'runtime_adapter_dispatch'
: 'package_core_signal',
capability: capabilityName,
support_level: capability.level ?? 'full'
});
continue;
}
blockedActions.push({
...requiredAction,
capability: capabilityName,
support_level: capability.level ?? 'none',
reason: 'runtime capability descriptor does not support this enforcement path'
});
}
let delivery_state = 'prepared';
if ((decision.decision === 'force_checkpoint' || decision.decision === 'annotate_placeholder') && deliveryStates.includes('pending_external_send')) {
delivery_state = 'pending_external_send';
notes.push('Operator-visible send remains a runtime-adapter responsibility until final sender delivery is acknowledged.');
} else if (decision.decision === 'block' && deliveryStates.includes('blocked')) {
delivery_state = 'blocked';
}
if (blockedActions.some((action) => action.mandatory)) {
notes.push('One or more mandatory actions could not be planned truthfully from the advertised runtime capabilities.');
}
return {
decision,
enforcement_intent: {
policy_id: decision.policy_id,
decision: decision.decision,
planned_actions: actionPlans,
blocked_actions: blockedActions,
runtime_adapter_required: actionPlans.filter((action) => action.execution_mode === 'runtime_adapter_dispatch').map((action) => action.action),
package_core_actions: actionPlans.filter((action) => action.execution_mode === 'package_core_signal').map((action) => action.action)
},
receipt: createReceipt({ decision, actionPlans, blockedActions, delivery_state, notes })
};
}

View File

@@ -0,0 +1,2 @@
export { evaluatePolicyPack, evaluatePolicies } from './policy-evaluator.mjs';
export { planDecisionExecution } from './decision-runner.mjs';

View File

@@ -0,0 +1,251 @@
const DECISION_PRECEDENCE = {
allow: 0,
annotate_placeholder: 1,
rewrite: 2,
require_review: 3,
downgrade_status: 4,
force_checkpoint: 5,
block: 6,
escalate: 7
};
const EVENT_TYPE_TO_CLAIM_TYPE = {
task_claimed_complete: 'completion',
task_verified_complete: 'verified_completion',
task_checkpoint_sent: 'progress',
forced_operator_update: 'progress'
};
const QUALITY_RANK = {
none: 0,
weak: 1,
moderate: 2,
strong: 3
};
function safeArray(value) {
return Array.isArray(value) ? value : [];
}
function getByPath(source, dottedPath) {
if (!source || !dottedPath) return undefined;
return dottedPath.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), source);
}
function compareFact(actual, condition) {
if (Object.hasOwn(condition, 'equals')) return actual === condition.equals;
if (Object.hasOwn(condition, 'not_equals')) return actual !== condition.not_equals;
if (Object.hasOwn(condition, 'greater_than')) return Number(actual) > Number(condition.greater_than);
if (Object.hasOwn(condition, 'less_than')) return Number(actual) < Number(condition.less_than);
if (Object.hasOwn(condition, 'contains')) return Array.isArray(actual) ? actual.includes(condition.contains) : String(actual ?? '').includes(String(condition.contains));
if (Object.hasOwn(condition, 'in')) return safeArray(condition.in).includes(actual);
return Boolean(actual);
}
function matchesConditions(conditions = {}, facts = {}) {
const groups = Object.keys(conditions);
if (groups.length === 0) return true;
if (Array.isArray(conditions.all)) {
return conditions.all.every((entry) => evaluateConditionEntry(entry, facts));
}
if (Array.isArray(conditions.any)) {
return conditions.any.some((entry) => evaluateConditionEntry(entry, facts));
}
if (conditions.not) {
return !evaluateConditionEntry(conditions.not, facts);
}
return true;
}
function evaluateConditionEntry(entry, facts) {
if (!entry || typeof entry !== 'object') return false;
if (entry.all || entry.any || entry.not) {
return matchesConditions(entry, facts);
}
const actual = getByPath(facts, entry.fact);
return compareFact(actual, entry);
}
function matchesTriggers(rule = {}, event = {}, facts = {}) {
const triggers = rule.triggers ?? {};
const eventTypes = safeArray(triggers.event_types);
const derivedSignals = safeArray(triggers.derived_signals);
const claimTypes = safeArray(triggers.claim_types);
if (eventTypes.length > 0 && !eventTypes.includes(event.type)) return false;
if (derivedSignals.length > 0 && !derivedSignals.every((signal) => facts.signals?.includes?.(signal))) return false;
const claimType = facts.claim?.type;
if (claimTypes.length > 0 && !claimTypes.includes(claimType)) return false;
return true;
}
function findHighestEvidenceQuality(evidence = []) {
return safeArray(evidence).reduce((best, item) => {
const quality = item?.quality ?? 'none';
return QUALITY_RANK[quality] > QUALITY_RANK[best] ? quality : best;
}, 'none');
}
function countNewEvidence(evidence = []) {
return safeArray(evidence).filter((item) => item?.is_new !== false).length;
}
function buildFacts({ event, evidence, capabilityDescriptor, context }) {
const eventPayload = event?.payload ?? {};
const evidenceItems = safeArray(evidence);
const topQuality = findHighestEvidenceQuality(evidenceItems);
const newEvidenceCount = countNewEvidence(evidenceItems);
const promisedFollowupDue = Boolean(eventPayload.promised_followup_due ?? context?.promised_followup_due);
const hasRequiredCheckpointFields = Boolean(context?.message?.has_required_checkpoint_fields);
const resultForwarded = Boolean(eventPayload.result_forwarded ?? context?.forwarding?.result_forwarded);
const resultAvailable = Boolean(eventPayload.result_available ?? context?.forwarding?.result_available);
const isSilentTask = Boolean(eventPayload.silent_task ?? context?.task?.silent_task);
const hasReportAnchor = Boolean(context?.operator_context?.report_anchor_present ?? eventPayload.report_anchor_present);
const externalizedPathValid = Boolean(context?.checkpoint?.externalized_path_valid ?? eventPayload.externalized_path_valid);
const completionMinQuality = QUALITY_RANK[topQuality] >= QUALITY_RANK.moderate;
const verifiedCompletionMinQuality = QUALITY_RANK[topQuality] >= QUALITY_RANK.strong;
const forcedSignals = safeArray(context?.signals);
const supports = capabilityDescriptor?.capabilities?.enforcement ?? {};
return {
checkpoint: {
is_overdue: Boolean(eventPayload.checkpoint_overdue ?? context?.checkpoint?.is_overdue),
externalized_path_valid: externalizedPathValid
},
forwarding: {
result_available_without_visible_followup: resultAvailable && !resultForwarded,
result_available: resultAvailable,
result_forwarded: resultForwarded
},
evidence: {
new_items_since_last_checkpoint: newEvidenceCount,
completion_min_quality: completionMinQuality,
verified_completion_min_quality: verifiedCompletionMinQuality,
top_quality: topQuality
},
claim: {
type: eventPayload.claim_type ?? EVENT_TYPE_TO_CLAIM_TYPE[event?.type] ?? 'generic',
promised_followup_due: promisedFollowupDue,
next_step_has_supporting_evidence: newEvidenceCount > 0
},
message: {
has_required_checkpoint_fields: hasRequiredCheckpointFields
},
operator_context: {
report_anchor: {
present: hasReportAnchor
}
},
task: {
silent_task: isSilentTask
},
runtime: {
enforcement: supports
},
signals: forcedSignals
};
}
function createAllowDecision(defaultPolicyId = 'policy.allow') {
return {
decision: 'allow',
policy_id: defaultPolicyId,
severity: 'info',
reason: 'no policy rule matched the evaluated event and evidence',
rewritten_message: null,
suggested_status: null,
required_actions: [],
operator_notice: null
};
}
function normalizeDecision({ rule, pack, facts }) {
const output = rule.decision_output ?? {};
const operatorMessageTemplates = rule.operator_message_templates ?? {};
const message = output.rewritten_message
?? operatorMessageTemplates.blocked
?? operatorMessageTemplates.checkpoint_forced
?? operatorMessageTemplates.placeholder_rewrite
?? operatorMessageTemplates.review_required
?? operatorMessageTemplates.status_downgraded
?? null;
return {
decision: output.decision ?? 'allow',
policy_id: rule.id ?? pack?.metadata?.id ?? 'unknown-policy',
severity: output.severity ?? pack?.metadata?.severity_default ?? 'medium',
reason: output.reason ?? rule.intent ?? `${rule.title ?? 'policy rule'} matched`,
rewritten_message: message,
suggested_status: output.suggested_status ?? null,
required_actions: safeArray(output.required_actions),
operator_notice: output.operator_notice ?? null,
matched_rule: {
id: rule.id ?? null,
title: rule.title ?? null,
pack_id: pack?.metadata?.id ?? null
},
facts_snapshot: facts
};
}
export function evaluatePolicyPack({ event, evidence = [], capabilityDescriptor = {}, policyPack, context = {} }) {
if (!policyPack?.spec?.rules) {
throw new Error('policyPack.spec.rules is required');
}
const facts = buildFacts({ event, evidence, capabilityDescriptor, context });
const matches = [];
for (const rule of policyPack.spec.rules) {
if (!matchesTriggers(rule, event, facts)) continue;
if (!matchesConditions(rule.conditions, facts)) continue;
matches.push(normalizeDecision({ rule, pack: policyPack, facts }));
if (policyPack.spec.evaluation_mode === 'first_match') break;
}
const winningDecision = matches.sort((left, right) => (DECISION_PRECEDENCE[right.decision] ?? -1) - (DECISION_PRECEDENCE[left.decision] ?? -1))[0];
return {
facts,
matches,
decision: winningDecision ?? createAllowDecision(policyPack?.metadata?.id)
};
}
export function evaluatePolicies({ event, evidence = [], capabilityDescriptor = {}, policyPacks = [], context = {} }) {
const evaluations = safeArray(policyPacks).map((policyPack) => evaluatePolicyPack({
event,
evidence,
capabilityDescriptor,
policyPack,
context
}));
const matchedDecisions = evaluations
.map((evaluation) => evaluation.decision)
.filter((decision) => decision.decision !== 'allow');
const finalDecision = matchedDecisions.sort((left, right) => (DECISION_PRECEDENCE[right.decision] ?? -1) - (DECISION_PRECEDENCE[left.decision] ?? -1))[0]
?? createAllowDecision('policy.allow');
return {
evaluations,
decision: finalDecision
};
}
export const __testables = {
DECISION_PRECEDENCE,
buildFacts,
matchesConditions,
matchesTriggers,
findHighestEvidenceQuality,
countNewEvidence
};

View File

@@ -0,0 +1,32 @@
export const packageName = '@openclaw/plugin-reporting-governance';
export const packageVersion = '0.1.0-mainline';
export const packageBoundaries = {
core: [
'event normalization',
'evidence building',
'policy evaluation',
'decision running',
'capability/profile compatibility'
],
adapters: [
'watchdog adapter',
'dispatcher adapter',
'bridge adapter',
'sender-binding adapter',
'orchestrator adapter'
],
storage: [
'event store',
'evidence store',
'queue store',
'spool store',
'receipt store',
'decision store'
],
reference: [
'openclaw watchdog chain'
]
};
export { evaluatePolicyPack, evaluatePolicies, planDecisionExecution } from './core/index.mjs';

View File

@@ -0,0 +1,81 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { planDecisionExecution } from '../src/core/decision-runner.mjs';
const baseCapabilityDescriptor = {
capabilities: {
enforcement: {
block_transition: { supported: true, level: 'partial' },
force_checkpoint: { supported: true, level: 'partial' },
rewrite_message: { supported: false, level: 'none' },
annotate_placeholder: { supported: false, level: 'none' },
request_review: { supported: false, level: 'none' },
downgrade_status: { supported: false, level: 'none' },
escalate: { supported: true, level: 'full' }
},
notification_path: {
truth_model: {
delivery_states: ['prepared', 'queued', 'dispatched', 'pending_external_send', 'acked', 'blocked'],
ack_requires_proven_send: true,
pending_external_send_supported: true
}
}
}
};
test('planDecisionExecution produces runtime-adapter dispatch intent for force_checkpoint', () => {
const result = planDecisionExecution({
decision: {
decision: 'force_checkpoint',
policy_id: 'no-silence.missed-checkpoint',
severity: 'high',
reason: 'checkpoint overdue',
rewritten_message: 'Required update.',
suggested_status: 'in_progress',
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: baseCapabilityDescriptor
});
assert.equal(result.receipt.delivery_state, 'pending_external_send');
assert.deepEqual(result.enforcement_intent.runtime_adapter_required, ['notify_operator']);
assert.ok(result.receipt.notes.some((note) => note.includes('runtime-adapter responsibility')));
});
test('planDecisionExecution truthfully blocks unsupported package action paths', () => {
const result = planDecisionExecution({
decision: {
decision: 'downgrade_status',
policy_id: 'verified-completion-only.insufficient-evidence',
severity: 'high',
reason: 'completion evidence too weak',
rewritten_message: null,
suggested_status: 'pending_verification',
required_actions: [
{
action: 'set_status',
target: 'task_record',
mandatory: true,
details: { to: 'pending_verification' }
}
],
operator_notice: null
},
capabilityDescriptor: baseCapabilityDescriptor
});
assert.equal(result.receipt.status, 'degraded');
assert.equal(result.enforcement_intent.planned_actions.length, 0);
assert.equal(result.enforcement_intent.blocked_actions[0].action, 'set_status');
});

View File

@@ -0,0 +1,143 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { evaluatePolicyPack, evaluatePolicies } from '../src/core/policy-evaluator.mjs';
const capabilityDescriptor = {
capabilities: {
enforcement: {
block_transition: { supported: true, level: 'partial' },
force_checkpoint: { supported: true, level: 'partial' },
rewrite_message: { supported: false, level: 'none' },
annotate_placeholder: { supported: false, level: 'none' },
request_review: { supported: false, level: 'none' },
downgrade_status: { supported: false, level: 'none' },
escalate: { supported: true, level: 'full' }
}
}
};
const noSilencePack = {
metadata: { id: 'no-silence', severity_default: 'high' },
spec: {
evaluation_mode: 'any_rule_match',
rules: [
{
id: 'no-silence.missed-checkpoint',
title: 'Missed checkpoint requires visible recovery',
intent: 'Prevent overdue checkpoints from becoming invisible.',
triggers: { event_types: ['silence_timeout'] },
conditions: {
all: [
{ fact: 'checkpoint.is_overdue', equals: true }
]
},
decision_output: {
decision: 'force_checkpoint',
severity: 'high',
reason: 'checkpoint overdue triggered forced operator-visible recovery',
suggested_status: 'in_progress',
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: checkpoint overdue.',
deadline: '2026-01-01T00:00:00.000Z'
}
},
operator_message_templates: {
checkpoint_forced: 'Required update: task exceeded allowed silence window.'
}
}
]
}
};
const verifiedCompletionPack = {
metadata: { id: 'verified-completion-only', severity_default: 'medium' },
spec: {
evaluation_mode: 'any_rule_match',
rules: [
{
id: 'verified-completion-only.insufficient-evidence',
title: 'Unsupported completion is downgraded',
intent: 'Completion claims require moderate evidence.',
triggers: {
event_types: ['task_claimed_complete'],
claim_types: ['completion']
},
conditions: {
all: [
{ fact: 'evidence.completion_min_quality', equals: false }
]
},
decision_output: {
decision: 'downgrade_status',
severity: 'high',
reason: 'completion evidence does not meet moderate threshold',
suggested_status: 'pending_verification',
required_actions: [
{
action: 'set_status',
target: 'task_record',
mandatory: true,
details: { to: 'pending_verification' }
},
{
action: 'append_audit_note',
target: 'task_record',
mandatory: true,
details: { note: 'downgraded unsupported completion' }
}
],
operator_notice: null
},
operator_message_templates: {
status_downgraded: 'Completion claim downgraded to pending verification.'
}
}
]
}
};
test('evaluatePolicyPack returns force_checkpoint for overdue silence event', () => {
const result = evaluatePolicyPack({
event: {
type: 'silence_timeout',
payload: { checkpoint_overdue: true }
},
evidence: [],
capabilityDescriptor,
policyPack: noSilencePack,
context: {
signals: ['checkpoint_overdue']
}
});
assert.equal(result.decision.decision, 'force_checkpoint');
assert.equal(result.decision.policy_id, 'no-silence.missed-checkpoint');
assert.equal(result.decision.operator_notice.required, true);
assert.equal(result.facts.checkpoint.is_overdue, true);
});
test('evaluatePolicies picks downgrade_status over allow for weak completion claim', () => {
const result = evaluatePolicies({
event: {
type: 'task_claimed_complete',
payload: {}
},
evidence: [
{ id: 'ev-1', quality: 'weak', is_new: true }
],
capabilityDescriptor,
policyPacks: [verifiedCompletionPack]
});
assert.equal(result.decision.decision, 'downgrade_status');
assert.equal(result.decision.suggested_status, 'pending_verification');
assert.equal(result.evaluations[0].decision.policy_id, 'verified-completion-only.insufficient-evidence');
});