diff --git a/plugins/reporting-governance/package.json b/plugins/reporting-governance/package.json index 43f0940..2b1747b 100644 --- a/plugins/reporting-governance/package.json +++ b/plugins/reporting-governance/package.json @@ -14,6 +14,6 @@ "./adapters/orchestrator": "./src/adapters/orchestrator.mjs" }, "scripts": { - "test": "node --test test/package-structure.test.mjs test/policy-evaluator.test.mjs test/decision-runner.test.mjs test/governance-contract.integration.test.mjs test/watchdog-chain.integration.test.mjs test/exports-boundary.integration.test.mjs" + "test": "node --test test/package-structure.test.mjs test/policy-evaluator.test.mjs test/compatibility-preflight.test.mjs test/decision-runner.test.mjs test/governance-contract.integration.test.mjs test/watchdog-chain.integration.test.mjs test/exports-boundary.integration.test.mjs" } } diff --git a/plugins/reporting-governance/src/core/compatibility-preflight.mjs b/plugins/reporting-governance/src/core/compatibility-preflight.mjs new file mode 100644 index 0000000..df7a48c --- /dev/null +++ b/plugins/reporting-governance/src/core/compatibility-preflight.mjs @@ -0,0 +1,177 @@ +const CANONICAL_SCHEMA_PATHS = Object.freeze({ + event_schema: 'schemas/reporting-governance/event-envelope.schema.json', + evidence_schema: 'schemas/reporting-governance/evidence.schema.json', + decision_schema: 'schemas/reporting-governance/decision.schema.json', + capabilities_schema: 'schemas/reporting-governance/capability-descriptor.schema.json' +}); + +const EXPECTATION_CAPABILITY_PATHS = Object.freeze({ + emit_canonical_events: ['capabilities.normalization.canonical_events'], + evaluate_watchdog_overdue: ['capabilities.watchdog.watchdog_evaluation'], + create_queue_items: ['capabilities.notification_path.queue_items'], + create_spool_handoff: ['capabilities.notification_path.spool_handoff'], + write_bridge_receipts: ['capabilities.notification_path.receipts'], + direct_sender_binding: ['capabilities.notification_path.sender_binding'], + final_delivery_ack: ['capabilities.notification_path.final_delivery_proof'], + inline_dispatch_blocking: ['capabilities.enforcement.block_transition'] +}); + +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 isSupported(node) { + return Boolean(node?.supported) && (node?.level === 'partial' || node?.level === 'full'); +} + +function assessCapabilityExpectation(capabilityDescriptor, expectation) { + const candidatePaths = EXPECTATION_CAPABILITY_PATHS[expectation] ?? []; + const matchedPath = candidatePaths.find((candidatePath) => isSupported(getByPath(capabilityDescriptor, candidatePath))); + return { + expectation, + supported: Boolean(matchedPath), + matched_path: matchedPath ?? candidatePaths[0] ?? null + }; +} + +function collectExpectedActions(profile = {}) { + const actions = []; + const overdueAction = profile?.spec?.policies?.overrides?.checkpoints?.overdueAction; + if (overdueAction) { + actions.push(overdueAction); + } + if (profile?.spec?.notifications?.operatorVisibleRecoveryRequired) { + actions.push('notify_operator'); + } + return [...new Set(actions)]; +} + +function evaluateActionSupport(capabilityDescriptor = {}, action) { + switch (action) { + case 'force_checkpoint': + return { + action, + supported: isSupported(getByPath(capabilityDescriptor, 'capabilities.enforcement.force_checkpoint')), + required_paths: ['capabilities.enforcement.force_checkpoint'], + mode: 'fail_closed' + }; + case 'notify_operator': { + const queueSupported = isSupported(getByPath(capabilityDescriptor, 'capabilities.notification_path.queue_items')); + const senderSupported = isSupported(getByPath(capabilityDescriptor, 'capabilities.notification_path.sender_binding')) + || isSupported(getByPath(capabilityDescriptor, 'capabilities.notification_path.direct_send')); + return { + action, + supported: queueSupported, + degraded: queueSupported && !senderSupported, + required_paths: ['capabilities.notification_path.queue_items'], + advisory_paths: ['capabilities.notification_path.sender_binding', 'capabilities.notification_path.direct_send'], + mode: queueSupported && !senderSupported ? 'honest_degrade' : 'pass' + }; + } + case 'dispatch_message': { + const directSendSupported = isSupported(getByPath(capabilityDescriptor, 'capabilities.notification_path.direct_send')); + const senderSupported = isSupported(getByPath(capabilityDescriptor, 'capabilities.notification_path.sender_binding')); + return { + action, + supported: directSendSupported || senderSupported, + required_paths: ['capabilities.notification_path.direct_send', 'capabilities.notification_path.sender_binding'], + mode: directSendSupported || senderSupported ? 'pass' : 'fail_closed' + }; + } + default: + return { + action, + supported: true, + required_paths: [], + mode: 'pass' + }; + } +} + +export function runCompatibilityPreflight({ capabilityDescriptor = {}, profile = {}, packageVersion } = {}) { + const runtimeId = capabilityDescriptor?.metadata?.id ?? capabilityDescriptor?.runtime?.name ?? 'unknown-runtime'; + const requestedPluginVersion = profile?.spec?.package?.pluginVersion ?? packageVersion ?? null; + const compatiblePluginVersions = safeArray(capabilityDescriptor?.compatibility?.plugin_spec_versions); + + const errors = []; + const warnings = []; + const notes = []; + + const schemaChecks = Object.entries(CANONICAL_SCHEMA_PATHS).map(([key, expectedPath]) => { + const actualPath = capabilityDescriptor?.compatibility?.[key] ?? null; + const ok = actualPath === expectedPath; + if (!ok) { + errors.push(`schema mismatch: ${key} expected ${expectedPath} but got ${actualPath ?? 'missing'}`); + } + return { key, expected: expectedPath, actual: actualPath, ok }; + }); + + const versionOk = requestedPluginVersion ? compatiblePluginVersions.includes(requestedPluginVersion) : false; + if (!versionOk) { + errors.push(`plugin version ${requestedPluginVersion ?? 'missing'} is not declared compatible by runtime ${runtimeId}`); + } + + const requiredExpectations = safeArray(profile?.capability_expectations?.required).map((expectation) => + assessCapabilityExpectation(capabilityDescriptor, expectation) + ); + const preferredExpectations = safeArray(profile?.capability_expectations?.preferred).map((expectation) => + assessCapabilityExpectation(capabilityDescriptor, expectation) + ); + + for (const result of requiredExpectations) { + if (!result.supported) { + errors.push(`required capability expectation not satisfied: ${result.expectation}`); + } + } + + for (const result of preferredExpectations) { + if (!result.supported) { + warnings.push(`preferred capability expectation not satisfied: ${result.expectation}`); + } + } + + const actionChecks = collectExpectedActions(profile).map((action) => evaluateActionSupport(capabilityDescriptor, action)); + for (const result of actionChecks) { + if (!result.supported) { + errors.push(`required action is not supportable by runtime: ${result.action}`); + } else if (result.mode === 'honest_degrade') { + warnings.push(`action ${result.action} only supports deferred truth path; treat final delivery as pending_external_send until acked`); + notes.push(`degrade:${result.action}=pending_external_send`); + } + } + + const status = errors.length > 0 ? 'fail_closed' : warnings.length > 0 ? 'degraded' : 'pass'; + + return { + status, + runtime: runtimeId, + requested_profile: profile?.metadata?.id ?? null, + requested_plugin_version: requestedPluginVersion, + compatibility: { + version_ok: versionOk, + compatible_plugin_versions: compatiblePluginVersions, + schema_checks: schemaChecks, + required_expectations: requiredExpectations, + preferred_expectations: preferredExpectations, + action_checks: actionChecks + }, + errors, + warnings, + notes + }; +} + +export const __testables = { + CANONICAL_SCHEMA_PATHS, + EXPECTATION_CAPABILITY_PATHS, + collectExpectedActions, + evaluateActionSupport, + assessCapabilityExpectation +}; + +export default runCompatibilityPreflight; diff --git a/plugins/reporting-governance/src/core/decision-runner.mjs b/plugins/reporting-governance/src/core/decision-runner.mjs index a8ba370..40e9b7f 100644 --- a/plugins/reporting-governance/src/core/decision-runner.mjs +++ b/plugins/reporting-governance/src/core/decision-runner.mjs @@ -42,13 +42,53 @@ function mapActionToCapability(action) { case 'block_transition': return 'block_transition'; case 'notify_operator': + return 'notify_operator'; case 'dispatch_message': - return 'force_checkpoint'; + return 'dispatch_message'; default: return null; } } +function actionSupport(capabilityDescriptor = {}, actionName) { + switch (actionName) { + case 'notify_operator': { + const queue = capabilityDescriptor?.capabilities?.notification_path?.queue_items ?? { supported: false, level: 'none' }; + const sender = capabilityDescriptor?.capabilities?.notification_path?.sender_binding ?? { supported: false, level: 'none' }; + const directSend = capabilityDescriptor?.capabilities?.notification_path?.direct_send ?? { supported: false, level: 'none' }; + const queueSupported = queue.supported && isTruthySupportLevel(queue.level); + const senderSupported = (sender.supported && isTruthySupportLevel(sender.level)) || (directSend.supported && isTruthySupportLevel(directSend.level)); + return { + supported: queueSupported, + level: queueSupported && senderSupported ? 'full' : queueSupported ? 'partial' : 'none', + capability: 'notify_operator', + mode: queueSupported && !senderSupported ? 'runtime_adapter_dispatch_deferred' : 'runtime_adapter_dispatch' + }; + } + case 'dispatch_message': { + const directSend = capabilityDescriptor?.capabilities?.notification_path?.direct_send ?? { supported: false, level: 'none' }; + const sender = capabilityDescriptor?.capabilities?.notification_path?.sender_binding ?? { supported: false, level: 'none' }; + const supported = (directSend.supported && isTruthySupportLevel(directSend.level)) || (sender.supported && isTruthySupportLevel(sender.level)); + return { + supported, + level: supported ? (directSend.level === 'full' || sender.level === 'full' ? 'full' : 'partial') : 'none', + capability: 'dispatch_message', + mode: 'runtime_adapter_dispatch' + }; + } + default: { + const capabilityName = mapActionToCapability(actionName); + const capability = capabilityName ? enforcementSupport(capabilityDescriptor, capabilityName) : { supported: true, level: 'full' }; + return { + supported: capabilityName ? capability.supported && isTruthySupportLevel(capability.level) : true, + level: capability.level ?? 'full', + capability: capabilityName, + mode: 'package_core_signal' + }; + } + } +} + export function planDecisionExecution({ decision, capabilityDescriptor = {} }) { if (!decision) { throw new Error('decision is required'); @@ -61,26 +101,22 @@ export function planDecisionExecution({ decision, capabilityDescriptor = {} }) { 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; + const support = actionSupport(capabilityDescriptor, requiredAction.action); - if (supported) { + if (support.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' + execution_mode: support.mode, + capability: support.capability, + support_level: support.level }); continue; } blockedActions.push({ ...requiredAction, - capability: capabilityName, - support_level: capability.level ?? 'none', + capability: support.capability, + support_level: support.level ?? 'none', reason: 'runtime capability descriptor does not support this enforcement path' }); } @@ -93,6 +129,10 @@ export function planDecisionExecution({ decision, capabilityDescriptor = {} }) { delivery_state = 'blocked'; } + if (actionPlans.some((action) => action.execution_mode === 'runtime_adapter_dispatch_deferred')) { + notes.push('Some operator notice paths are only queue/bridge capable; final delivery must remain pending_external_send until acked.'); + } + if (blockedActions.some((action) => action.mandatory)) { notes.push('One or more mandatory actions could not be planned truthfully from the advertised runtime capabilities.'); } @@ -104,7 +144,9 @@ export function planDecisionExecution({ decision, capabilityDescriptor = {} }) { 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), + runtime_adapter_required: actionPlans + .filter((action) => action.execution_mode === 'runtime_adapter_dispatch' || action.execution_mode === 'runtime_adapter_dispatch_deferred') + .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 index ab6c084..e341a6f 100644 --- a/plugins/reporting-governance/src/core/index.mjs +++ b/plugins/reporting-governance/src/core/index.mjs @@ -1,3 +1,4 @@ export { evaluatePolicyPack, evaluatePolicies } from './policy-evaluator.mjs'; export { planDecisionExecution } from './decision-runner.mjs'; export { executeGovernanceContract } from './execute-governance-contract.mjs'; +export { runCompatibilityPreflight } from './compatibility-preflight.mjs'; diff --git a/plugins/reporting-governance/src/index.mjs b/plugins/reporting-governance/src/index.mjs index e6e4bc8..4bfa7c5 100644 --- a/plugins/reporting-governance/src/index.mjs +++ b/plugins/reporting-governance/src/index.mjs @@ -29,7 +29,13 @@ export const packageBoundaries = { ] }; -export { evaluatePolicyPack, evaluatePolicies, planDecisionExecution, executeGovernanceContract } from './core/index.mjs'; +export { + evaluatePolicyPack, + evaluatePolicies, + planDecisionExecution, + executeGovernanceContract, + runCompatibilityPreflight, +} from './core/index.mjs'; export { createRuntimeBinding, runWatchdogAdapter, diff --git a/plugins/reporting-governance/test/compatibility-preflight.test.mjs b/plugins/reporting-governance/test/compatibility-preflight.test.mjs new file mode 100644 index 0000000..1573734 --- /dev/null +++ b/plugins/reporting-governance/test/compatibility-preflight.test.mjs @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { runCompatibilityPreflight } from '../src/core/compatibility-preflight.mjs'; +import capabilityDescriptor from '../capabilities/openclaw-watchdog-reference.json' with { type: 'json' }; + +const strictProfile = { + metadata: { id: 'strict-manager-mode' }, + spec: { + package: { pluginVersion: '0.1.0-mainline' }, + policies: { + overrides: { + checkpoints: { overdueAction: 'force_checkpoint' } + } + }, + notifications: { + operatorVisibleRecoveryRequired: true + } + }, + capability_expectations: { + required: [ + 'emit_canonical_events', + 'evaluate_watchdog_overdue', + 'create_queue_items', + 'create_spool_handoff', + 'write_bridge_receipts' + ], + preferred: ['direct_sender_binding', 'final_delivery_ack', 'inline_dispatch_blocking'] + } +}; + +test('runCompatibilityPreflight passes strict profile against reference descriptor', () => { + const result = runCompatibilityPreflight({ + capabilityDescriptor, + profile: strictProfile, + packageVersion: '0.1.0-mainline' + }); + + assert.equal(result.status, 'pass'); + assert.equal(result.compatibility.version_ok, true); + assert.ok(result.compatibility.schema_checks.every((entry) => entry.ok)); + assert.ok(result.compatibility.required_expectations.every((entry) => entry.supported)); + assert.equal(result.errors.length, 0); +}); + +test('runCompatibilityPreflight fails closed on schema/version mismatch', () => { + const brokenDescriptor = { + ...capabilityDescriptor, + compatibility: { + ...capabilityDescriptor.compatibility, + plugin_spec_versions: ['9.9.9'], + decision_schema: 'schemas/reporting-governance/not-the-canonical-decision.schema.json' + } + }; + + const result = runCompatibilityPreflight({ + capabilityDescriptor: brokenDescriptor, + profile: strictProfile, + packageVersion: '0.1.0-mainline' + }); + + assert.equal(result.status, 'fail_closed'); + assert.equal(result.compatibility.version_ok, false); + assert.match(result.errors.join('\n'), /schema mismatch: decision_schema/); + assert.match(result.errors.join('\n'), /plugin version 0.1.0-mainline is not declared compatible/); +}); + +test('runCompatibilityPreflight degrades honestly when notify path can queue but cannot prove final send', () => { + const degradedDescriptor = { + ...capabilityDescriptor, + capabilities: { + ...capabilityDescriptor.capabilities, + notification_path: { + ...capabilityDescriptor.capabilities.notification_path, + sender_binding: { supported: false, level: 'none' }, + direct_send: { supported: false, level: 'none' } + } + } + }; + + const result = runCompatibilityPreflight({ + capabilityDescriptor: degradedDescriptor, + profile: strictProfile, + packageVersion: '0.1.0-mainline' + }); + + assert.equal(result.status, 'degraded'); + assert.equal(result.errors.length, 0); + assert.ok(result.warnings.some((warning) => warning.includes('notify_operator'))); + assert.ok(result.notes.includes('degrade:notify_operator=pending_external_send')); +}); + +test('runCompatibilityPreflight fails closed when profile requires unsupported force_checkpoint', () => { + const limitedDescriptor = { + ...capabilityDescriptor, + capabilities: { + ...capabilityDescriptor.capabilities, + enforcement: { + ...capabilityDescriptor.capabilities.enforcement, + force_checkpoint: { supported: false, level: 'none' } + } + } + }; + + const result = runCompatibilityPreflight({ + capabilityDescriptor: limitedDescriptor, + profile: strictProfile, + packageVersion: '0.1.0-mainline' + }); + + assert.equal(result.status, 'fail_closed'); + assert.match(result.errors.join('\n'), /required action is not supportable by runtime: force_checkpoint/); +}); diff --git a/plugins/reporting-governance/test/decision-runner.test.mjs b/plugins/reporting-governance/test/decision-runner.test.mjs index 7ca77be..6b83585 100644 --- a/plugins/reporting-governance/test/decision-runner.test.mjs +++ b/plugins/reporting-governance/test/decision-runner.test.mjs @@ -15,6 +15,10 @@ const baseCapabilityDescriptor = { escalate: { supported: true, level: 'full' } }, notification_path: { + queue_items: { supported: true, level: 'full' }, + spool_handoff: { supported: true, level: 'full' }, + sender_binding: { supported: true, level: 'full' }, + direct_send: { supported: true, level: 'partial' }, truth_model: { delivery_states: ['prepared', 'queued', 'dispatched', 'pending_external_send', 'acked', 'blocked'], ack_requires_proven_send: true, @@ -53,6 +57,39 @@ test('planDecisionExecution produces runtime-adapter dispatch intent for force_c assert.ok(result.receipt.notes.some((note) => note.includes('runtime-adapter responsibility'))); }); +test('planDecisionExecution keeps notify_operator separate from dispatch_message capability semantics', () => { + const notifyOnlyDescriptor = { + capabilities: { + ...baseCapabilityDescriptor.capabilities, + notification_path: { + ...baseCapabilityDescriptor.capabilities.notification_path, + sender_binding: { supported: false, level: 'none' }, + direct_send: { supported: false, level: 'none' } + } + } + }; + + const result = planDecisionExecution({ + decision: { + decision: 'force_checkpoint', + policy_id: 'no-silence.missed-checkpoint', + severity: 'high', + reason: 'checkpoint overdue', + required_actions: [ + { action: 'notify_operator', target: 'operator_channel', mandatory: true }, + { action: 'dispatch_message', target: 'operator_channel', mandatory: true } + ], + operator_notice: { required: true } + }, + capabilityDescriptor: notifyOnlyDescriptor + }); + + assert.deepEqual(result.enforcement_intent.runtime_adapter_required, ['notify_operator']); + assert.equal(result.enforcement_intent.planned_actions[0].execution_mode, 'runtime_adapter_dispatch_deferred'); + assert.equal(result.enforcement_intent.blocked_actions[0].action, 'dispatch_message'); + assert.ok(result.receipt.notes.some((note) => note.includes('pending_external_send'))); +}); + test('planDecisionExecution truthfully blocks unsupported package action paths', () => { const result = planDecisionExecution({ decision: { @@ -79,3 +116,22 @@ test('planDecisionExecution truthfully blocks unsupported package action paths', assert.equal(result.enforcement_intent.planned_actions.length, 0); assert.equal(result.enforcement_intent.blocked_actions[0].action, 'set_status'); }); + +test('planDecisionExecution marks block decisions as blocked when runtime truth model supports blocked state', () => { + const result = planDecisionExecution({ + decision: { + decision: 'block', + policy_id: 'no-fake-progress.invalid-transition', + severity: 'high', + reason: 'invalid dispatch attempt', + required_actions: [ + { action: 'block_transition', target: 'dispatch_gate', mandatory: true } + ], + operator_notice: { required: false } + }, + capabilityDescriptor: baseCapabilityDescriptor + }); + + assert.equal(result.receipt.delivery_state, 'blocked'); + assert.deepEqual(result.enforcement_intent.package_core_actions, ['block_transition']); +}); diff --git a/plugins/reporting-governance/test/governance-contract.integration.test.mjs b/plugins/reporting-governance/test/governance-contract.integration.test.mjs index a04eb4c..56f2aaa 100644 --- a/plugins/reporting-governance/test/governance-contract.integration.test.mjs +++ b/plugins/reporting-governance/test/governance-contract.integration.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { executeGovernanceContract } from '../src/core/execute-governance-contract.mjs'; +import { executeGovernanceContract, runCompatibilityPreflight } from '../src/core/index.mjs'; import capabilityDescriptor from '../capabilities/openclaw-watchdog-reference.json' with { type: 'json' }; const noSilencePack = { @@ -44,7 +44,39 @@ const noSilencePack = { } }; +const strictProfile = { + metadata: { id: 'strict-manager-mode' }, + spec: { + package: { pluginVersion: '0.1.0-mainline' }, + policies: { + overrides: { + checkpoints: { overdueAction: 'force_checkpoint' } + } + }, + notifications: { + operatorVisibleRecoveryRequired: true + } + }, + capability_expectations: { + required: [ + 'emit_canonical_events', + 'evaluate_watchdog_overdue', + 'create_queue_items', + 'create_spool_handoff', + 'write_bridge_receipts' + ], + preferred: ['direct_sender_binding', 'final_delivery_ack'] + } +}; + test('capability descriptor -> policy evaluation -> decision planning yields adapter-compatible contract', () => { + const preflight = runCompatibilityPreflight({ + capabilityDescriptor, + profile: strictProfile, + packageVersion: '0.1.0-mainline' + }); + assert.equal(preflight.status, 'pass'); + const result = executeGovernanceContract({ event: { type: 'silence_timeout', @@ -74,7 +106,7 @@ test('capability descriptor -> policy evaluation -> decision planning yields ada assert.equal(result.contract.runtime, 'openclaw-watchdog-reference'); }); -test('contract truthfully degrades when capability descriptor cannot satisfy mandatory action', () => { +test('contract truthfully degrades when notify path can queue but cannot directly dispatch', () => { const limitedDescriptor = { ...capabilityDescriptor, metadata: { @@ -83,13 +115,21 @@ test('contract truthfully degrades when capability descriptor cannot satisfy man }, capabilities: { ...capabilityDescriptor.capabilities, - enforcement: { - ...capabilityDescriptor.capabilities.enforcement, - force_checkpoint: { supported: false, level: 'none' } + notification_path: { + ...capabilityDescriptor.capabilities.notification_path, + sender_binding: { supported: false, level: 'none' }, + direct_send: { supported: false, level: 'none' } } } }; + const preflight = runCompatibilityPreflight({ + capabilityDescriptor: limitedDescriptor, + profile: strictProfile, + packageVersion: '0.1.0-mainline' + }); + assert.equal(preflight.status, 'degraded'); + const result = executeGovernanceContract({ event: { type: 'silence_timeout', @@ -103,7 +143,48 @@ test('contract truthfully degrades when capability descriptor cannot satisfy man }); assert.equal(result.evaluation.decision.decision, 'force_checkpoint'); - assert.deepEqual(result.contract.adapter_actions, []); - assert.deepEqual(result.contract.blocked_actions, ['notify_operator']); - assert.equal(result.contract.receipt_status, 'degraded'); + assert.deepEqual(result.contract.adapter_actions, ['notify_operator']); + assert.deepEqual(result.contract.blocked_actions, []); + assert.equal(result.contract.receipt_status, 'planned'); + assert.ok(result.planning.receipt.notes.some((note) => note.includes('pending_external_send'))); +}); + +test('contract fails closed when capability descriptor cannot satisfy mandatory force_checkpoint path', () => { + const limitedDescriptor = { + ...capabilityDescriptor, + metadata: { + ...capabilityDescriptor.metadata, + id: 'hard-limited-openclaw-watchdog-reference' + }, + capabilities: { + ...capabilityDescriptor.capabilities, + enforcement: { + ...capabilityDescriptor.capabilities.enforcement, + force_checkpoint: { supported: false, level: 'none' } + } + } + }; + + const preflight = runCompatibilityPreflight({ + capabilityDescriptor: limitedDescriptor, + profile: strictProfile, + packageVersion: '0.1.0-mainline' + }); + assert.equal(preflight.status, 'fail_closed'); + + const result = executeGovernanceContract({ + event: { + type: 'silence_timeout', + payload: { checkpoint_overdue: true } + }, + capabilityDescriptor: limitedDescriptor, + policyPacks: [noSilencePack], + context: { + signals: ['checkpoint_overdue'] + } + }); + + assert.equal(result.evaluation.decision.decision, 'force_checkpoint'); + assert.deepEqual(result.contract.adapter_actions, ['notify_operator']); + assert.equal(result.contract.receipt_status, 'planned'); }); diff --git a/plugins/reporting-governance/test/policy-evaluator.test.mjs b/plugins/reporting-governance/test/policy-evaluator.test.mjs index 0e38cb9..1a6c06c 100644 --- a/plugins/reporting-governance/test/policy-evaluator.test.mjs +++ b/plugins/reporting-governance/test/policy-evaluator.test.mjs @@ -104,6 +104,47 @@ const verifiedCompletionPack = { } }; +const structurePack = { + metadata: { id: 'mandatory-checkpoint-structure', severity_default: 'medium' }, + spec: { + evaluation_mode: 'any_rule_match', + rules: [ + { + id: 'mandatory-checkpoint-structure.block-missing-fields', + title: 'Missing checkpoint structure blocks dispatch', + intent: 'Do not allow malformed checkpoints to pass as normal progress.', + triggers: { + event_types: ['task_checkpoint_sent'], + claim_types: ['progress'] + }, + conditions: { + all: [ + { fact: 'message.has_required_checkpoint_fields', equals: false } + ] + }, + decision_output: { + decision: 'block', + severity: 'high', + reason: 'checkpoint structure missing required fields', + required_actions: [ + { action: 'block_transition', target: 'dispatch_gate', mandatory: true }, + { action: 'notify_operator', target: 'operator_channel', mandatory: true } + ], + operator_notice: { + required: true, + channel: 'telegram', + urgency: 'high', + message: 'Checkpoint blocked: missing required structure.' + } + }, + operator_message_templates: { + blocked: 'Checkpoint blocked: missing required structure.' + } + } + ] + } +}; + test('evaluatePolicyPack returns force_checkpoint for overdue silence event', () => { const result = evaluatePolicyPack({ event: { @@ -141,3 +182,80 @@ test('evaluatePolicies picks downgrade_status over allow for weak completion cla assert.equal(result.decision.suggested_status, 'pending_verification'); assert.equal(result.evaluations[0].decision.policy_id, 'verified-completion-only.insufficient-evidence'); }); + +test('evaluatePolicies gives block precedence over allow-like outcomes when checkpoint structure is invalid', () => { + const result = evaluatePolicies({ + event: { + type: 'task_checkpoint_sent', + payload: {} + }, + evidence: [ + { id: 'ev-2', quality: 'moderate', is_new: true } + ], + capabilityDescriptor, + policyPacks: [noSilencePack, structurePack], + context: { + message: { has_required_checkpoint_fields: false } + } + }); + + assert.equal(result.decision.decision, 'block'); + assert.equal(result.decision.policy_id, 'mandatory-checkpoint-structure.block-missing-fields'); +}); + +test('evaluatePolicies applies multi-pack precedence with escalate over block and force_checkpoint', () => { + const escalationPack = { + metadata: { id: 'escalation-pack', severity_default: 'critical' }, + spec: { + evaluation_mode: 'any_rule_match', + rules: [ + { + id: 'escalation-pack.missing-visible-followup', + title: 'Missing visible follow-up escalates', + intent: 'Escalate when result exists without visible forwarding.', + triggers: { event_types: ['silence_timeout'] }, + conditions: { + all: [ + { fact: 'forwarding.result_available_without_visible_followup', equals: true } + ] + }, + decision_output: { + decision: 'escalate', + severity: 'critical', + reason: 'result exists without visible forwarding', + required_actions: [ + { action: 'raise_escalation', target: 'manager_channel', mandatory: true } + ], + operator_notice: { + required: true, + channel: 'telegram', + urgency: 'high', + message: 'Escalation required: hidden result path detected.' + } + } + } + ] + } + }; + + const result = evaluatePolicies({ + event: { + type: 'silence_timeout', + payload: { + checkpoint_overdue: true, + result_available: true, + result_forwarded: false + } + }, + evidence: [{ id: 'ev-3', quality: 'moderate', is_new: true }], + capabilityDescriptor, + policyPacks: [noSilencePack, structurePack, escalationPack], + context: { + signals: ['checkpoint_overdue'], + message: { has_required_checkpoint_fields: false } + } + }); + + assert.equal(result.decision.decision, 'escalate'); + assert.equal(result.decision.policy_id, 'escalation-pack.missing-visible-followup'); +});