From 3223feba935e796faafbf0fd4ea1c4442311e8c6 Mon Sep 17 00:00:00 2001 From: Eve Date: Fri, 8 May 2026 10:16:29 +0800 Subject: [PATCH] feat(reporting-governance): wire profile artifacts into contract and orchestrator --- plugins/reporting-governance/README.md | 28 ++++---- .../src/adapters/orchestrator.mjs | 19 ++++- .../src/core/execute-governance-contract.mjs | 7 ++ .../src/reference/openclaw-watchdog-chain.md | 10 +-- .../src/storage/profile-artifact.mjs | 69 ++++++++++++++++--- .../governance-contract.integration.test.mjs | 54 +++++++++++++++ .../test/profile-artifact.test.mjs | 61 +++++++++++++++- .../test/watchdog-chain.integration.test.mjs | 34 +++++++++ 8 files changed, 247 insertions(+), 35 deletions(-) diff --git a/plugins/reporting-governance/README.md b/plugins/reporting-governance/README.md index 2e58c06..8dce7c8 100644 --- a/plugins/reporting-governance/README.md +++ b/plugins/reporting-governance/README.md @@ -10,6 +10,7 @@ Current purpose: - prepare the next implementation round for evaluator / decision-runner extraction - provide a minimal package-level policy evaluator and decision runner skeleton that can be verified in isolation - add one minimal package-owned deployment profile artifact / loader / binding contract slice that is executable in tests +- let profile artifacts drive one real orchestrator adapter entrypoint instead of staying test-only ## Package skeleton @@ -118,9 +119,6 @@ Practical migration rule: - new integrations should always send a profile artifact or package version pin. - old integrations may temporarily call without one, but should treat returned notes as migration debt. - -Practical migration rule: - - depend on package root exports or declared adapter subpaths only - do not couple runtime integrations to repo-private file paths - treat capability descriptors and schemas as package artifacts, but not as guaranteed JS import entrypoints unless exported later @@ -151,14 +149,17 @@ This round adds one small but real package artifact path: - package artifact: `profiles/strict-manager-mode.profile.json` - loader: `src/storage/profile-artifact.mjs#loadDeploymentProfileArtifact(...)` +- validator: `src/storage/profile-artifact.mjs#validateDeploymentProfileArtifact(...)` - binding contract: `src/storage/profile-artifact.mjs#createDeploymentBindingContract(...)` What this slice does: 1. package ships a profile artifact snapshot under package boundary 2. loader resolves that artifact from package-local path -3. binding contract translates profile-declared script/artifact roots into concrete repo/runtime paths -4. adapter runtime binding can be instantiated from that contract in tests +3. validator fail-closes minimal boundary drift on `kind`, `apiVersion`, `spec.bindings.entrypoint`, `scripts`, `artifact_roots`, and `spec.package.pluginVersion` +4. binding contract translates profile-declared script/artifact roots into concrete repo/runtime paths +5. adapter runtime binding can be instantiated from that contract in tests +6. orchestrator adapter can now bootstrap from package profile artifact input directly What this slice does **not** claim yet: @@ -169,14 +170,6 @@ What this slice does **not** claim yet: It is intentionally the smallest verifiable step that proves package profile artifacts are executable inputs rather than documentation only. -## Current reference composition - -The current reference composition is the OpenClaw watchdog chain: - -```text -watchdog -> queue -> dispatcher -> bridge -> sender binding -> acked|blocked|pending_external_send -``` - ## Minimal evaluator / decision runner now included The current package now includes a small but runnable `core/` implementation: @@ -195,6 +188,7 @@ Current package-core responsibilities: - convert a canonical decision into an execution plan, enforcement intent, and receipt skeleton - truthfully degrade unsupported enforcement paths based on the capability descriptor - provide one minimal contract path from `capability descriptor -> policy decision -> execution planning` +- surface deployment binding metadata when caller passes a validated profile artifact Still **runtime-adapter responsibility** at this stage: @@ -213,13 +207,15 @@ This slice now has one small but testable contract path: 1. capability descriptor advertises real enforcement support 2. policy evaluator emits a canonical decision from event/evidence/context 3. decision runner converts that decision into execution planning -4. the result declares: +4. validated profile artifact can supply deployment binding metadata +5. orchestrator adapter can consume profile artifact bindings and run one real runtime layer +6. the result declares: - adapter-dispatch actions required - package-core actions possible locally - blocked mandatory actions when capability support is missing - truthful delivery / receipt state -This is intentionally **planning-level end-to-end**, not full live inline interception. +This is intentionally **planning-level end-to-end plus one adapter bootstrap layer**, not full live inline interception. It proves contract alignment without pretending all runtime enforcement is already extracted. ## Not yet included @@ -232,4 +228,4 @@ This package still does **not** claim full implementation of: - complete rewrite / placeholder / review / status-downgrade adapter execution - non-watchdog full runtime governance interception -It now provides the first package-mainline evaluator / decision-runner core, a compatibility-envelope boundary, and a minimal package profile artifact/binding slice, but the remaining enforcement surface is still intentionally honest about adapter gaps. +It now provides the first package-mainline evaluator / decision-runner core, a compatibility-envelope boundary, a minimal package profile artifact/binding slice, and one profile-driven orchestrator path, but the remaining enforcement surface is still intentionally honest about adapter gaps. diff --git a/plugins/reporting-governance/src/adapters/orchestrator.mjs b/plugins/reporting-governance/src/adapters/orchestrator.mjs index 80f54bf..454c245 100644 --- a/plugins/reporting-governance/src/adapters/orchestrator.mjs +++ b/plugins/reporting-governance/src/adapters/orchestrator.mjs @@ -1,10 +1,15 @@ import path from 'node:path'; import { ensureSuccess, parseJsonStdout, runNodeScript } from './_script-runner.mjs'; import { createRuntimeBinding, resolveScriptPath } from './runtime-binding.mjs'; +import { loadDeploymentProfileArtifact, createDeploymentBindingContract } from '../storage/profile-artifact.mjs'; export function runOrchestratorAdapter({ scriptPath = null, runtimeBinding = null, + profileArtifact = null, + profileArtifactPath = null, + profileId = null, + repoRootOverride = null, state, evidenceDir, eventDir, @@ -23,8 +28,18 @@ export function runOrchestratorAdapter({ claim = false, dryRun = false, } = {}) { - const binding = runtimeBinding ?? createRuntimeBinding(); - const resolvedScriptPath = path.resolve(scriptPath ?? resolveScriptPath('orchestrator', { runtimeBinding: binding })); + const deploymentBinding = profileArtifact || profileArtifactPath || profileId + ? createDeploymentBindingContract({ + artifact: profileArtifact ?? loadDeploymentProfileArtifact({ artifactPath: profileArtifactPath, profileId }).artifact, + repoRootOverride, + }) + : null; + + const binding = runtimeBinding ?? createRuntimeBinding({ + cwd: repoRootOverride, + scripts: deploymentBinding?.scripts, + }); + const resolvedScriptPath = path.resolve(scriptPath ?? deploymentBinding?.entrypoint ?? resolveScriptPath('orchestrator', { runtimeBinding: binding })); const resolvedWatchdogScript = path.resolve(watchdogScript ?? resolveScriptPath('watchdog', { runtimeBinding: binding })); const resolvedDispatcherScript = path.resolve(dispatcherScript ?? resolveScriptPath('dispatcher', { runtimeBinding: binding })); const resolvedSupervisorScript = path.resolve(supervisorScript ?? resolveScriptPath('bridgeSupervisor', { runtimeBinding: binding })); diff --git a/plugins/reporting-governance/src/core/execute-governance-contract.mjs b/plugins/reporting-governance/src/core/execute-governance-contract.mjs index 7af7c56..f8b6392 100644 --- a/plugins/reporting-governance/src/core/execute-governance-contract.mjs +++ b/plugins/reporting-governance/src/core/execute-governance-contract.mjs @@ -1,6 +1,7 @@ import { evaluatePolicies } from './policy-evaluator.mjs'; import { runCompatibilityPreflight } from './compatibility-preflight.mjs'; import { planDecisionExecution } from './decision-runner.mjs'; +import { createDeploymentBindingContract } from '../storage/profile-artifact.mjs'; function createBlockedReceipt({ evaluation, preflight }) { return { @@ -57,6 +58,7 @@ export function executeGovernanceContract({ context = {}, profile = {}, packageVersion, + repoRootOverride, } = {}) { const evaluation = evaluatePolicies({ event, @@ -81,10 +83,15 @@ export function executeGovernanceContract({ capabilityDescriptor, }); + const deploymentBinding = profile?.spec?.bindings + ? createDeploymentBindingContract({ artifact: profile, repoRootOverride }) + : null; + return { evaluation, preflight, planning, + deploymentBinding, contract: { runtime: capabilityDescriptor?.metadata?.id ?? capabilityDescriptor?.runtime?.name ?? 'unknown-runtime', policy_id: evaluation.decision.policy_id, diff --git a/plugins/reporting-governance/src/reference/openclaw-watchdog-chain.md b/plugins/reporting-governance/src/reference/openclaw-watchdog-chain.md index b1fa2a0..1ae68b7 100644 --- a/plugins/reporting-governance/src/reference/openclaw-watchdog-chain.md +++ b/plugins/reporting-governance/src/reference/openclaw-watchdog-chain.md @@ -29,11 +29,11 @@ scripts/long_task_watchdog.mjs ## Package mapping -- `src/adapters/watchdog-adapter.mjs` → watchdog trigger + canonical event seeding -- `src/adapters/dispatcher-adapter.mjs` → queue to spool handoff -- `src/adapters/bridge-adapter.mjs` → spool consumption + receipt writeback -- `src/adapters/sender-binding-adapter.mjs` → sender contract boundary -- `src/adapters/orchestrator-adapter.mjs` → deterministic composition entrypoint +- `src/adapters/watchdog.mjs` → watchdog trigger + canonical event seeding +- `src/adapters/dispatcher.mjs` → queue to spool handoff +- `src/adapters/bridge-supervisor.mjs` → spool consumption + receipt writeback +- `src/adapters/sender-binding.mjs` → sender contract boundary +- `src/adapters/orchestrator.mjs` → deterministic composition entrypoint ## Runtime artifact classes diff --git a/plugins/reporting-governance/src/storage/profile-artifact.mjs b/plugins/reporting-governance/src/storage/profile-artifact.mjs index 811a93d..668064f 100644 --- a/plugins/reporting-governance/src/storage/profile-artifact.mjs +++ b/plugins/reporting-governance/src/storage/profile-artifact.mjs @@ -3,11 +3,58 @@ import path from 'node:path'; const packageRoot = path.resolve(import.meta.dirname, '..', '..'); const repoRoot = path.resolve(packageRoot, '..', '..'); +const EXPECTED_KIND = 'DeploymentProfileArtifact'; +const EXPECTED_API_VERSION = 'reporting-governance/v1alpha1'; function readJsonFile(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } +function assertNonEmptyString(value, label) { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${label} must be a non-empty string`); + } + return value.trim(); +} + +function assertObjectRecord(value, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object record`); + } + return value; +} + +export function validateDeploymentProfileArtifact(artifact) { + if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) { + throw new Error('deployment profile artifact must be an object'); + } + if (artifact.kind !== EXPECTED_KIND) { + throw new Error(`deployment profile artifact kind must be ${EXPECTED_KIND}`); + } + if (artifact.apiVersion !== EXPECTED_API_VERSION) { + throw new Error(`deployment profile artifact apiVersion must be ${EXPECTED_API_VERSION}`); + } + + const bindings = artifact?.spec?.bindings; + if (!bindings || typeof bindings !== 'object' || Array.isArray(bindings)) { + throw new Error('deployment profile artifact bindings are required'); + } + + assertNonEmptyString(bindings.entrypoint, 'deployment profile artifact spec.bindings.entrypoint'); + const scripts = assertObjectRecord(bindings.scripts, 'deployment profile artifact spec.bindings.scripts'); + const artifactRoots = assertObjectRecord(bindings.artifact_roots, 'deployment profile artifact spec.bindings.artifact_roots'); + assertNonEmptyString(artifact?.spec?.package?.pluginVersion, 'deployment profile artifact spec.package.pluginVersion'); + + for (const [key, relativePath] of Object.entries(scripts)) { + assertNonEmptyString(relativePath, `deployment profile artifact spec.bindings.scripts.${key}`); + } + for (const [key, relativePath] of Object.entries(artifactRoots)) { + assertNonEmptyString(relativePath, `deployment profile artifact spec.bindings.artifact_roots.${key}`); + } + + return artifact; +} + export function resolvePackageArtifactPath(...segments) { return path.resolve(packageRoot, ...segments); } @@ -17,7 +64,7 @@ export function loadDeploymentProfileArtifact({ artifactPath, profileId } = {}) artifactPath ?? resolvePackageArtifactPath('profiles', `${profileId ?? 'strict-manager-mode'}.profile.json`) ); - const artifact = readJsonFile(resolvedPath); + const artifact = validateDeploymentProfileArtifact(readJsonFile(resolvedPath)); return { artifactPath: resolvedPath, artifact, @@ -25,30 +72,30 @@ export function loadDeploymentProfileArtifact({ artifactPath, profileId } = {}) } export function createDeploymentBindingContract({ artifact, repoRootOverride } = {}) { - if (!artifact?.spec?.bindings) { - throw new Error('deployment profile artifact bindings are required'); - } - + const validatedArtifact = validateDeploymentProfileArtifact(artifact); const root = path.resolve(repoRootOverride ?? repoRoot); const scripts = Object.fromEntries( - Object.entries(artifact.spec.bindings.scripts ?? {}).map(([key, relativePath]) => [key, path.resolve(root, relativePath)]) + Object.entries(validatedArtifact.spec.bindings.scripts).map(([key, relativePath]) => [key, path.resolve(root, relativePath)]) ); const artifactRoots = Object.fromEntries( - Object.entries(artifact.spec.bindings.artifact_roots ?? {}).map(([key, relativePath]) => [key, path.resolve(root, relativePath)]) + Object.entries(validatedArtifact.spec.bindings.artifact_roots).map(([key, relativePath]) => [key, path.resolve(root, relativePath)]) ); return { - runtime: artifact.spec.bindings.runtime ?? artifact.metadata?.runtime ?? 'unknown-runtime', - entrypoint: path.resolve(root, artifact.spec.bindings.entrypoint), - pluginVersion: artifact.spec?.package?.pluginVersion ?? null, - compatibilityMode: artifact.metadata?.compatibility_mode ?? 'strict_envelope', + runtime: validatedArtifact.spec.bindings.runtime ?? validatedArtifact.metadata?.runtime ?? 'unknown-runtime', + entrypoint: path.resolve(root, validatedArtifact.spec.bindings.entrypoint), + pluginVersion: validatedArtifact.spec.package.pluginVersion, + compatibilityMode: validatedArtifact.metadata?.compatibility_mode ?? 'strict_envelope', scripts, artifactRoots, }; } export const __testables = { + EXPECTED_KIND, + EXPECTED_API_VERSION, packageRoot, repoRoot, readJsonFile, + validateDeploymentProfileArtifact, }; diff --git a/plugins/reporting-governance/test/governance-contract.integration.test.mjs b/plugins/reporting-governance/test/governance-contract.integration.test.mjs index b3c085f..a2289f5 100644 --- a/plugins/reporting-governance/test/governance-contract.integration.test.mjs +++ b/plugins/reporting-governance/test/governance-contract.integration.test.mjs @@ -1,9 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import path from 'node:path'; import { executeGovernanceContract, runCompatibilityPreflight } from '../src/core/index.mjs'; import capabilityDescriptor from '../capabilities/openclaw-watchdog-reference.json' with { type: 'json' }; +const packageRoot = path.resolve(import.meta.dirname, '..'); +const repoRoot = path.resolve(packageRoot, '..', '..'); + const noSilencePack = { metadata: { id: 'no-silence', severity_default: 'high' }, spec: { @@ -294,3 +298,53 @@ test('schema/version mismatch blocks contract before any runnable plan is produc 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'))); }); + +test('executeGovernanceContract exposes deployment binding when profile artifact bindings are provided', () => { + const profileArtifact = { + ...strictProfile, + kind: 'DeploymentProfileArtifact', + apiVersion: 'reporting-governance/v1alpha1', + metadata: { + ...strictProfile.metadata, + runtime: 'openclaw', + compatibility_mode: 'strict_envelope', + }, + spec: { + ...strictProfile.spec, + bindings: { + runtime: 'openclaw', + entrypoint: 'scripts/watchdog_auto_notify_orchestrator.mjs', + scripts: { + watchdog: 'scripts/long_task_watchdog.mjs', + dispatcher: 'scripts/operator_notify_dispatcher.mjs', + bridgeSupervisor: 'scripts/operator_notify_bridge_supervisor.mjs', + senderBinding: 'scripts/operator_notify_sender_binding.mjs', + orchestrator: 'scripts/watchdog_auto_notify_orchestrator.mjs' + }, + artifact_roots: { + queueItems: 'state/operator-notify-queue' + } + } + } + }; + + const result = executeGovernanceContract({ + event: { + type: 'silence_timeout', + payload: { checkpoint_overdue: true } + }, + capabilityDescriptor, + policyPacks: [noSilencePack], + context: { + signals: ['checkpoint_overdue'] + }, + profile: profileArtifact, + packageVersion: '0.1.0-mainline', + repoRootOverride: repoRoot, + }); + + assert.equal(result.preflight.status, 'pass'); + assert.equal(result.deploymentBinding.entrypoint, path.resolve(repoRoot, 'scripts/watchdog_auto_notify_orchestrator.mjs')); + assert.equal(result.deploymentBinding.scripts.dispatcher, path.resolve(repoRoot, 'scripts/operator_notify_dispatcher.mjs')); + assert.equal(result.deploymentBinding.artifactRoots.queueItems, path.resolve(repoRoot, 'state/operator-notify-queue')); +}); diff --git a/plugins/reporting-governance/test/profile-artifact.test.mjs b/plugins/reporting-governance/test/profile-artifact.test.mjs index d6371e9..dd9acfc 100644 --- a/plugins/reporting-governance/test/profile-artifact.test.mjs +++ b/plugins/reporting-governance/test/profile-artifact.test.mjs @@ -3,7 +3,11 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { loadDeploymentProfileArtifact, createDeploymentBindingContract } from '../src/storage/profile-artifact.mjs'; +import { + loadDeploymentProfileArtifact, + createDeploymentBindingContract, + validateDeploymentProfileArtifact, +} from '../src/storage/profile-artifact.mjs'; import { createRuntimeBinding } from '../src/adapters/index.mjs'; const packageRoot = path.resolve(import.meta.dirname, '..'); @@ -45,3 +49,58 @@ test('runtime binding can be instantiated from profile artifact binding contract assert.equal(runtimeBinding.scripts.bridgeSupervisor, contract.scripts.bridgeSupervisor); assert.equal(runtimeBinding.scripts.senderBinding, contract.scripts.senderBinding); }); + +test('deployment profile artifact validation fails closed on boundary drift', () => { + assert.throws( + () => validateDeploymentProfileArtifact({}), + /kind must be DeploymentProfileArtifact/ + ); + + assert.throws( + () => validateDeploymentProfileArtifact({ + kind: 'DeploymentProfileArtifact', + apiVersion: 'reporting-governance/v1alpha1', + spec: { + package: { pluginVersion: '0.1.0-mainline' }, + bindings: { + entrypoint: '', + scripts: { watchdog: 'scripts/long_task_watchdog.mjs' }, + artifact_roots: { queueItems: 'state/operator-notify-queue' }, + }, + }, + }), + /spec\.bindings\.entrypoint must be a non-empty string/ + ); + + assert.throws( + () => validateDeploymentProfileArtifact({ + kind: 'DeploymentProfileArtifact', + apiVersion: 'reporting-governance/v1alpha1', + spec: { + package: { pluginVersion: '' }, + bindings: { + entrypoint: 'scripts/watchdog_auto_notify_orchestrator.mjs', + scripts: { watchdog: '' }, + artifact_roots: { queueItems: 'state/operator-notify-queue' }, + }, + }, + }), + /spec\.package\.pluginVersion must be a non-empty string/ + ); + + assert.throws( + () => validateDeploymentProfileArtifact({ + kind: 'DeploymentProfileArtifact', + apiVersion: 'reporting-governance/v1alpha1', + spec: { + package: { pluginVersion: '0.1.0-mainline' }, + bindings: { + entrypoint: 'scripts/watchdog_auto_notify_orchestrator.mjs', + scripts: [], + artifact_roots: { queueItems: 'state/operator-notify-queue' }, + }, + }, + }), + /spec\.bindings\.scripts must be an object record/ + ); +}); diff --git a/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs b/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs index 2b3107c..06fab24 100644 --- a/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs +++ b/plugins/reporting-governance/test/watchdog-chain.integration.test.mjs @@ -9,6 +9,8 @@ import { runWatchdogChain, } from '../src/index.mjs'; +const packageRoot = path.resolve(import.meta.dirname, '..'); + function createFixtureRoot() { return fs.mkdtempSync(path.join(os.tmpdir(), 'reporting-governance-plugin-')); } @@ -115,3 +117,35 @@ test('dry-run path produces verifiable pending receipt via package adapter', () fs.rmSync(root, { recursive: true, force: true }); } }); + +test('orchestrator adapter can bootstrap from profile artifact loader path', () => { + const root = createFixtureRoot(); + try { + mkdirs(root, ['evidence', 'events', 'queue', 'spool', 'receipts']); + const statePath = writeState(root); + + const result = runOrchestratorAdapter({ + profileId: 'strict-manager-mode', + repoRootOverride: path.resolve(packageRoot, '..', '..'), + state: statePath, + evidenceDir: path.join(root, 'evidence'), + eventDir: path.join(root, 'events'), + queueDir: path.join(root, 'queue'), + spoolDir: path.join(root, 'spool'), + receiptDir: path.join(root, 'receipts'), + writeState: true, + dryRun: true, + now: '2026-05-07T08:20:00.000Z', + }); + + assert.equal(result.ok, true); + assert.equal(result.result.watchdog.notificationCount, 1); + assert.equal(result.result.dispatcher.dispatchedCount, 1); + assert.equal(result.result.supervisor.pendingCount, 1); + + const receipt = readSingleJson(path.join(root, 'receipts')).payload; + assert.equal(receipt.state, 'pending_external_send'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +});