feat: add reporting governance preflight contract
This commit is contained in:
@@ -14,6 +14,6 @@
|
|||||||
"./adapters/orchestrator": "./src/adapters/orchestrator.mjs"
|
"./adapters/orchestrator": "./src/adapters/orchestrator.mjs"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -42,13 +42,53 @@ function mapActionToCapability(action) {
|
|||||||
case 'block_transition':
|
case 'block_transition':
|
||||||
return 'block_transition';
|
return 'block_transition';
|
||||||
case 'notify_operator':
|
case 'notify_operator':
|
||||||
|
return 'notify_operator';
|
||||||
case 'dispatch_message':
|
case 'dispatch_message':
|
||||||
return 'force_checkpoint';
|
return 'dispatch_message';
|
||||||
default:
|
default:
|
||||||
return null;
|
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 = {} }) {
|
export function planDecisionExecution({ decision, capabilityDescriptor = {} }) {
|
||||||
if (!decision) {
|
if (!decision) {
|
||||||
throw new Error('decision is required');
|
throw new Error('decision is required');
|
||||||
@@ -61,26 +101,22 @@ export function planDecisionExecution({ decision, capabilityDescriptor = {} }) {
|
|||||||
const notes = [];
|
const notes = [];
|
||||||
|
|
||||||
for (const requiredAction of safeArray(decision.required_actions)) {
|
for (const requiredAction of safeArray(decision.required_actions)) {
|
||||||
const capabilityName = mapActionToCapability(requiredAction.action);
|
const support = actionSupport(capabilityDescriptor, requiredAction.action);
|
||||||
const capability = capabilityName ? enforcementSupport(capabilityDescriptor, capabilityName) : { supported: true, level: 'full' };
|
|
||||||
const supported = capabilityName ? capability.supported && isTruthySupportLevel(capability.level) : true;
|
|
||||||
|
|
||||||
if (supported) {
|
if (support.supported) {
|
||||||
actionPlans.push({
|
actionPlans.push({
|
||||||
...requiredAction,
|
...requiredAction,
|
||||||
execution_mode: requiredAction.action === 'notify_operator' || requiredAction.action === 'dispatch_message'
|
execution_mode: support.mode,
|
||||||
? 'runtime_adapter_dispatch'
|
capability: support.capability,
|
||||||
: 'package_core_signal',
|
support_level: support.level
|
||||||
capability: capabilityName,
|
|
||||||
support_level: capability.level ?? 'full'
|
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
blockedActions.push({
|
blockedActions.push({
|
||||||
...requiredAction,
|
...requiredAction,
|
||||||
capability: capabilityName,
|
capability: support.capability,
|
||||||
support_level: capability.level ?? 'none',
|
support_level: support.level ?? 'none',
|
||||||
reason: 'runtime capability descriptor does not support this enforcement path'
|
reason: 'runtime capability descriptor does not support this enforcement path'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -93,6 +129,10 @@ export function planDecisionExecution({ decision, capabilityDescriptor = {} }) {
|
|||||||
delivery_state = 'blocked';
|
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)) {
|
if (blockedActions.some((action) => action.mandatory)) {
|
||||||
notes.push('One or more mandatory actions could not be planned truthfully from the advertised runtime capabilities.');
|
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,
|
decision: decision.decision,
|
||||||
planned_actions: actionPlans,
|
planned_actions: actionPlans,
|
||||||
blocked_actions: blockedActions,
|
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)
|
package_core_actions: actionPlans.filter((action) => action.execution_mode === 'package_core_signal').map((action) => action.action)
|
||||||
},
|
},
|
||||||
receipt: createReceipt({ decision, actionPlans, blockedActions, delivery_state, notes })
|
receipt: createReceipt({ decision, actionPlans, blockedActions, delivery_state, notes })
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { evaluatePolicyPack, evaluatePolicies } from './policy-evaluator.mjs';
|
export { evaluatePolicyPack, evaluatePolicies } from './policy-evaluator.mjs';
|
||||||
export { planDecisionExecution } from './decision-runner.mjs';
|
export { planDecisionExecution } from './decision-runner.mjs';
|
||||||
export { executeGovernanceContract } from './execute-governance-contract.mjs';
|
export { executeGovernanceContract } from './execute-governance-contract.mjs';
|
||||||
|
export { runCompatibilityPreflight } from './compatibility-preflight.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 {
|
export {
|
||||||
createRuntimeBinding,
|
createRuntimeBinding,
|
||||||
runWatchdogAdapter,
|
runWatchdogAdapter,
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -15,6 +15,10 @@ const baseCapabilityDescriptor = {
|
|||||||
escalate: { supported: true, level: 'full' }
|
escalate: { supported: true, level: 'full' }
|
||||||
},
|
},
|
||||||
notification_path: {
|
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: {
|
truth_model: {
|
||||||
delivery_states: ['prepared', 'queued', 'dispatched', 'pending_external_send', 'acked', 'blocked'],
|
delivery_states: ['prepared', 'queued', 'dispatched', 'pending_external_send', 'acked', 'blocked'],
|
||||||
ack_requires_proven_send: true,
|
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')));
|
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', () => {
|
test('planDecisionExecution truthfully blocks unsupported package action paths', () => {
|
||||||
const result = planDecisionExecution({
|
const result = planDecisionExecution({
|
||||||
decision: {
|
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.planned_actions.length, 0);
|
||||||
assert.equal(result.enforcement_intent.blocked_actions[0].action, 'set_status');
|
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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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' };
|
import capabilityDescriptor from '../capabilities/openclaw-watchdog-reference.json' with { type: 'json' };
|
||||||
|
|
||||||
const noSilencePack = {
|
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', () => {
|
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({
|
const result = executeGovernanceContract({
|
||||||
event: {
|
event: {
|
||||||
type: 'silence_timeout',
|
type: 'silence_timeout',
|
||||||
@@ -74,7 +106,7 @@ test('capability descriptor -> policy evaluation -> decision planning yields ada
|
|||||||
assert.equal(result.contract.runtime, 'openclaw-watchdog-reference');
|
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 = {
|
const limitedDescriptor = {
|
||||||
...capabilityDescriptor,
|
...capabilityDescriptor,
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -83,13 +115,21 @@ test('contract truthfully degrades when capability descriptor cannot satisfy man
|
|||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
...capabilityDescriptor.capabilities,
|
...capabilityDescriptor.capabilities,
|
||||||
enforcement: {
|
notification_path: {
|
||||||
...capabilityDescriptor.capabilities.enforcement,
|
...capabilityDescriptor.capabilities.notification_path,
|
||||||
force_checkpoint: { supported: false, level: 'none' }
|
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({
|
const result = executeGovernanceContract({
|
||||||
event: {
|
event: {
|
||||||
type: 'silence_timeout',
|
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.equal(result.evaluation.decision.decision, 'force_checkpoint');
|
||||||
assert.deepEqual(result.contract.adapter_actions, []);
|
assert.deepEqual(result.contract.adapter_actions, ['notify_operator']);
|
||||||
assert.deepEqual(result.contract.blocked_actions, ['notify_operator']);
|
assert.deepEqual(result.contract.blocked_actions, []);
|
||||||
assert.equal(result.contract.receipt_status, 'degraded');
|
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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', () => {
|
test('evaluatePolicyPack returns force_checkpoint for overdue silence event', () => {
|
||||||
const result = evaluatePolicyPack({
|
const result = evaluatePolicyPack({
|
||||||
event: {
|
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.decision.suggested_status, 'pending_verification');
|
||||||
assert.equal(result.evaluations[0].decision.policy_id, 'verified-completion-only.insufficient-evidence');
|
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');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user