feat: add governance evaluator and decision runner skeleton
This commit is contained in:
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
|
||||
};
|
||||
Reference in New Issue
Block a user