From 7e4b2906bbdf351429850dede10cb8744bec4c5d Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:36:08 +0800 Subject: [PATCH] fix(todos): bind first completion validator through canonical updates Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../presentation-surface-schema-smoke.ts | 19 +++ .../presentation/dashboard/src/data/status.ts | 2 +- docs/project-agent-todo-contract.md | 50 +++++++ .../goals/acceptance_contract.ts | 36 +++-- .../todos/completion_validation_revision.ts | 89 ++++++++++-- loopx/control_plane/todos/provider_update.py | 12 +- ...t_completion_validation_initial_binding.py | 129 ++++++++++++++++++ .../authority_store_conformance.ts | 2 + ...mpletion_validation_binding_conformance.ts | 71 ++++++++++ .../goal_acceptance_authority.test.ts | 46 +++++++ tests/control_plane_ts/todo_update.test.ts | 81 +++++++++++ 11 files changed, 503 insertions(+), 34 deletions(-) create mode 100644 tests/control_plane/test_completion_validation_initial_binding.py create mode 100644 tests/control_plane_ts/completion_validation_binding_conformance.ts diff --git a/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.ts b/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.ts index 1d15402f27..159d761ac1 100644 --- a/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.ts +++ b/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.ts @@ -46,6 +46,25 @@ assert( "agent-a", "Todo validator revision readback must survive status parsing", ); +const firstBoundTodo = todoItemSchema.parse({ + ...revisedTodo, + completion_validation_revision_history: [{ + schema_version: "loopx_todo_completion_validation_revision_receipt_v1", + revision: 1, + operation_id: "first-bind", + previous_declaration_sha256: null, + previous_validation_authority: {}, + declaration_sha256: PAYLOAD_SHA256, + actor_agent_id: "agent-a", + revised_at: "2026-09-27T00:00:00Z", + }], +}); +assert( + firstBoundTodo.completion_validation_revision_history[0]?.previous_declaration_sha256 === null && + firstBoundTodo.completion_validation_revision_history[0]?.schema_version === + "loopx_todo_completion_validation_revision_receipt_v1", + "First validator binding must preserve explicit absence and its versioned receipt in status readback", +); function detailRef() { return { diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts index 6569d8d714..cd8572fb67 100644 --- a/apps/presentation/dashboard/src/data/status.ts +++ b/apps/presentation/dashboard/src/data/status.ts @@ -92,7 +92,7 @@ export const todoItemSchema = z.object({ completion_validation_revision: z.number().int().nonnegative().optional().nullable(), completion_validation_revision_history: z.array(z.object({ revision: z.number().int().positive(), - previous_declaration_sha256: z.string(), + previous_declaration_sha256: z.string().nullable(), declaration_sha256: z.string(), actor_agent_id: z.string(), revised_at: z.string(), diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index 6db3e0d817..f0cb49cbc0 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -825,6 +825,56 @@ fails closed with a typed `validation_blocked_completion` receipt instead of committing `done`. Todos without a declared command keep the unchanged fast path. +### Bind the first completion validator / 首次绑定完成验证 + +An active, open canonical Todo imported without a completion validator can use +the existing `todo update` entry point to bind its first command. Read the current +provider revision first; include the registered actor and the current lease fence +when the Goal requires a lease. Review the command before committing it: + +```bash +loopx todo update \ + --goal-id --todo-id --agent-id \ + --update-operation-id \ + --update-expected-provider-revision \ + --task-lease-idempotency-key \ + --task-lease-expected-version \ + --validation-command-json '["node","--test","tests/independent-check.test.ts"]' \ + --validation-label "Independent completion check" --dry-run +``` + +Remove `--dry-run` only after inspecting the preview. Binding does not run the +command or complete the Todo. `todo complete` must execute the current declaration +successfully; failure keeps the Todo open. The native TypeScript update transaction +requires a genuinely absent digest (not a broken required validator), records a +v1 revision receipt with `previous_declaration_sha256: null` and the exact previous +validation markers, then appends ordinary v0 replacement receipts on later edits. +The private command stays in the local declaration store; canonical readback carries +the digest and receipt, not the command. Status/frontend readback accepts both +receipt versions; Lark consumes the same Todo projection, with no second binding +store or validator editor. + +After a lost response or private publication failure, retry the same operation, +revision, lease and command. Do not mint another operation to evade a conflict. +A historical replay cannot restore a validator that has since been replaced. +Proven first binding preserves an existing owner acceptance association, but +cannot create an absent association or excuse changed work/write scope. Owner +Goal criteria, lease authority and completion validation remain separate gates. + +已晋级到 canonical provider、仍为 active/open 且从未声明完成验证的旧 Todo, +可通过现有 `todo update` 首次绑定命令。先读当前 provider revision,带上注册 +Agent 身份及现有租约 fence(需要租约时),检查上述预览后再去掉 `--dry-run`。 +绑定不执行命令、不完成任务;真正完成仍必须运行当前命令,验证失败保持 open。 +TS 更新事务只接受真实缺失的 digest,不把“required=true 但 digest 缺失”的损坏 +状态当成首次绑定。首次 v1 回执记录旧 digest 为 null 及旧验证字段的准确状态; +后续替换继续追加兼容的 v0 回执。命令保留在本地私有存储,canonical、前端状态 +及 Lark 共用 digest/历史回执投影,不新增另一套验证配置或编辑器。 + +响应丢失或私有声明发布失败后,沿用原操作 ID、revision、租约和命令重试;不能 +换 ID 绕过冲突,也不能借历史重放恢复已被替换的命令。可证明的首次绑定仅保留 +已有 owner 验收关联,不能补造缺失的关联,不能豁免工作内容或写入范围变化。 +Goal 验收准则、执行租约和完成验证仍各自独立。 + Use `--resume-when` when deferring a successor that should wake up after a machine-readable condition instead of living only in prose: diff --git a/loopx/control_plane/goals/acceptance_contract.ts b/loopx/control_plane/goals/acceptance_contract.ts index d4f9afdc05..7947bb9413 100644 --- a/loopx/control_plane/goals/acceptance_contract.ts +++ b/loopx/control_plane/goals/acceptance_contract.ts @@ -4,6 +4,8 @@ import type {JsonObject} from "../effect_program.ts"; import {AuthorityStoreProtocolError, authorityUnicodeCompare, canonicalAuthorityBytes, canonicalAuthorityObject, canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; import {indexCoordinationProjectionTodos, validateCoordinationTodoReadModel} from "../coordination/coordination_projection.ts"; +import {COMPLETION_VALIDATION_BINDING_RECEIPT_SCHEMA, + completionValidationRevisionHistory} from "../todos/completion_validation_revision.ts"; export const GOAL_ACCEPTANCE_SCHEMA = "loopx_goal_acceptance_v0"; export interface AcceptanceCriterion extends JsonObject { @@ -213,21 +215,19 @@ function acceptanceBindingMatches(todo: JsonObject, boundDigest: string): boolea } const revision = todo.completion_validation_revision; - const history = todo.completion_validation_revision_history; + let history: JsonObject[] = []; let revisionPrefixes: number[] = []; if (Number.isSafeInteger(revision) && Number(revision) >= 1 && Number(revision) <= 32 && - Array.isArray(history) && history.length === revision && - history.every((entry, index) => entry !== null && typeof entry === "object" && !Array.isArray(entry) && - (entry as JsonObject).schema_version === "loopx_todo_completion_validation_revision_receipt_v0" && - (entry as JsonObject).revision === index + 1 && - typeof (entry as JsonObject).previous_declaration_sha256 === "string" && - /^[a-f0-9]{64}$/.test((entry as JsonObject).previous_declaration_sha256 as string) && - typeof (entry as JsonObject).declaration_sha256 === "string" && - /^[a-f0-9]{64}$/.test((entry as JsonObject).declaration_sha256 as string)) && - history.every((entry, index) => index === 0 || - (entry as JsonObject).previous_declaration_sha256 === (history[index - 1] as JsonObject).declaration_sha256) && - (history.at(-1) as JsonObject).declaration_sha256 === todo.completion_validation_sha256) { - revisionPrefixes = Array.from({length: Number(revision)}, (_, index) => index); + todo.completion_validation_required === true && + typeof todo.completion_validation_sha256 === "string") { + try { + history = completionValidationRevisionHistory(todo.completion_validation_revision_history, + Number(revision), todo.completion_validation_sha256); + revisionPrefixes = Array.from({length: Number(revision)}, (_, index) => index); + } catch (error) { + // Malformed history cannot prove any prior owner-confirmed declaration. + if (!(error instanceof AuthorityStoreProtocolError)) throw error; + } } for (const scheduleVariant of scheduleVariants) { @@ -246,7 +246,15 @@ function acceptanceBindingMatches(todo: JsonObject, boundDigest: string): boolea if (successorVariant !== todo && goalAcceptanceTodoDigest(successorVariant) === boundDigest) return true; for (const priorRevision of revisionPrefixes) { const previous: JsonObject = {...successorVariant, completion_validation_revision: priorRevision, - completion_validation_revision_history: (history as JsonObject[]).slice(0, priorRevision)}; + completion_validation_revision_history: history.slice(0, priorRevision)}; + if (priorRevision === 0 && history[0]?.schema_version === COMPLETION_VALIDATION_BINDING_RECEIPT_SCHEMA) { + delete previous.completion_validation_required; + delete previous.completion_validation_revision; + delete previous.completion_validation_revision_history; + Object.assign(previous, history[0].previous_validation_authority); + if (goalAcceptanceTodoDigest(previous) === boundDigest) return true; + continue; + } if (goalAcceptanceTodoDigest(previous) === boundDigest) return true; if (priorRevision === 0) { delete previous.completion_validation_revision; diff --git a/loopx/control_plane/todos/completion_validation_revision.ts b/loopx/control_plane/todos/completion_validation_revision.ts index b94c9d2a88..ab2d7de185 100644 --- a/loopx/control_plane/todos/completion_validation_revision.ts +++ b/loopx/control_plane/todos/completion_validation_revision.ts @@ -10,12 +10,17 @@ import {normalizeTodoCompletionValidationDeclaration} from "./completion_validat export const COMPLETION_VALIDATION_REVISION_SCHEMA = "loopx_todo_completion_validation_revision_v0"; +export const COMPLETION_VALIDATION_BINDING_SCHEMA = + "loopx_todo_completion_validation_revision_v1"; export const COMPLETION_VALIDATION_REVISION_RECEIPT_SCHEMA = "loopx_todo_completion_validation_revision_receipt_v0"; +export const COMPLETION_VALIDATION_BINDING_RECEIPT_SCHEMA = + "loopx_todo_completion_validation_revision_receipt_v1"; export interface CompletionValidationRevision extends JsonObject { - readonly schema_version: typeof COMPLETION_VALIDATION_REVISION_SCHEMA; - readonly expected_declaration_sha256: string; + readonly schema_version: typeof COMPLETION_VALIDATION_REVISION_SCHEMA | + typeof COMPLETION_VALIDATION_BINDING_SCHEMA; + readonly expected_declaration_sha256: string | null; readonly declaration: JsonObject; } @@ -26,10 +31,31 @@ const digest = (value: unknown, label: string): string => { return value; }; -const revisionHistory = ( +/** The exact absent/default markers are retained, not a fabricated old digest. + * A missing digest beside a required validator is corruption, not first binding. */ +function unboundValidationAuthority(todo: JsonObject): JsonObject { + if (Object.hasOwn(todo, "completion_validation_sha256") || + (Object.hasOwn(todo, "completion_validation_required") && + todo.completion_validation_required !== false) || + (Object.hasOwn(todo, "completion_validation_revision") && + todo.completion_validation_revision !== 0) || + (Object.hasOwn(todo, "completion_validation_revision_history") && + (!Array.isArray(todo.completion_validation_revision_history) || + todo.completion_validation_revision_history.length !== 0))) { + throw new AuthorityStoreProtocolError( + "first binding requires absent completion validation authority", + ); + } + return Object.fromEntries(Object.entries(todo).filter(([field]) => [ + "completion_validation_required", "completion_validation_revision", + "completion_validation_revision_history", + ].includes(field))); +} + +export const completionValidationRevisionHistory = ( value: unknown, priorRevision: number, - currentDigest: string, + currentDigest: string | null, ): JsonObject[] => { if (value === undefined && priorRevision === 0) return []; if (!Array.isArray(value)) { @@ -42,6 +68,8 @@ const revisionHistory = ( entry, `completion validation revision history[${index}]`, ); + const firstBinding = receipt.schema_version === + COMPLETION_VALIDATION_BINDING_RECEIPT_SCHEMA; const fields = [ "schema_version", "revision", @@ -50,10 +78,12 @@ const revisionHistory = ( "declaration_sha256", "actor_agent_id", "revised_at", + ...(firstBinding ? ["previous_validation_authority"] : []), ]; if (Object.keys(receipt).some((field) => !fields.includes(field)) || - receipt.schema_version !== - COMPLETION_VALIDATION_REVISION_RECEIPT_SCHEMA || + (!firstBinding && receipt.schema_version !== + COMPLETION_VALIDATION_REVISION_RECEIPT_SCHEMA) || + (firstBinding && (index !== 0 || receipt.previous_declaration_sha256 !== null)) || !Number.isSafeInteger(receipt.revision) || Number(receipt.revision) < 1 || receipt.revision !== index + 1 || typeof receipt.revised_at !== "string" || receipt.revised_at.length === 0) { @@ -61,14 +91,27 @@ const revisionHistory = ( "Todo completion validation revision history is not canonical", ); } + let previousAuthority: JsonObject | undefined; + if (firstBinding) { + previousAuthority = canonicalAuthorityObject( + receipt.previous_validation_authority, "previous validation authority", + ); + if (Object.keys(previousAuthority).some(field => ![ + "completion_validation_required", "completion_validation_revision", + "completion_validation_revision_history", + ].includes(field))) { + throw new AuthorityStoreProtocolError("previous validation authority has unsupported fields"); + } + unboundValidationAuthority(previousAuthority); + } return { - schema_version: COMPLETION_VALIDATION_REVISION_RECEIPT_SCHEMA, + schema_version: receipt.schema_version, revision: Number(receipt.revision), operation_id: requireAuthorityStoreId( receipt.operation_id, "revision history operation id", ), - previous_declaration_sha256: digest( + previous_declaration_sha256: firstBinding ? null : digest( receipt.previous_declaration_sha256, "revision history previous declaration", ), @@ -81,10 +124,13 @@ const revisionHistory = ( "revision history actor_agent_id", ), revised_at: receipt.revised_at, + ...(previousAuthority === undefined ? {} : {previous_validation_authority: previousAuthority}), }; }); const last = history.at(-1); if (history.length !== priorRevision || + history.some((entry, index) => index > 0 && + entry.previous_declaration_sha256 !== history[index - 1]!.declaration_sha256) || (last !== undefined && last.declaration_sha256 !== currentDigest)) { throw new AuthorityStoreProtocolError( "Todo completion validation revision history does not match current state", @@ -108,7 +154,8 @@ export function decodeCompletionValidationRevision( ].includes(field), ); if (unexpected.length > 0 || - revision.schema_version !== COMPLETION_VALIDATION_REVISION_SCHEMA) { + (revision.schema_version !== COMPLETION_VALIDATION_REVISION_SCHEMA && + revision.schema_version !== COMPLETION_VALIDATION_BINDING_SCHEMA)) { throw new AuthorityStoreProtocolError( "completion validation revision has unsupported fields or schema", ); @@ -127,9 +174,13 @@ export function decodeCompletionValidationRevision( if (!declaration.ok) { throw new AuthorityStoreProtocolError(declaration.summary); } + const firstBinding = revision.schema_version === COMPLETION_VALIDATION_BINDING_SCHEMA; + if (firstBinding && revision.expected_declaration_sha256 !== null) { + throw new AuthorityStoreProtocolError("first binding must explicitly expect an absent declaration"); + } return { - schema_version: COMPLETION_VALIDATION_REVISION_SCHEMA, - expected_declaration_sha256: digest( + schema_version: firstBinding ? COMPLETION_VALIDATION_BINDING_SCHEMA : COMPLETION_VALIDATION_REVISION_SCHEMA, + expected_declaration_sha256: firstBinding ? null : digest( revision.expected_declaration_sha256, "expected_declaration_sha256", ), @@ -150,12 +201,17 @@ export function planCompletionValidationRevision(args: { "completion validation can be revised only while the Todo is open and active", ); } - if (args.todo.completion_validation_required !== true) { + const firstBinding = args.revision.schema_version === COMPLETION_VALIDATION_BINDING_SCHEMA; + if (firstBinding !== (args.revision.expected_declaration_sha256 === null)) { + throw new AuthorityStoreProtocolError("validator binding schema and absence witness must agree"); + } + const previousAuthority = firstBinding ? unboundValidationAuthority(args.todo) : undefined; + if (!firstBinding && args.todo.completion_validation_required !== true) { throw new AuthorityStoreProtocolError( "Todo has no completion validation declaration to revise", ); } - const previousDigest = digest( + const previousDigest = firstBinding ? null : digest( args.todo.completion_validation_sha256, "Todo completion_validation_sha256", ); @@ -186,23 +242,26 @@ export function planCompletionValidationRevision(args: { ); } const currentRevision = Number(priorRevision ?? 0); - const history = revisionHistory( + const history = completionValidationRevisionHistory( args.todo.completion_validation_revision_history, currentRevision, previousDigest, ); const revision = currentRevision + 1; const receipt = { - schema_version: COMPLETION_VALIDATION_REVISION_RECEIPT_SCHEMA, + schema_version: firstBinding ? COMPLETION_VALIDATION_BINDING_RECEIPT_SCHEMA : + COMPLETION_VALIDATION_REVISION_RECEIPT_SCHEMA, revision, operation_id: requireAuthorityStoreId(args.operation_id, "operation id"), previous_declaration_sha256: previousDigest, declaration_sha256: nextDigest, actor_agent_id: actor, revised_at: args.revised_at, + ...(previousAuthority === undefined ? {} : {previous_validation_authority: previousAuthority}), }; return { updates: { + completion_validation_required: true, completion_validation_sha256: nextDigest, completion_validation_revision: revision, completion_validation_revision_history: [...history, receipt], diff --git a/loopx/control_plane/todos/provider_update.py b/loopx/control_plane/todos/provider_update.py index b136513fbb..60a29ef0e3 100644 --- a/loopx/control_plane/todos/provider_update.py +++ b/loopx/control_plane/todos/provider_update.py @@ -72,14 +72,18 @@ def _completion_validation_revision_request( if ( isinstance(receipt, dict) and receipt.get("operation_id") == operation_id - and isinstance(receipt.get("previous_declaration_sha256"), str) + and "previous_declaration_sha256" in receipt ): expected_digest = receipt["previous_declaration_sha256"] break - if not isinstance(expected_digest, str) or len(expected_digest) != 64: - raise ValueError("Todo has no current completion validation digest to revise") + # Serialize canonical absence; the typed transaction alone decides whether + # this is a legal first binding or an inconsistent/missing declaration. return { - "schema_version": "loopx_todo_completion_validation_revision_v0", + "schema_version": ( + "loopx_todo_completion_validation_revision_v1" + if expected_digest is None + else "loopx_todo_completion_validation_revision_v0" + ), "expected_declaration_sha256": expected_digest, "declaration": declaration, } diff --git a/tests/control_plane/test_completion_validation_initial_binding.py b/tests/control_plane/test_completion_validation_initial_binding.py new file mode 100644 index 0000000000..de1c24d925 --- /dev/null +++ b/tests/control_plane/test_completion_validation_initial_binding.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest +from canonical_authority_fixture import isolate_sqlite_runtime, promoted_create_fixture + +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.todos import provider_update +from loopx.control_plane.todos.completion_validation_store import ( + completion_validation_declaration_path, + read_completion_validation_declaration, +) +from loopx.todos import add_goal_todo, complete_goal_todo, list_goal_todos, update_goal_todo + + +def canonical(runtime_root: Path) -> dict: + result = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id="goal-a") + assert result is not None + return result + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_public_cli_first_binding_replays_and_still_runs_real_validation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, +) -> None: + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, runtime, _ = promoted_create_fixture(tmp_path, provider=provider) + created = add_goal_todo(registry_path=registry, goal_id="goal-a", role="agent", + text="Check independently before completing", claimed_by="agent-a", agent_id="agent-a") + todo_id = created["todo_id"] + before = canonical(runtime) + assert "completion_validation_sha256" not in before["todos"][0] + # A marker proves binding/preview/replay never executes the validation command. + marker = tmp_path / "validator-executed" + argv = [sys.executable, "-c", f"from pathlib import Path; Path({str(marker)!r}).touch(); raise SystemExit(4)"] + command = [sys.executable, "-m", "loopx.cli", "--format", "json", "--registry", str(registry), + "--runtime-root", str(runtime), "todo", "update", "--goal-id", "goal-a", "--todo-id", todo_id, + "--agent-id", "agent-a", "--update-operation-id", "first-bind", + "--update-expected-provider-revision", before["provider_revision"], + "--validation-command-json", json.dumps(argv), "--validation-label", "Independent check"] + + def run(extra: list[str] | None = None) -> dict: + proc = subprocess.run(command + (extra or []), capture_output=True, text=True, timeout=45) + assert proc.returncode == 0, proc.stderr + proc.stdout + return json.loads(proc.stdout) + + assert run(["--dry-run"])["status"] == "planned" + assert canonical(runtime) == before + assert not marker.exists() + assert run()["status"] == "applied" + bound = canonical(runtime) + work = bound["todos"][0] + assert work["status"] == "open" + assert work["completion_validation_required"] is True + first = work["completion_validation_revision_history"][0] + assert first["schema_version"] == "loopx_todo_completion_validation_revision_receipt_v1" + assert first["previous_declaration_sha256"] is None + public_work = next(row for row in list_goal_todos(registry_path=registry, goal_id="goal-a")["todos"] + if row["todo_id"] == todo_id) + assert public_work["completion_validation_revision_history"] == work["completion_validation_revision_history"] + assert public_work["completion_validation_sha256"] == work["completion_validation_sha256"] + assert "validation_command_argv" not in public_work + assert "validation_command" not in public_work + assert read_completion_validation_declaration(runtime_root=runtime, goal_id="goal-a", todo_id=todo_id)["validation_command_argv"] == argv + assert run()["status"] == "replayed" + assert canonical(runtime) == bound + assert not marker.exists() + + failed = complete_goal_todo(registry_path=registry, runtime_root_arg=str(runtime), goal_id="goal-a", + todo_id=todo_id, role="agent", agent_id="agent-a", claimed_by="agent-a", no_followup=True) + assert failed["status"] == "failed" + assert failed["validation_blocked_completion"] is True + assert marker.exists() + assert canonical(runtime)["todos"][0]["status"] == "open" + current = canonical(runtime) + replacement = [sys.executable, "-c", "raise SystemExit(0)"] + assert update_goal_todo(registry_path=registry, runtime_root_arg=str(runtime), goal_id="goal-a", + todo_id=todo_id, role="agent", agent_id="agent-a", update_operation_id="replace", + update_expected_provider_revision=current["provider_revision"], + validation_command_json=json.dumps(replacement), validation_label="Independent check")["status"] == "applied" + replaced = canonical(runtime) + assert replaced["todos"][0]["completion_validation_revision"] == 2 + # The original first-binding replay is historical, not permission to restore old bytes. + replay = subprocess.run(command, capture_output=True, text=True, timeout=45) + assert replay.returncode != 0 + assert "publication_mismatch" in replay.stdout + replay.stderr + assert canonical(runtime) == replaced + completed = complete_goal_todo(registry_path=registry, runtime_root_arg=str(runtime), goal_id="goal-a", + todo_id=todo_id, role="agent", agent_id="agent-a", claimed_by="agent-a", no_followup=True) + assert completed["status"] == "done" + assert completed["validation_receipt"]["passed"] is True + assert completed["validation_receipt"]["validation_declaration_sha256"] == replaced["todos"][0]["completion_validation_sha256"] + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_first_binding_recovers_lost_private_publication_with_same_operation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, +) -> None: + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, runtime, _ = promoted_create_fixture(tmp_path, provider=provider) + created = add_goal_todo(registry_path=registry, goal_id="goal-a", role="agent", + text="Recover declaration publication", claimed_by="agent-a", agent_id="agent-a") + todo_id = created["todo_id"] + before = canonical(runtime) + args = dict(registry_path=registry, runtime_root_arg=str(runtime), goal_id="goal-a", todo_id=todo_id, + role="agent", agent_id="agent-a", update_operation_id="bind-recover", + update_expected_provider_revision=before["provider_revision"], + validation_command_json=json.dumps([sys.executable, "-c", "raise SystemExit(0)"])) + publish = provider_update.persist_completion_validation_declaration + + def lose_publication(**_kwargs): + raise OSError("Synthetic post-commit publication failure") + + monkeypatch.setattr(provider_update, "persist_completion_validation_declaration", lose_publication) + with pytest.raises(OSError, match="post-commit"): + update_goal_todo(**args) + committed = canonical(runtime) + assert committed["todos"][0]["completion_validation_revision"] == 1 + sidecar = completion_validation_declaration_path(runtime_root=runtime, goal_id="goal-a", todo_id=todo_id) + assert not sidecar.exists() + monkeypatch.setattr(provider_update, "persist_completion_validation_declaration", publish) + assert update_goal_todo(**args)["status"] == "replayed" + assert canonical(runtime) == committed + assert sidecar.exists() + assert read_completion_validation_declaration(runtime_root=runtime, goal_id="goal-a", todo_id=todo_id) is not None diff --git a/tests/control_plane_ts/authority_store_conformance.ts b/tests/control_plane_ts/authority_store_conformance.ts index d47e5b62b5..ddb2e5b65b 100644 --- a/tests/control_plane_ts/authority_store_conformance.ts +++ b/tests/control_plane_ts/authority_store_conformance.ts @@ -27,6 +27,7 @@ import test from "node:test"; import {registerCoordinationReceiptConformance} from "./coordination_receipt_conformance.ts"; import {registerAuthoritySourceConformance} from "./authority_source_conformance.ts"; import {registerNativePlanningUpdateConformance} from "./native_planning_update_conformance.ts"; +import {registerCompletionValidationBindingConformance} from "./completion_validation_binding_conformance.ts"; import type { AuthorityStore, @@ -292,6 +293,7 @@ export function registerAuthorityStoreConformance( registerSuccessionReadConformance(providerName, factory); registerTodoConsumerScopeConformance(providerName, factory); registerNativePlanningUpdateConformance(providerName, factory); + registerCompletionValidationBindingConformance(providerName, factory); registerUserCompletionUpdateConformance(providerName, factory); registerTerminalSourceConformance(providerName, factory); registerUserCompletionFollowthroughConformance(providerName, factory); diff --git a/tests/control_plane_ts/completion_validation_binding_conformance.ts b/tests/control_plane_ts/completion_validation_binding_conformance.ts new file mode 100644 index 0000000000..1066964cfc --- /dev/null +++ b/tests/control_plane_ts/completion_validation_binding_conformance.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type {AuthorityStore} from "../../loopx/control_plane/coordination/authority_store.ts"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {executeCoordinationTodoUpdate} from "../../loopx/control_plane/coordination/todo_update.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import type {AuthorityStoreConformanceFactory} from "./authority_store_conformance.ts"; +import {authorityProjectionFixture} from "./authority_projection_fixture.ts"; + +async function head(store: AuthorityStore) { + const loaded = await store.loadAuthority(); + assert.equal(loaded.status, "loaded"); + if (loaded.status !== "loaded") throw new Error("binding fixture missing"); + return loaded; +} + +export function registerCompletionValidationBindingConformance(provider: string, factory: AuthorityStoreConformanceFactory) { + for (const schema of ["legacy", "native"] as const) { + test(`${provider}: first validator binding on ${schema} work preserves fencing and durable replay`, async t => { + const {store, contender} = await factory(t); + const goal = "validator-binding"; + const work = {todo_id: "todo_target", role: "agent", status: "open", done: false, + archive_state: "active", text: "Synthetic independently checked work", claimed_by: "agent-a"}; + const leases = [{todo_id: work.todo_id, owner: "agent-a", status: "active", version: 3, + lease_epoch: 2, idempotency_key: "execute", expires_at: "2026-09-28T00:00:00Z", write_scopes: []}]; + await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + next_projection: authorityProjectionFixture(goal, [work], leases, schema, {handoff_mode: "hard_lease"}), + events: [], receipts: []}); + const before = await head(store); + const declaration = {validation_command: null, validation_command_argv: ["false"], + validation_label: "Independent check", validation_timeout_seconds: 5}; + const request = {goal_id: goal, todo_id: work.todo_id, expected_role: "agent", actor_agent_id: "agent-a", + registered_agents: ["agent-a", "agent-b"], operation_id: "bind", expected_provider_revision: before.provider_revision, + patch: {}, clear_fields: [], lease_idempotency_key: "execute", lease_expected_version: 3, + completion_validation_revision: {schema_version: "loopx_todo_completion_validation_revision_v1" as const, + expected_declaration_sha256: null, declaration}, dry_run: false, now: new Date("2026-09-27T00:00:00Z")}; + for (const patch of [{actor_agent_id: "agent-b"}, {lease_expected_version: 2}, + {lease_idempotency_key: "wrong"}, {now: new Date("2026-09-29T00:00:00Z")}]) { + assert.equal((await executeCoordinationTodoUpdate(store, {...request, ...patch})).status, "failed"); + assert.deepEqual(await head(store), before); + assert.equal((await store.readReceipt("bind")).status, "missing"); + } + assert.equal((await executeCoordinationTodoUpdate(store, {...request, dry_run: true})).status, "planned"); + assert.deepEqual(await head(store), before); + assert.equal((await executeCoordinationTodoUpdate(store, request)).status, "applied"); + const bound = await head(store); + const target = (bound.head.todos as JsonObject[])[0]!; + assert.equal(target.completion_validation_sha256, canonicalAuthoritySha256(declaration)); + assert.equal(target.status, "open"); + assert.deepEqual(bound.head.leases, leases); + assert.equal((target.completion_validation_revision_history as JsonObject[])[0]!.previous_declaration_sha256, null); + const immutable = await store.readReceipt("bind"); + assert.equal((await executeCoordinationTodoUpdate(contender, {...request, operation_id: "stale-bind"})).reason_code, + "provider_revision_mismatch"); + assert.equal((await executeCoordinationTodoUpdate(contender, {...request, operation_id: "already-bound", + expected_provider_revision: bound.provider_revision})).status, "failed"); + const replacement = {...declaration, validation_command_argv: ["true"]}; + assert.equal((await executeCoordinationTodoUpdate(contender, {...request, operation_id: "replace", + expected_provider_revision: bound.provider_revision, + completion_validation_revision: {schema_version: "loopx_todo_completion_validation_revision_v0", + expected_declaration_sha256: canonicalAuthoritySha256(declaration), declaration: replacement}})).status, "applied"); + const latest = await head(store); + assert.equal((await executeCoordinationTodoUpdate(store, request)).status, "replayed"); + assert.deepEqual(await head(store), latest, "old replay must not restore a subsequently replaced validator"); + assert.deepEqual(await store.readReceipt("bind"), immutable); + assert.equal((await executeCoordinationTodoUpdate(store, {...request, + completion_validation_revision: {...request.completion_validation_revision, declaration: replacement}})).reason_code, + "coordination_operation_identity_mismatch"); + }); + } +} diff --git a/tests/control_plane_ts/goal_acceptance_authority.test.ts b/tests/control_plane_ts/goal_acceptance_authority.test.ts index 09343f6dcb..4ca3fafed6 100644 --- a/tests/control_plane_ts/goal_acceptance_authority.test.ts +++ b/tests/control_plane_ts/goal_acceptance_authority.test.ts @@ -22,6 +22,8 @@ import {acceptanceCompletionRequirements, acceptanceWorkGuard, goalAcceptanceTod normalizeGoalAcceptanceDocument, projectGoalAcceptance, readGoalAcceptance, validateAcceptanceCompletion} from "../../loopx/control_plane/goals/acceptance_contract.ts"; import {commitGoalAcceptanceVerification, commitLocalGoalAcceptance, commitLocalGoalAcceptanceVerification, configureGoalAcceptance, inspectGoalAcceptance, inspectLocalGoalAcceptance} from "../../loopx/control_plane/goals/acceptance_authority.ts"; +import {decodeCompletionValidationRevision, planCompletionValidationRevision} + from "../../loopx/control_plane/todos/completion_validation_revision.ts"; const goal = "goal-acceptance-test"; test("documented owner configuration satisfies the canonical acceptance contract", async () => { @@ -91,6 +93,50 @@ test("validator revisions and successor links preserve an existing acceptance bi {goal_acceptance: boundWithWait}), goal, "todo_first"); assert.equal(changedWait?.state, "stale", "the matcher cannot reconstruct a replaced prior wait condition"); }); +test("first validator binding preserves only the proven original work and owner association", () => { + for (const defaults of [{}, {completion_validation_required: false, + completion_validation_revision: 0, completion_validation_revision_history: []}]) { + const original = todo("todo_first", defaults); + const contract = normalizeGoalAcceptanceDocument({...document(), + bindings: [{todo_id: "todo_first", criterion_ids: ["prerequisite"]}]}); + const state = {schema_version: "loopx_goal_acceptance_v0", enabled: true, revision: 1, + digest: canonicalAuthoritySha256(contract), document: contract, verification: null, + bindings: [{todo_id: "todo_first", todo_semantic_digest: goalAcceptanceTodoDigest(original), + revision: 1, criterion_ids: ["prerequisite"], confirmed_by: "owner"}]}; + const guarded = (work: JsonObject) => acceptanceWorkGuard(authorityProjectionFixture(goal, + [work], [], "native", {goal_acceptance: state}), goal, "todo_first"); + const declaration = {validation_command: null, validation_command_argv: [process.execPath, "-e", "process.exit(1)"], + validation_label: "Independent validation", validation_timeout_seconds: 5}; + const bind = planCompletionValidationRevision({todo: original, actor_agent_id: "agent-a", + operation_id: "first-bind", revised_at: "2026-09-27T00:00:00Z", + revision: decodeCompletionValidationRevision({schema_version: "loopx_todo_completion_validation_revision_v1", + expected_declaration_sha256: null, declaration})}); + const bound = {...original, ...bind.updates}; + assert.equal(guarded(bound)?.state, "ready"); + assert.notEqual(goalAcceptanceTodoDigest(bound), goalAcceptanceTodoDigest(original)); + const replace = planCompletionValidationRevision({todo: bound, actor_agent_id: "agent-a", + operation_id: "replace", revised_at: "2026-09-27T00:01:00Z", + revision: decodeCompletionValidationRevision({schema_version: "loopx_todo_completion_validation_revision_v0", + expected_declaration_sha256: bound.completion_validation_sha256, + declaration: {...declaration, validation_command_argv: [process.execPath, "-e", "process.exit(0)"]}})}); + const revised = {...bound, ...replace.updates}; + assert.equal(guarded(revised)?.state, "ready"); + for (const patch of [{text: "Changed work"}, {required_write_scopes: ["private/**"]}, + {completion_validation_required: false}, + {completion_validation_revision_history: [{...bind.receipt, previous_validation_authority: {completion_validation_required: true}}]}, + {completion_validation_revision_history: [{...bind.receipt, previous_validation_authority: null}]}, + {completion_validation_revision_history: [{...bind.receipt, declaration_sha256: "a".repeat(64)}]}]) { + assert.equal(guarded({...bound, ...patch})?.state, "stale"); + } + assert.equal(guarded({...revised, completion_validation_revision_history: [bind.receipt, + {...replace.receipt, previous_declaration_sha256: "b".repeat(64)}]})?.state, "stale"); + const unboundState = {...state, bindings: [], document: {...contract, bindings: []}}; + unboundState.digest = canonicalAuthoritySha256(unboundState.document); + assert.equal(acceptanceWorkGuard(authorityProjectionFixture(goal, [bound], [], "native", + {goal_acceptance: unboundState}), goal, "todo_first")?.state, "unbound", + "a local validator cannot create an owner acceptance binding"); + } +}); async function seed(store: AuthorityStore) { assert.equal((await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, events: [], receipts: [], next_projection: originalHead()})).status, "applied"); diff --git a/tests/control_plane_ts/todo_update.test.ts b/tests/control_plane_ts/todo_update.test.ts index 1fb10222e6..e41d67f687 100644 --- a/tests/control_plane_ts/todo_update.test.ts +++ b/tests/control_plane_ts/todo_update.test.ts @@ -251,6 +251,87 @@ async function seeded(overrides: Record = {}) { return {store, request}; } +test("first validator binding requires explicit absence, current CAS and active lease", async () => { + const {store, request} = await seeded(); + const loaded = await store.loadAuthority(); + assert.equal(loaded.status, "loaded"); + if (loaded.status !== "loaded") return; + await store.commitAuthority({operation_id: "lease-before-binding", + expected_provider_revision: loaded.provider_revision, events: [], receipts: [], + next_projection: {...loaded.head, handoff_mode: "hard_lease", leases: [{ + todo_id: request.todo_id, owner: "agent-a", status: "active", + expires_at: "2026-09-06T00:00:00Z", idempotency_key: "binding-execution", + version: 1, lease_epoch: 1, write_scopes: [], + }]}}); + const before = await store.loadAuthority(); + assert.equal(before.status, "loaded"); + if (before.status !== "loaded") return; + const declaration = {validation_command: null, validation_command_argv: ["false"], + validation_label: "independent check", validation_timeout_seconds: 20}; + const edit = {...request, patch: {}, clear_fields: [], operation_id: "bind-first", + expected_provider_revision: before.provider_revision, + lease_idempotency_key: "binding-execution", lease_expected_version: 1, + completion_validation_revision: {schema_version: "loopx_todo_completion_validation_revision_v1", + expected_declaration_sha256: null, declaration}} as const; + for (const overrides of [{actor_agent_id: "agent-b"}, {lease_expected_version: 0}, + {expected_provider_revision: loaded.provider_revision}]) { + assert.equal((await executeCoordinationTodoUpdate(store, {...edit, ...overrides})).status, "failed"); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal((await store.readReceipt(edit.operation_id)).status, "missing"); + } + assert.equal((await executeCoordinationTodoUpdate(store, {...edit, dry_run: true})).status, "planned"); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal((await executeCoordinationTodoUpdate(store, edit)).status, "applied"); + assert.equal((await executeCoordinationTodoUpdate(store, edit)).status, "replayed"); + const after = await store.loadAuthority(); + assert.equal(after.status, "loaded"); + if (after.status !== "loaded") return; + const bound = (after.head.todos as Record[])[0]!; + assert.equal(bound.completion_validation_required, true); + assert.equal(bound.completion_validation_sha256, canonicalAuthoritySha256(declaration)); + assert.equal(bound.status, "open", "binding must not complete or execute work"); + assert.deepEqual(after.head.leases, before.head.leases); + const receipt = (bound.completion_validation_revision_history as Record[])[0]!; + assert.equal(receipt.previous_declaration_sha256, null); + assert.deepEqual(receipt.previous_validation_authority, {}); + assert.equal((await executeCoordinationTodoUpdate(store, {...edit, + completion_validation_revision: {...edit.completion_validation_revision, + declaration: {...declaration, validation_command_argv: ["true"]}}})).reason_code, + "coordination_operation_identity_mismatch"); + assert.equal((await executeCoordinationTodoUpdate(store, {...edit, operation_id: "second-first-bind", + expected_provider_revision: after.provider_revision})).status, "failed"); +}); + +test("first binding rejects malformed absence and incompatible history without partial writes", async () => { + const declaration = {validation_command: null, validation_command_argv: ["true"], + validation_label: "independent check", validation_timeout_seconds: 5}; + for (const metadata of [ + {completion_validation_required: true}, {completion_validation_sha256: null}, + {completion_validation_revision: 1}, {completion_validation_revision_history: [{}]}, + {status: "done", done: true}, {archive_state: "archived"}, + ]) { + const {store, request} = await seeded(metadata); + const before = await store.loadAuthority(); + const result = await executeCoordinationTodoUpdate(store, {...request, patch: {}, clear_fields: [], + completion_validation_revision: {schema_version: "loopx_todo_completion_validation_revision_v1", + expected_declaration_sha256: null, declaration}}); + assert.equal(result.status, "failed", JSON.stringify(result)); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal((await store.readReceipt(request.operation_id)).status, "missing"); + } + for (const revision of [ + {schema_version: "loopx_todo_completion_validation_revision_v0", expected_declaration_sha256: null, declaration}, + {schema_version: "loopx_todo_completion_validation_revision_v1", expected_declaration_sha256: "a".repeat(64), declaration}, + {schema_version: "loopx_todo_completion_validation_revision_v1", declaration}, + {schema_version: ["loopx_todo_completion_validation_revision_v0"], expected_declaration_sha256: "a".repeat(64), declaration}, + ]) { + const result = await updateLocalCoordinationTodo({schema_version: "loopx_local_coordination_todo_update_request_v5", + completion_validation_revision: revision}, + {createStore: () => {throw new Error("invalid absence must not open a provider");}}); + assert.equal(result.status, "failed"); + } +}); + test("open Todo revises its validator with CAS, audit history, and idempotent replay", async () => { const original = { validation_command: null,