Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/presentation/dashboard/src/data/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
50 changes: 50 additions & 0 deletions docs/project-agent-todo-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <goal-id> --todo-id <todo_id> --agent-id <registered-agent> \
--update-operation-id <stable-binding-operation-id> \
--update-expected-provider-revision <read-provider-revision> \
--task-lease-idempotency-key <current-lease-key> \
--task-lease-expected-version <current-lease-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:

Expand Down
36 changes: 22 additions & 14 deletions loopx/control_plane/goals/acceptance_contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
89 changes: 74 additions & 15 deletions loopx/control_plane/todos/completion_validation_revision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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)) {
Expand All @@ -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",
Expand All @@ -50,25 +78,40 @@ 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) {
throw new AuthorityStoreProtocolError(
"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",
),
Expand All @@ -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",
Expand All @@ -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",
);
Expand All @@ -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",
),
Expand All @@ -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",
);
Expand Down Expand Up @@ -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],
Expand Down
12 changes: 8 additions & 4 deletions loopx/control_plane/todos/provider_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
Loading
Loading