diff --git a/plugins/reporting-governance/src/core/compatibility-preflight.mjs b/plugins/reporting-governance/src/core/compatibility-preflight.mjs index 5606e79..4e0c04f 100644 --- a/plugins/reporting-governance/src/core/compatibility-preflight.mjs +++ b/plugins/reporting-governance/src/core/compatibility-preflight.mjs @@ -38,6 +38,13 @@ export function runCompatibilityPreflight({ capabilityDescriptor = {}, profile = 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 hasCompatibilityEnvelope = Boolean( + requestedPluginVersion || + profile?.metadata?.id || + safeArray(profile?.capability_expectations?.required).length > 0 || + safeArray(profile?.capability_expectations?.preferred).length > 0 || + collectExpectedActions(profile).length > 0 + ); const errors = []; const warnings = []; @@ -46,15 +53,17 @@ export function runCompatibilityPreflight({ capabilityDescriptor = {}, profile = const schemaChecks = Object.entries(CANONICAL_SCHEMA_PATHS).map(([key, expectedPath]) => { const actualPath = capabilityDescriptor?.compatibility?.[key] ?? null; const ok = actualPath === expectedPath; - if (!ok) { + if (!ok && hasCompatibilityEnvelope) { 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; + const versionOk = requestedPluginVersion ? compatiblePluginVersions.includes(requestedPluginVersion) : true; if (!versionOk) { errors.push(`plugin version ${requestedPluginVersion ?? 'missing'} is not declared compatible by runtime ${runtimeId}`); + } else if (!requestedPluginVersion) { + notes.push('compatibility preflight skipped plugin version pin because caller did not provide profile.spec.package.pluginVersion or packageVersion'); } const requiredExpectations = safeArray(profile?.capability_expectations?.required).map((expectation) => diff --git a/plugins/reporting-governance/src/core/execute-governance-contract.mjs b/plugins/reporting-governance/src/core/execute-governance-contract.mjs index 115faf0..7af7c56 100644 --- a/plugins/reporting-governance/src/core/execute-governance-contract.mjs +++ b/plugins/reporting-governance/src/core/execute-governance-contract.mjs @@ -2,7 +2,25 @@ import { evaluatePolicies } from './policy-evaluator.mjs'; import { runCompatibilityPreflight } from './compatibility-preflight.mjs'; import { planDecisionExecution } from './decision-runner.mjs'; +function createBlockedReceipt({ evaluation, preflight }) { + return { + policy_id: evaluation.decision.policy_id, + decision: evaluation.decision.decision, + status: 'blocked', + delivery_state: 'blocked', + enforcement_intent: [], + blocked_actions: [], + operator_notice_required: Boolean(evaluation.decision.operator_notice?.required), + notes: [ + 'Compatibility preflight failed closed; execution planning was blocked before any runnable contract could be produced.', + ...preflight.errors, + ], + }; +} + function createBlockedContract({ capabilityDescriptor = {}, evaluation, preflight }) { + const receipt = createBlockedReceipt({ evaluation, preflight }); + return { evaluation, preflight, @@ -16,19 +34,7 @@ function createBlockedContract({ capabilityDescriptor = {}, evaluation, prefligh runtime_adapter_required: [], package_core_actions: [] }, - receipt: { - policy_id: evaluation.decision.policy_id, - decision: evaluation.decision.decision, - status: 'failed', - delivery_state: 'blocked', - enforcement_intent: [], - blocked_actions: [], - operator_notice_required: Boolean(evaluation.decision.operator_notice?.required), - notes: [ - 'Compatibility preflight failed closed; execution planning was blocked before any runnable contract could be produced.', - ...preflight.errors - ] - } + receipt }, contract: { runtime: capabilityDescriptor?.metadata?.id ?? capabilityDescriptor?.runtime?.name ?? 'unknown-runtime', @@ -37,8 +43,8 @@ function createBlockedContract({ capabilityDescriptor = {}, evaluation, prefligh adapter_actions: [], package_actions: [], blocked_actions: [], - delivery_state: 'blocked', - receipt_status: 'failed' + delivery_state: receipt.delivery_state, + receipt_status: receipt.status, } }; } diff --git a/plugins/reporting-governance/test/governance-contract.integration.test.mjs b/plugins/reporting-governance/test/governance-contract.integration.test.mjs index 72ad000..5623e62 100644 --- a/plugins/reporting-governance/test/governance-contract.integration.test.mjs +++ b/plugins/reporting-governance/test/governance-contract.integration.test.mjs @@ -109,6 +109,28 @@ test('capability descriptor -> policy evaluation -> decision planning yields ada assert.equal(result.contract.runtime, 'openclaw-watchdog-reference'); }); +test('executeGovernanceContract stays compatible for legacy callers without profile/packageVersion', () => { + const result = executeGovernanceContract({ + event: { + type: 'silence_timeout', + payload: { checkpoint_overdue: true } + }, + capabilityDescriptor, + policyPacks: [noSilencePack], + context: { + signals: ['checkpoint_overdue'] + } + }); + + assert.equal(result.evaluation.decision.decision, 'force_checkpoint'); + assert.equal(result.preflight.status, 'pass'); + assert.equal(result.preflight.requested_plugin_version, null); + assert.ok(result.preflight.notes.some((note) => note.includes('skipped plugin version pin'))); + assert.equal(result.planning.receipt.status, 'planned'); + assert.deepEqual(result.contract.adapter_actions, ['notify_operator']); + assert.deepEqual(result.contract.package_actions, ['emit_event']); +}); + test('contract truthfully degrades when notify path can queue but cannot directly dispatch', () => { const limitedDescriptor = { ...capabilityDescriptor, @@ -199,6 +221,46 @@ test('contract fails closed when capability descriptor cannot satisfy mandatory assert.deepEqual(result.contract.package_actions, []); assert.deepEqual(result.contract.blocked_actions, []); assert.equal(result.contract.delivery_state, 'blocked'); - assert.equal(result.contract.receipt_status, 'failed'); + assert.equal(result.contract.receipt_status, 'blocked'); + assert.equal(result.planning.receipt.status, 'blocked'); + assert.equal(result.planning.receipt.delivery_state, 'blocked'); + assert.deepEqual(result.planning.receipt.enforcement_intent, []); + assert.deepEqual(result.planning.receipt.blocked_actions, []); assert.ok(result.planning.receipt.notes.some((note) => note.includes('failed closed'))); }); + +test('schema/version mismatch blocks contract before any runnable plan is produced', () => { + 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 = executeGovernanceContract({ + event: { + type: 'silence_timeout', + payload: { checkpoint_overdue: true } + }, + capabilityDescriptor: brokenDescriptor, + policyPacks: [noSilencePack], + context: { + signals: ['checkpoint_overdue'] + }, + profile: strictProfile, + packageVersion: '0.1.0-mainline' + }); + + assert.equal(result.preflight.status, 'fail_closed'); + assert.equal(result.contract.delivery_state, 'blocked'); + assert.equal(result.contract.receipt_status, 'blocked'); + assert.deepEqual(result.planning.enforcement_intent.planned_actions, []); + assert.deepEqual(result.planning.enforcement_intent.runtime_adapter_required, []); + assert.deepEqual(result.planning.enforcement_intent.package_core_actions, []); + assert.deepEqual(result.planning.receipt.enforcement_intent, []); + assert.deepEqual(result.planning.receipt.blocked_actions, []); + assert.ok(result.planning.receipt.notes.some((note) => note.includes('schema mismatch: decision_schema'))); + assert.ok(result.planning.receipt.notes.some((note) => note.includes('plugin version 0.1.0-mainline is not declared compatible'))); +});