From c2a775b62c44938cbb0fa1fa99ed57895101a38e Mon Sep 17 00:00:00 2001 From: Eve Date: Thu, 7 May 2026 23:38:46 +0800 Subject: [PATCH] feat: add governance evaluator and decision runner skeleton --- plugins/reporting-governance/README.md | 127 +++++++++ plugins/reporting-governance/package.json | 13 + .../src/core/decision-runner.mjs | 112 ++++++++ .../reporting-governance/src/core/index.mjs | 2 + .../src/core/policy-evaluator.mjs | 251 ++++++++++++++++++ plugins/reporting-governance/src/index.mjs | 32 +++ .../test/decision-runner.test.mjs | 81 ++++++ .../test/policy-evaluator.test.mjs | 143 ++++++++++ 8 files changed, 761 insertions(+) create mode 100644 plugins/reporting-governance/README.md create mode 100644 plugins/reporting-governance/package.json create mode 100644 plugins/reporting-governance/src/core/decision-runner.mjs create mode 100644 plugins/reporting-governance/src/core/index.mjs create mode 100644 plugins/reporting-governance/src/core/policy-evaluator.mjs create mode 100644 plugins/reporting-governance/src/index.mjs create mode 100644 plugins/reporting-governance/test/decision-runner.test.mjs create mode 100644 plugins/reporting-governance/test/policy-evaluator.test.mjs diff --git a/plugins/reporting-governance/README.md b/plugins/reporting-governance/README.md new file mode 100644 index 0000000..a11085b --- /dev/null +++ b/plugins/reporting-governance/README.md @@ -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. diff --git a/plugins/reporting-governance/package.json b/plugins/reporting-governance/package.json new file mode 100644 index 0000000..7cbc56c --- /dev/null +++ b/plugins/reporting-governance/package.json @@ -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" + } +} diff --git a/plugins/reporting-governance/src/core/decision-runner.mjs b/plugins/reporting-governance/src/core/decision-runner.mjs new file mode 100644 index 0000000..a8ba370 --- /dev/null +++ b/plugins/reporting-governance/src/core/decision-runner.mjs @@ -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 }) + }; +} diff --git a/plugins/reporting-governance/src/core/index.mjs b/plugins/reporting-governance/src/core/index.mjs new file mode 100644 index 0000000..e11c1eb --- /dev/null +++ b/plugins/reporting-governance/src/core/index.mjs @@ -0,0 +1,2 @@ +export { evaluatePolicyPack, evaluatePolicies } from './policy-evaluator.mjs'; +export { planDecisionExecution } from './decision-runner.mjs'; diff --git a/plugins/reporting-governance/src/core/policy-evaluator.mjs b/plugins/reporting-governance/src/core/policy-evaluator.mjs new file mode 100644 index 0000000..478b9d4 --- /dev/null +++ b/plugins/reporting-governance/src/core/policy-evaluator.mjs @@ -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 +}; diff --git a/plugins/reporting-governance/src/index.mjs b/plugins/reporting-governance/src/index.mjs new file mode 100644 index 0000000..0b22c93 --- /dev/null +++ b/plugins/reporting-governance/src/index.mjs @@ -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'; diff --git a/plugins/reporting-governance/test/decision-runner.test.mjs b/plugins/reporting-governance/test/decision-runner.test.mjs new file mode 100644 index 0000000..7ca77be --- /dev/null +++ b/plugins/reporting-governance/test/decision-runner.test.mjs @@ -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'); +}); diff --git a/plugins/reporting-governance/test/policy-evaluator.test.mjs b/plugins/reporting-governance/test/policy-evaluator.test.mjs new file mode 100644 index 0000000..0e38cb9 --- /dev/null +++ b/plugins/reporting-governance/test/policy-evaluator.test.mjs @@ -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'); +});