fix: preserve governance contract compatibility
This commit is contained in:
@@ -38,6 +38,13 @@ export function runCompatibilityPreflight({ capabilityDescriptor = {}, profile =
|
|||||||
const runtimeId = capabilityDescriptor?.metadata?.id ?? capabilityDescriptor?.runtime?.name ?? 'unknown-runtime';
|
const runtimeId = capabilityDescriptor?.metadata?.id ?? capabilityDescriptor?.runtime?.name ?? 'unknown-runtime';
|
||||||
const requestedPluginVersion = profile?.spec?.package?.pluginVersion ?? packageVersion ?? null;
|
const requestedPluginVersion = profile?.spec?.package?.pluginVersion ?? packageVersion ?? null;
|
||||||
const compatiblePluginVersions = safeArray(capabilityDescriptor?.compatibility?.plugin_spec_versions);
|
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 errors = [];
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
@@ -46,15 +53,17 @@ export function runCompatibilityPreflight({ capabilityDescriptor = {}, profile =
|
|||||||
const schemaChecks = Object.entries(CANONICAL_SCHEMA_PATHS).map(([key, expectedPath]) => {
|
const schemaChecks = Object.entries(CANONICAL_SCHEMA_PATHS).map(([key, expectedPath]) => {
|
||||||
const actualPath = capabilityDescriptor?.compatibility?.[key] ?? null;
|
const actualPath = capabilityDescriptor?.compatibility?.[key] ?? null;
|
||||||
const ok = actualPath === expectedPath;
|
const ok = actualPath === expectedPath;
|
||||||
if (!ok) {
|
if (!ok && hasCompatibilityEnvelope) {
|
||||||
errors.push(`schema mismatch: ${key} expected ${expectedPath} but got ${actualPath ?? 'missing'}`);
|
errors.push(`schema mismatch: ${key} expected ${expectedPath} but got ${actualPath ?? 'missing'}`);
|
||||||
}
|
}
|
||||||
return { key, expected: expectedPath, actual: actualPath, ok };
|
return { key, expected: expectedPath, actual: actualPath, ok };
|
||||||
});
|
});
|
||||||
|
|
||||||
const versionOk = requestedPluginVersion ? compatiblePluginVersions.includes(requestedPluginVersion) : false;
|
const versionOk = requestedPluginVersion ? compatiblePluginVersions.includes(requestedPluginVersion) : true;
|
||||||
if (!versionOk) {
|
if (!versionOk) {
|
||||||
errors.push(`plugin version ${requestedPluginVersion ?? 'missing'} is not declared compatible by runtime ${runtimeId}`);
|
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) =>
|
const requiredExpectations = safeArray(profile?.capability_expectations?.required).map((expectation) =>
|
||||||
|
|||||||
@@ -2,7 +2,25 @@ import { evaluatePolicies } from './policy-evaluator.mjs';
|
|||||||
import { runCompatibilityPreflight } from './compatibility-preflight.mjs';
|
import { runCompatibilityPreflight } from './compatibility-preflight.mjs';
|
||||||
import { planDecisionExecution } from './decision-runner.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 }) {
|
function createBlockedContract({ capabilityDescriptor = {}, evaluation, preflight }) {
|
||||||
|
const receipt = createBlockedReceipt({ evaluation, preflight });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
evaluation,
|
evaluation,
|
||||||
preflight,
|
preflight,
|
||||||
@@ -16,19 +34,7 @@ function createBlockedContract({ capabilityDescriptor = {}, evaluation, prefligh
|
|||||||
runtime_adapter_required: [],
|
runtime_adapter_required: [],
|
||||||
package_core_actions: []
|
package_core_actions: []
|
||||||
},
|
},
|
||||||
receipt: {
|
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
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
contract: {
|
contract: {
|
||||||
runtime: capabilityDescriptor?.metadata?.id ?? capabilityDescriptor?.runtime?.name ?? 'unknown-runtime',
|
runtime: capabilityDescriptor?.metadata?.id ?? capabilityDescriptor?.runtime?.name ?? 'unknown-runtime',
|
||||||
@@ -37,8 +43,8 @@ function createBlockedContract({ capabilityDescriptor = {}, evaluation, prefligh
|
|||||||
adapter_actions: [],
|
adapter_actions: [],
|
||||||
package_actions: [],
|
package_actions: [],
|
||||||
blocked_actions: [],
|
blocked_actions: [],
|
||||||
delivery_state: 'blocked',
|
delivery_state: receipt.delivery_state,
|
||||||
receipt_status: 'failed'
|
receipt_status: receipt.status,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,28 @@ 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('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', () => {
|
test('contract truthfully degrades when notify path can queue but cannot directly dispatch', () => {
|
||||||
const limitedDescriptor = {
|
const limitedDescriptor = {
|
||||||
...capabilityDescriptor,
|
...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.package_actions, []);
|
||||||
assert.deepEqual(result.contract.blocked_actions, []);
|
assert.deepEqual(result.contract.blocked_actions, []);
|
||||||
assert.equal(result.contract.delivery_state, 'blocked');
|
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')));
|
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')));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user