diff --git a/docs/roadmaps/reporting-governance-plugin-status-plain-language.md b/docs/roadmaps/reporting-governance-plugin-status-plain-language.md new file mode 100644 index 0000000..20aa999 --- /dev/null +++ b/docs/roadmaps/reporting-governance-plugin-status-plain-language.md @@ -0,0 +1,231 @@ +# Reporting Governance Plugin:現在做到哪、為什麼這樣切、還沒做到什麼 + +這份文件是給**不想先看程式碼的人**看的。 + +一句話先講白: +**這個 plugin 在做的事,是把「代理有沒有老實回報、有没有卡住不講、有没有把該交接的事真的交出去」這些規則,從口頭要求,慢慢做成可驗證的程式與紀錄。** + +--- + +## 1) 目前已經做了什麼 + +現在不是只有概念,而是已經有一條**可以跑、會留下痕跡、能被測試證明**的最小鏈路。 + +### 已經有的骨架 + +`plugins/reporting-governance/` 這個 package 已經拉出基本邊界: + +- `core/`:放規則判斷與決策邏輯 +- `adapters/`:接現有 runtime / script +- `storage/`:放可保存的 artifact 與 store contract +- `profiles/`:放部署設定 artifact +- `capabilities/`:放能力描述 + +這很重要,因為它代表這件事開始不是散在 repo 各角落,而是有自己的 package 家。 + +### 已經有的決策能力 + +目前已經有最小可跑的: + +- policy evaluator:根據事件/證據判斷要不要出手 +- decision runner:把判斷結果轉成可執行意圖 +- compatibility preflight:先檢查輸入/相容性,不對就 fail closed + +白話講,就是: +**系統已經不只是「看到問題」,而是能把問題翻成明確決策,例如要不要強制 checkpoint、要不要擋下某動作。** + +### 已經有的 OpenClaw 參考鏈 + +目前 repo 裡最真實的一條鏈,是 watchdog 逾時告警路徑: + +`watchdog -> queue -> dispatcher -> bridge -> sender -> receipt` + +這條鏈已經能做到: + +- 產生 canonical event +- 進 queue / spool +- 經過 bridge / sender binding +- 留下 receipt +- 在不能真的送出的情況下,誠實標示成 `pending_external_send`,不是假裝已送達 + +這件事的價值很實際: +**它已經能防止「其實沒送到,卻說送到了」這種黑箱假成功。** + +### 已經有的 package-owned profile artifact + +目前已經有一個 package 自己擁有的部署設定 artifact: + +- `plugins/reporting-governance/profiles/strict-manager-mode.profile.json` + +而且不是擺著好看,已經有: + +- loader +- validator +- binding contract projector +- 測試 + +白話講: +**這個 plugin 已經開始自己宣告:我依賴哪些腳本、哪些 artifact 目錄、哪些 runtime 邊界。** + +不是再靠「大家心裡知道」或 README 暗示。 + +### 這一輪新補的最小 storage contract slice + +這輪再往前推了一小刀: + +- 定義 `DecisionRecordArtifact` +- 定義 decision artifact validator / filename contract +- 落一個最小 file-based decision store +- 用測試證明 decision 可以被寫入、讀回、驗證,而且不能亂寫出 repo root + +白話講: +**決策紀錄開始有 package 自己的保存格式,而不是每次臨時拼 JSON。** + +--- + +## 2) 為什麼要這樣設計 / 為什麼要這樣切 slices + +### 原因一:這題太大,不能一口氣抽乾淨 + +Reporting governance 牽到的東西很多: + +- 事件模型 +- 證據模型 +- 決策模型 +- queue / spool / receipt +- runtime adapter +- 真正外送通知 +- 未來的 audit/export + +如果一次硬做成完整 framework,很容易出現兩種壞事: + +1. 做很大,但沒有任何一塊真的可驗證 +2. 名字切漂亮,實際上還是靠舊 repo 腳本在撐 + +所以現在的主線策略是: +**每次只切一塊最小、能被測試證明、而且不會假裝完成的 slice。** + +### 原因二:先把「誠實邊界」做出來 + +這個 plugin 的核心不是炫技,而是**不能說謊**。 + +所以設計上優先做的是: + +- capability descriptor:先講清楚 runtime 有什麼能力 +- compatibility preflight:能力不夠就 fail,不硬裝能做 +- truthful receipt states:沒送到就是沒送到,不能灌水成 acked +- package-owned artifacts:讓輸入/輸出有固定契約 + +這就是為什麼現在會先看到 profile artifact、receipt truth model、decision record 這些東西,而不是先衝一個很大的「全能治理引擎」。 + +### 原因三:把 package 真正養成自己的邊界 + +如果 `core`、`adapters`、`storage` 不分開,未來就會很難回答: + +- 哪些是可重用的治理邏輯? +- 哪些只是 OpenClaw 這個 runtime 的接線? +- 哪些是資料保存契約? + +現在這樣切,是為了讓未來能逐步達到: + +- `core` 不依賴特定 runtime +- `adapters` 負責接現場 +- `storage` 擁有 artifact contract + +這樣才有機會從「repo 內一套特殊腳本」長成「可攜的 plugin」。 + +--- + +## 3) 這些工作帶來什麼實際好處 + +### 好處一:比較不容易再發生黑箱失聯 + +watchdog / queue / receipt 這條鏈已經讓「任務太久沒回報」有一條比較正式的處理路。 + +不是再靠人記得盯。 + +### 好處二:可以區分「真的做到」和「只是說做到」 + +receipt truth model 的意義很直接: + +- 有證據送出,才叫 acked +- 沒證據,就停在 pending / blocked / dispatched 等誠實狀態 + +這會直接降低假完成、假外送、假交接。 + +### 好處三:未來比較能換 runtime,不用整組重寫 + +先把 core / adapter / storage 切開,未來若不是 OpenClaw,也比較有機會重用核心規則。 + +### 好處四:決策開始可保存、可追查 + +這輪 decision store slice 的實際價值是: + +- 決策不只存在記憶體 +- 可落成 artifact +- 檔名與內容有固定 contract +- 可追 policy、decision、receipt、source event + +這對 audit、debug、回放都很關鍵。 + +--- + +## 4) 目前明確還沒做到什麼 + +這裡要講老實話。 + +### 還沒做到完整 storage framework + +現在只有先落一個最小 decision store slice。 + +**還沒有**完整抽成: + +- event store +- evidence store +- queue store +- spool store +- receipt store +- 統一 manifest / audit export + +### 還沒做到所有決策都能真的執行 + +目前 package `core` 已經能做判斷與規劃, +但很多實際 side effects 還是 adapter / runtime 責任。 + +例如: + +- 真正 inline 攔截所有訊息/狀態變更 +- 完整 rewrite / placeholder / review / downgrade_status 執行 +- 非 watchdog 路徑的全面治理攔截 + +### 還沒完全脫離 repo 既有 scripts + +現在 adapter 仍大量包既有 scripts。 +這代表 package 邊界雖然成形,但 runtime 實作還在過渡期。 + +### 還沒形成完整對外穩定 API + +現在還是 `0.1.0-mainline`,屬於 pre-1.0。 + +意思是: + +- package surface 還在收斂 +- 深層 `src/` import 不算穩定公開 API +- 現在能跑,不代表介面已經定版 + +--- + +## 現在最準確的定位 + +如果要一句話描述現在狀態: + +**它已經不是只有規格文件;已經有最小可跑、可測、會留痕跡的治理鏈。** + +但同時: + +**它也還不是完整成熟的 reporting-governance framework。** + +現在主線是在做一件對的事: +**把最重要、最容易說謊、最需要證據的那幾刀,先變成 package-owned contracts。** + +這樣後面每往前一步,才比較不會變成另一個大而空的治理殼。 diff --git a/plugins/reporting-governance/package.json b/plugins/reporting-governance/package.json index 4feaefa..208ad2e 100644 --- a/plugins/reporting-governance/package.json +++ b/plugins/reporting-governance/package.json @@ -14,7 +14,7 @@ "./adapters/orchestrator": "./src/adapters/orchestrator.mjs" }, "scripts": { - "test": "node --test test/package-structure.test.mjs test/policy-evaluator.test.mjs test/compatibility-preflight.test.mjs test/profile-artifact.test.mjs test/profile-generator.test.mjs test/decision-runner.test.mjs test/governance-contract.integration.test.mjs test/watchdog-chain.integration.test.mjs test/runtime-integrated.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/profile-artifact.test.mjs test/profile-generator.test.mjs test/decision-runner.test.mjs test/decision-store.test.mjs test/governance-contract.integration.test.mjs test/watchdog-chain.integration.test.mjs test/runtime-integrated.integration.test.mjs test/exports-boundary.integration.test.mjs" }, "dependencies": { "ajv": "^8.20.0", diff --git a/plugins/reporting-governance/src/index.mjs b/plugins/reporting-governance/src/index.mjs index 79892ef..2e2dc73 100644 --- a/plugins/reporting-governance/src/index.mjs +++ b/plugins/reporting-governance/src/index.mjs @@ -1,6 +1,11 @@ export const packageName = '@openclaw/plugin-reporting-governance'; export const packageVersion = '0.1.0-mainline'; +export const artifactKinds = { + deploymentProfile: 'DeploymentProfileArtifact', + decisionRecord: 'DecisionRecordArtifact', +}; + export const packageBoundaries = { core: [ 'event normalization', @@ -46,3 +51,9 @@ export { runOrchestratorAdapter, } from './adapters/index.mjs'; export { runOrchestratorAdapter as runWatchdogChain } from './adapters/orchestrator.mjs'; +export { + createDecisionRecordArtifact, + createDecisionRecordFileName, + createFileDecisionStore, + validateDecisionRecordArtifact, +} from './storage/index.mjs'; diff --git a/plugins/reporting-governance/src/storage/decision-artifact.mjs b/plugins/reporting-governance/src/storage/decision-artifact.mjs new file mode 100644 index 0000000..a224bb8 --- /dev/null +++ b/plugins/reporting-governance/src/storage/decision-artifact.mjs @@ -0,0 +1,95 @@ +import crypto from 'node:crypto'; + +const EXPECTED_KIND = 'DecisionRecordArtifact'; +const EXPECTED_API_VERSION = 'reporting-governance/v1alpha1'; + +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; +} + +function sanitizeFileSegment(value, fallback) { + const normalized = String(value ?? '').trim().replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); + return normalized || fallback; +} + +export function validateDecisionRecordArtifact(artifact) { + if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) { + throw new Error('decision record artifact must be an object'); + } + if (artifact.kind !== EXPECTED_KIND) { + throw new Error(`decision record artifact kind must be ${EXPECTED_KIND}`); + } + if (artifact.apiVersion !== EXPECTED_API_VERSION) { + throw new Error(`decision record artifact apiVersion must be ${EXPECTED_API_VERSION}`); + } + + const metadata = assertObjectRecord(artifact.metadata, 'decision record artifact metadata'); + const spec = assertObjectRecord(artifact.spec, 'decision record artifact spec'); + const decision = assertObjectRecord(spec.decision, 'decision record artifact spec.decision'); + const receipt = assertObjectRecord(spec.receipt, 'decision record artifact spec.receipt'); + + assertNonEmptyString(metadata.recorded_at, 'decision record artifact metadata.recorded_at'); + assertNonEmptyString(metadata.policy_id, 'decision record artifact metadata.policy_id'); + assertNonEmptyString(metadata.decision, 'decision record artifact metadata.decision'); + assertNonEmptyString(decision.policy_id, 'decision record artifact spec.decision.policy_id'); + assertNonEmptyString(decision.decision, 'decision record artifact spec.decision.decision'); + assertNonEmptyString(receipt.delivery_state, 'decision record artifact spec.receipt.delivery_state'); + + return artifact; +} + +export function createDecisionRecordArtifact({ decision, receipt, recordedAt = new Date().toISOString(), source = {} } = {}) { + const normalizedDecision = assertObjectRecord(decision, 'decision'); + const normalizedReceipt = assertObjectRecord(receipt, 'receipt'); + const policyId = assertNonEmptyString(normalizedDecision.policy_id, 'decision.policy_id'); + const decisionName = assertNonEmptyString(normalizedDecision.decision, 'decision.decision'); + + return validateDecisionRecordArtifact({ + kind: EXPECTED_KIND, + apiVersion: EXPECTED_API_VERSION, + metadata: { + record_id: `dec_${crypto.randomUUID()}`, + recorded_at: assertNonEmptyString(recordedAt, 'recordedAt'), + policy_id: policyId, + decision: decisionName, + correlation_id: source.correlation_id ?? null, + task_id: source.task_id ?? null, + event_id: source.event_id ?? null, + }, + spec: { + decision: normalizedDecision, + receipt: normalizedReceipt, + source: { + event_id: source.event_id ?? null, + task_id: source.task_id ?? null, + correlation_id: source.correlation_id ?? null, + }, + }, + }); +} + +export function createDecisionRecordFileName(artifact) { + const validatedArtifact = validateDecisionRecordArtifact(artifact); + return [ + validatedArtifact.metadata.recorded_at.replace(/[.:]/g, '-'), + sanitizeFileSegment(validatedArtifact.metadata.policy_id, 'policy'), + sanitizeFileSegment(validatedArtifact.metadata.decision, 'decision'), + `${validatedArtifact.metadata.record_id}.json`, + ].join('-'); +} + +export const __testables = { + EXPECTED_KIND, + EXPECTED_API_VERSION, + sanitizeFileSegment, +}; diff --git a/plugins/reporting-governance/src/storage/decision-store.mjs b/plugins/reporting-governance/src/storage/decision-store.mjs new file mode 100644 index 0000000..e319cc8 --- /dev/null +++ b/plugins/reporting-governance/src/storage/decision-store.mjs @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { + createDecisionRecordArtifact, + createDecisionRecordFileName, + validateDecisionRecordArtifact, +} from './decision-artifact.mjs'; +import { assertUseTimePathWithinRepoRoot } from './profile-artifact.mjs'; + +export function createFileDecisionStore({ decisionsDir, repoRootOverride } = {}) { + const resolvedDecisionsDir = assertUseTimePathWithinRepoRoot( + path.resolve(decisionsDir ?? 'state/reporting-governance-decisions'), + 'decision store decisionsDir', + { repoRootOverride, allowMissingLeaf: true } + ); + + return { + decisionsDir: resolvedDecisionsDir, + write({ decision, receipt, recordedAt, source } = {}) { + fs.mkdirSync(resolvedDecisionsDir, { recursive: true }); + const artifact = createDecisionRecordArtifact({ decision, receipt, recordedAt, source }); + const artifactPath = path.join(resolvedDecisionsDir, createDecisionRecordFileName(artifact)); + fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8'); + return { + artifact, + artifactPath, + }; + }, + load(artifactPath) { + const resolvedPath = assertUseTimePathWithinRepoRoot( + path.resolve(artifactPath), + 'decision store artifactPath', + { repoRootOverride } + ); + const artifact = validateDecisionRecordArtifact(JSON.parse(fs.readFileSync(resolvedPath, 'utf8'))); + return { + artifact, + artifactPath: resolvedPath, + }; + }, + }; +} diff --git a/plugins/reporting-governance/src/storage/index.mjs b/plugins/reporting-governance/src/storage/index.mjs index 116364c..b602fec 100644 --- a/plugins/reporting-governance/src/storage/index.mjs +++ b/plugins/reporting-governance/src/storage/index.mjs @@ -8,3 +8,9 @@ export { generateDeploymentProfileArtifact, generateDeploymentProfileArtifactFromFile, } from './profile-generator.mjs'; +export { + createDecisionRecordArtifact, + createDecisionRecordFileName, + validateDecisionRecordArtifact, +} from './decision-artifact.mjs'; +export { createFileDecisionStore } from './decision-store.mjs'; diff --git a/plugins/reporting-governance/test/decision-store.test.mjs b/plugins/reporting-governance/test/decision-store.test.mjs new file mode 100644 index 0000000..3f2cea3 --- /dev/null +++ b/plugins/reporting-governance/test/decision-store.test.mjs @@ -0,0 +1,133 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + createDecisionRecordArtifact, + createDecisionRecordFileName, + createFileDecisionStore, + validateDecisionRecordArtifact, +} from '../src/storage/index.mjs'; +import { planDecisionExecution } from '../src/core/decision-runner.mjs'; + +const packageRoot = path.resolve(import.meta.dirname, '..'); +const repoRoot = path.resolve(packageRoot, '..', '..'); + +const capabilityDescriptor = { + capabilities: { + enforcement: { + force_checkpoint: { supported: true, level: 'partial' }, + 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: false, level: 'none' }, + truth_model: { + delivery_states: ['prepared', 'queued', 'dispatched', 'pending_external_send', 'acked', 'blocked'], + ack_requires_proven_send: true, + pending_external_send_supported: true + } + } + } +}; + +function createPlannedDecision() { + return 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: 'emit_event', target: 'event_stream', mandatory: true } + ], + operator_notice: { + required: true, + channel: 'telegram', + urgency: 'high', + message: 'Required update.', + deadline: '2026-01-01T00:00:00.000Z' + } + }, + capabilityDescriptor, + }); +} + +test('decision artifact validates minimal package-owned contract', () => { + const planned = createPlannedDecision(); + const artifact = createDecisionRecordArtifact({ + decision: planned.decision, + receipt: planned.receipt, + recordedAt: '2026-05-08T04:00:00.000Z', + source: { + event_id: 'evt_watchdog_001', + task_id: 'task-reporting-governance', + correlation_id: 'corr-001', + }, + }); + + assert.equal(artifact.kind, 'DecisionRecordArtifact'); + assert.equal(artifact.apiVersion, 'reporting-governance/v1alpha1'); + assert.equal(artifact.metadata.policy_id, 'no-silence.missed-checkpoint'); + assert.equal(artifact.spec.receipt.delivery_state, 'pending_external_send'); + assert.equal(validateDecisionRecordArtifact(artifact), artifact); +}); + +test('decision artifact filename is stable and readable', () => { + const planned = createPlannedDecision(); + const artifact = createDecisionRecordArtifact({ + decision: planned.decision, + receipt: planned.receipt, + recordedAt: '2026-05-08T04:00:00.000Z', + }); + + const fileName = createDecisionRecordFileName(artifact); + assert.match(fileName, /^2026-05-08T04-00-00-000Z-no-silence\.missed-checkpoint-force_checkpoint-dec_[a-f0-9-]+\.json$/); +}); + +test('file decision store writes and reloads a validated decision artifact inside repo root', async (t) => { + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'reporting-governance-decision-store-')); + t.after(() => fs.rmSync(sandbox, { recursive: true, force: true })); + + const fakeRepoRoot = path.join(sandbox, 'repo'); + fs.mkdirSync(fakeRepoRoot, { recursive: true }); + + const planned = createPlannedDecision(); + const store = createFileDecisionStore({ + decisionsDir: path.join(fakeRepoRoot, 'state', 'decisions'), + repoRootOverride: fakeRepoRoot, + }); + + const written = store.write({ + decision: planned.decision, + receipt: planned.receipt, + recordedAt: '2026-05-08T04:00:00.000Z', + source: { + event_id: 'evt_watchdog_001', + task_id: 'task-reporting-governance', + correlation_id: 'corr-001', + }, + }); + + assert.equal(fs.existsSync(written.artifactPath), true); + assert.match(path.basename(written.artifactPath), /^2026-05-08T04-00-00-000Z-no-silence\.missed-checkpoint-force_checkpoint-dec_[a-f0-9-]+\.json$/); + + const loaded = store.load(written.artifactPath); + assert.equal(loaded.artifact.metadata.event_id, 'evt_watchdog_001'); + assert.equal(loaded.artifact.spec.receipt.delivery_state, 'pending_external_send'); +}); + +test('file decision store rejects decision directory escaping repo root', () => { + assert.throws( + () => createFileDecisionStore({ + decisionsDir: path.resolve(repoRoot, '..', 'escape'), + repoRootOverride: repoRoot, + }), + /decision store decisionsDir must stay within repo root/ + ); +}); diff --git a/plugins/reporting-governance/test/exports-boundary.integration.test.mjs b/plugins/reporting-governance/test/exports-boundary.integration.test.mjs index d6db08c..bfe4c1a 100644 --- a/plugins/reporting-governance/test/exports-boundary.integration.test.mjs +++ b/plugins/reporting-governance/test/exports-boundary.integration.test.mjs @@ -66,18 +66,27 @@ test('package root export resolves public package surface only', () => { import * as plugin from '@openclaw/plugin-reporting-governance'; process.stdout.write(JSON.stringify({ packageName: plugin.packageName, + artifactKinds: plugin.artifactKinds, hasRunWatchdogChain: typeof plugin.runWatchdogChain, hasPlanDecisionExecution: typeof plugin.planDecisionExecution, hasExecuteGovernanceContract: typeof plugin.executeGovernanceContract, hasExecuteRuntimeIntegratedGovernance: typeof plugin.executeRuntimeIntegratedGovernance, + hasCreateDecisionRecordArtifact: typeof plugin.createDecisionRecordArtifact, + hasCreateFileDecisionStore: typeof plugin.createFileDecisionStore, })); `); assert.equal(result.packageName, '@openclaw/plugin-reporting-governance'); + assert.deepEqual(result.artifactKinds, { + deploymentProfile: 'DeploymentProfileArtifact', + decisionRecord: 'DecisionRecordArtifact', + }); assert.equal(result.hasRunWatchdogChain, 'function'); assert.equal(result.hasPlanDecisionExecution, 'function'); assert.equal(result.hasExecuteGovernanceContract, 'function'); assert.equal(result.hasExecuteRuntimeIntegratedGovernance, 'function'); + assert.equal(result.hasCreateDecisionRecordArtifact, 'function'); + assert.equal(result.hasCreateFileDecisionStore, 'function'); } finally { fs.rmSync(root, { recursive: true, force: true }); } diff --git a/plugins/reporting-governance/test/package-structure.test.mjs b/plugins/reporting-governance/test/package-structure.test.mjs index 6dae6d8..ac25c58 100644 --- a/plugins/reporting-governance/test/package-structure.test.mjs +++ b/plugins/reporting-governance/test/package-structure.test.mjs @@ -20,6 +20,8 @@ const requiredPaths = [ 'src/adapters/orchestrator.mjs', 'src/storage', 'src/storage/profile-artifact.mjs', + 'src/storage/decision-artifact.mjs', + 'src/storage/decision-store.mjs', 'src/reference/openclaw-watchdog-chain.md', 'capabilities/openclaw-watchdog-reference.json', 'examples/openclaw-watchdog-reference.descriptor.example.json',