feat: add governance evaluator and decision runner skeleton
This commit is contained in:
127
plugins/reporting-governance/README.md
Normal file
127
plugins/reporting-governance/README.md
Normal 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.
|
||||||
13
plugins/reporting-governance/package.json
Normal file
13
plugins/reporting-governance/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
112
plugins/reporting-governance/src/core/decision-runner.mjs
Normal file
112
plugins/reporting-governance/src/core/decision-runner.mjs
Normal 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 })
|
||||||
|
};
|
||||||
|
}
|
||||||
2
plugins/reporting-governance/src/core/index.mjs
Normal file
2
plugins/reporting-governance/src/core/index.mjs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { evaluatePolicyPack, evaluatePolicies } from './policy-evaluator.mjs';
|
||||||
|
export { planDecisionExecution } from './decision-runner.mjs';
|
||||||
251
plugins/reporting-governance/src/core/policy-evaluator.mjs
Normal file
251
plugins/reporting-governance/src/core/policy-evaluator.mjs
Normal 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
|
||||||
|
};
|
||||||
32
plugins/reporting-governance/src/index.mjs
Normal file
32
plugins/reporting-governance/src/index.mjs
Normal 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';
|
||||||
81
plugins/reporting-governance/test/decision-runner.test.mjs
Normal file
81
plugins/reporting-governance/test/decision-runner.test.mjs
Normal 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');
|
||||||
|
});
|
||||||
143
plugins/reporting-governance/test/policy-evaluator.test.mjs
Normal file
143
plugins/reporting-governance/test/policy-evaluator.test.mjs
Normal 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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user