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