diff --git a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md index 9ae9c6ff28..227a6545c2 100644 --- a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md +++ b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md @@ -349,12 +349,25 @@ Todo readers retain their existing authority and scope. A selected-work acceptance document cannot replace whole-Goal intent. Shared host adapters preserve selected context. Default heartbeat generation → guard → source readback must be tested alongside envelope transport; one route cannot qualify -the other. This does not -implement the full requirement ledger, a global closeout gate, automatic proof -of required-read execution, or model adherence. Those remain with R1/R4/R5 and -S11; this RFC does not silently enable a new acceptance policy. The next complete -slice should map existing structured criteria through a work switch and restart, -reject dropped coverage, and invalidate stale evidence under the same owner. +the other. + +For an enabled Goal-wide acceptance contract, the existing TS owner now retains +a canonical verification gap when active advancement work is exhausted but full +current verification has not passed. The gap keeps every configured criterion +ID and its current contract/work basis. Quota selects replan; TurnEnvelope retains +the contract and replan packet. A Todo-bound Host still blocks an empty selection; +the gap grants no new execution authority. Neither a vision ACK nor local `no_followup` can clear it or trigger +terminal automation shutdown. Full current verification clears this necessary +condition. Freshness uses the existing contract/work bindings, without a new +artifact freshness oracle. Contract revisions invalidate old success; historical receipts remain +unchanged. Explicit `selected_work` contracts keep their local scope, and +disabled/absent governance keeps its previous behavior. + +This does not implement the full requirement ledger, a complete Goal-lifecycle +closeout gate, automatic proof of required-read execution, or model adherence. +Those remain with R1/R4/R5 and S11. Coverage is limited to the owner's declared +contract; it does not prove that all natural-language requirements were captured +or that a model follows them over a long run. ### 3.8 Invalid evidence and affected consumers diff --git a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md index 2981fd1d28..ff60505f28 100644 --- a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md @@ -293,10 +293,20 @@ canonical acceptance 与当前 Todo 精确详情读取。普通产品 heartbeat 消费同一清单;短包只传输并签名,不另行制定读取规则。Goal 文档恢复完整意图和停止 条件,canonical acceptance 与 Todo reader 保留各自权威和范围;selected-work 验收 文档不能替代完整目标。共享 host adapter 保留选中任务。须一起验证默认 heartbeat -生成→guard→来源回读与短包传输,不能用一条路径替另一条验收。尚未 -实现完整义务账本、全局收口门禁、读取执行的自动证明或模型遵循资格。这些继续归 -R1/R4/R5 与 S11,本 RFC 不隐式开启新的 acceptance policy。下一完整切片应让既有 -结构化 criterion 穿过任务切换和重启,在同一 owner 拒绝覆盖丢失并使过期证据失效。 +生成→guard→来源回读与短包传输,不能用一条路径替另一条验收。 + +已启用 Goal 全局验收合同时,若当前推进工作已耗尽、但当前完整合同验证尚未通过, +既有 TS owner 现在保留 canonical verification gap。缺口保留全部已配置 criterion ID +及当前合同/工作基线。quota 选择重规划,TurnEnvelope 保留合同与重规划包。 +依赖 Todo 的 Host 仍阻止空任务选择;缺口不授予新执行权威。vision ACK 或局部 +`no_followup` 均不能消除此缺口,也不能据此永久停止自动化。当前完整验证通过后, +这项必要条件才解除。时效性沿用既有合同/工作绑定,不新增产物时效判定器。 +合同修订使旧成功过期,历史回执保持原样。显式 `selected_work` +合同保持局部范围,停用或未配置验收时保持原行为。 + +这尚未实现完整义务账本、完整 Goal 生命周期收口门禁、读取执行的自动证明或模型 +遵循资格。这些继续归 R1/R4/R5 与 S11。覆盖范围仅限所有者声明的合同,不能证明 +自然语言要求已被完整提取,也不能证明模型在长期运行中始终遵循它们。 ### 3.8 失效证据与受影响消费者 diff --git a/docs/reference/goal-acceptance-observations.md b/docs/reference/goal-acceptance-observations.md index e0c9d83730..b390f53031 100644 --- a/docs/reference/goal-acceptance-observations.md +++ b/docs/reference/goal-acceptance-observations.md @@ -254,6 +254,30 @@ The section exists only when the server's contracts retain the baseline UI and export. The existing snapshot export and status Markdown include the enabled readback without command bodies or raw logs. +When an enabled Goal-wide contract has no active advancement work left, an +`unverified`, `partial`, `failed` or `stale` result remains a verification gap. +Quota and Turn planning retain all configured criterion IDs and require replan; +local Todo completion, `no_followup` and a vision ACK cannot replace the missing +verification or derive `terminal_no_followup`. Inspect the contract and run the +checks above from its delivery workspace. Repair unmet work or record a concrete +blocker when necessary. Full current verification clears the gap; a later +contract or work revision can reopen it. Reading or polling never runs checks +automatically. `selected_work` contracts keep their local coverage, and absent +or disabled governance retains its previous behavior. This is a necessary +configured-contract check, not proof of whole-Goal success or model adherence. +The gap fingerprint binds Goal/instance, contract/work basis and verification +facts. Repeating identical results under a new operation ID is not new progress. + +已启用 Goal 全局验收合同、且当前推进工作耗尽时,未验证、局部通过、失败或过期结果 +仍保留验收缺口。quota 与 Turn planning 保留全部 criterion ID 并要求重规划;局部 +Todo 完成、`no_followup` 或 vision ACK 不能代替验证,也不能推出 `terminal_no_followup`。 +应从交付工作区 inspect 并运行上面的检查;必要时修复未满足的工作或记录具体阻塞。 +当前完整验证通过后才解除缺口,合同或工作修订可使缺口重开。读取或轮询不会自动执行 +检查。`selected_work` 仍只管局部工作,停用/未配置时保持原行为。这项必要检查不证明 +整个 Goal 已成功,也不证明模型长期不偏离目标。 +缺口指纹绑定 Goal/实例、合同/工作基线与验证事实;仅更换 operation ID 重复相同 +结果不算新进展。 + | Server state | Readback meaning | | --- | --- | | Task `ready` | Owner confirmed the current task association; artifact checks are separate | diff --git a/loopx/control_plane/goals/acceptance_contract.ts b/loopx/control_plane/goals/acceptance_contract.ts index 0ece3e8d36..bd290053e4 100644 --- a/loopx/control_plane/goals/acceptance_contract.ts +++ b/loopx/control_plane/goals/acceptance_contract.ts @@ -482,10 +482,31 @@ export function projectGoalAcceptance(head: JsonObject, goalId: string): JsonObj else status = "accepted"; } if (held.length) status = "held"; + // Empty work is not evidence for the remaining Goal-wide criteria. A local + // contract still governs only its declared work, never unrelated closeout. + const verificationGap = state.document.scope?.kind !== "selected_work" && status !== "accepted" && + ![...todos.values()].some(acceptanceApplies) ? { + kind: "vision_acceptance_gap", source: "goal_acceptance_contract", + canonical_verification_required: true, + criterion_ids: state.document.criteria.map(criterion => criterion.id), + frontier_revision: canonicalAuthoritySha256({goal_id: goalId, + goal_ref: authority.kind === "exact" ? authority.lifecycle.goal_ref : null, + revision: state.revision, digest: state.digest, + work_digest: goalAcceptanceWorkDigest(head, goalId, state.document.scope), status, + verification: receipt === null ? null : {contract_revision: receipt.contract_revision, + contract_digest: receipt.contract_digest, work_digest: receipt.work_digest, + todo_id: receipt.todo_id, results: receipt.results}}), + replan_trigger_summary: `Goal-wide acceptance remains ${status} after the advancement frontier closed.`, + acceptance_summary: "Retain every declared Goal criterion until current full-contract verification passes.", + resolution_hint: "Inspect the current Goal acceptance contract and run its configured checks with " + + "goal-acceptance verify from the delivery workspace. Repair unmet work or record a concrete blocker " + + "when needed. A vision or no-followup note cannot replace canonical verification.", + } : null; return {...exactProjection, ...(state.document.scope === undefined ? {} : {scope: state.document.scope}), enabled: true, revision: state.revision, digest: state.digest, objective: state.document.objective, non_goals: state.document.non_goals, criteria: state.document.criteria.map(({id, description}) => ({id, description})), tasks, held_todo_ids: held.map(task => task.todo_id), status, + ...(verificationGap === null ? {} : {verification_gap: verificationGap}), verification: receipt ? {operation_id: receipt.operation_id, contract_revision: receipt.contract_revision, contract_digest: receipt.contract_digest, todo_id: receipt.todo_id, results: receipt.results} : null}; } diff --git a/loopx/control_plane/goals/goal_frontier/__init__.py b/loopx/control_plane/goals/goal_frontier/__init__.py index f0e60dedf3..dc3437993f 100644 --- a/loopx/control_plane/goals/goal_frontier/__init__.py +++ b/loopx/control_plane/goals/goal_frontier/__init__.py @@ -1195,6 +1195,9 @@ def derive_goal_frontier_replan_obligation_from_summaries( }, ) if replan_rule.rule is GoalFrontierReplanRule.VISION_ACCEPTANCE_GAP: + verification_required = any( + gap.get("canonical_verification_required") is True for gap in compact_acceptance_gaps + ) acceptance_held = any( gap.get("kind") in GOAL_ACCEPTANCE_HOLD_TRIGGERS for gap in compact_acceptance_gaps ) @@ -1226,6 +1229,8 @@ def derive_goal_frontier_replan_obligation_from_summaries( "completed_todo_ids", "vision_todo_ids", "frontier_revision", + "criterion_ids", + "canonical_verification_required", "reason_code", "component_checks", "resolution_hint", @@ -1236,13 +1241,15 @@ def derive_goal_frontier_replan_obligation_from_summaries( for gap in compact_acceptance_gaps[:3] ], guidance_actions=( + ["use_replan_context", "create_successor", "record_evidence_gap"] + if verification_required else ["inspect_acceptance_binding", "select_eligible_successor", "record_evidence_gap"] if acceptance_held else ["create_successor", "update_agent_vision", "record_evidence_gap", "record_no_followup"] ), # Another unbound advancement Todo cannot repair this admission # hold. Keep authoring suggestions aligned with the typed exits. - todo_actions=[] if acceptance_held else [ + todo_actions=[] if acceptance_held or verification_required else [ { "action": "add", "role": "agent", diff --git a/loopx/control_plane/goals/goal_frontier/acceptance.py b/loopx/control_plane/goals/goal_frontier/acceptance.py index e636109973..98b15b2239 100644 --- a/loopx/control_plane/goals/goal_frontier/acceptance.py +++ b/loopx/control_plane/goals/goal_frontier/acceptance.py @@ -20,18 +20,23 @@ def acceptance_gaps_from_held_goal_binding( *, agent_id: str | None, ) -> list[dict[str, Any]]: - """Route missing or stale acceptance through this agent's replan lane. + """Retain native verification gaps and route scoped association holds. This is a read-only trigger, not an acceptance rebind. Other runnable work remains selectable; the frontier rule schedules a replan only when no advancement Todo can be selected for this agent. """ - if not agent_id or not isinstance(agent_todo_summary, dict): + if not isinstance(agent_todo_summary, dict): return [] contract = agent_todo_summary.get("goal_acceptance_contract") if not isinstance(contract, dict) or contract.get("enabled") is not True: return [] + verification_gap = contract.get("verification_gap") + gaps = ([{**verification_gap, "agent_id": agent_id}] + if isinstance(verification_gap, dict) else []) + if not agent_id: + return gaps held_states = { task.get("todo_id"): task.get("state") for task in contract.get("tasks", []) @@ -39,7 +44,6 @@ def acceptance_gaps_from_held_goal_binding( and task.get("state") in {"stale", "unbound"} and task.get("applicable") is True } - gaps: list[dict[str, Any]] = [] for item in source_items or []: if ( not isinstance(item, dict) diff --git a/loopx/control_plane/goals/goal_frontier/terminal.py b/loopx/control_plane/goals/goal_frontier/terminal.py index 76d716881a..41150a8f1b 100644 --- a/loopx/control_plane/goals/goal_frontier/terminal.py +++ b/loopx/control_plane/goals/goal_frontier/terminal.py @@ -112,9 +112,17 @@ def _terminal_todo_source_contract( "user_todos": user_status, "agent_todos": agent_status, } + contract = ( + agent_todo_summary.get("goal_acceptance_contract") + if isinstance(agent_todo_summary, dict) else None + ) + verification_gap = ( + contract.get("verification_gap") if isinstance(contract, dict) else None + ) return ( source_completeness, - user_status == agent_status == "valid" and (user_intent or agent_intent), + user_status == agent_status == "valid" and (user_intent or agent_intent) + and not isinstance(verification_gap, dict), ) diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 26ff4c4d67..98a3f869d1 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -328,7 +328,10 @@ function acknowledgeVisionEvidence(request: JsonObject): JsonObject { return timestamp(gap.generated_at ?? (gap.kind === "vision_outcome_checkpoint_required" && gap.source === "recent_completed_advancement_todo" ? gap.completed_at : null)); }); - return {acknowledged: ack.recorded === true && delta.accepted === true && + // Only the acceptance owner can clear its current verification gap. + const verificationRequired = request.acceptance_gaps.some(value => + object(value).canonical_verification_required === true); + return {acknowledged: !verificationRequired && ack.recorded === true && delta.accepted === true && strings(delta.outcomes).some(outcome => VISION_OUTCOMES.some(known => known === outcome)) && acknowledgedAt !== null && gaps.length > 0 && gaps.every(time => time !== null && time <= acknowledgedAt)}; diff --git a/tests/control_plane/test_goal_acceptance_closeout.py b/tests/control_plane/test_goal_acceptance_closeout.py new file mode 100644 index 0000000000..9d184099ed --- /dev/null +++ b/tests/control_plane/test_goal_acceptance_closeout.py @@ -0,0 +1,137 @@ +"""An exhausted Todo frontier cannot erase declared Goal acceptance.""" +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +from loopx.control_plane import effect_runtime +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.todos.active_state_todo_parser import parse_active_state_todos +from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority +from tests.test_loopx_turn_driver import _write_live_fixture + +GOAL = "loopx-turn-fixture" +REPO = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def owned_runtime(tmp_path, monkeypatch): + for name in ("TMPDIR", "TEMP", "TMP"): + monkeypatch.setenv(name, str(tmp_path)) + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + yield + assert effect_runtime._runtime_dir().resolve().is_relative_to(tmp_path.resolve()) + effect_runtime.restart_effect_runtime(wait_for_exit=True) + + +def _invoke(argv): + result = subprocess.run([sys.executable, "-m", "loopx.entrypoint", *argv], + cwd=REPO, capture_output=True, text=True, timeout=60) + assert not result.stderr, result.stderr + return result.returncode, json.loads(result.stdout) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_goal_criteria_survive_closed_work_restart_and_revised_acceptance(tmp_path, owned_runtime, provider): + project, runtime, registry = _write_live_fixture(tmp_path) + registered = json.loads(registry.read_text()) + registered["goals"][0]["coordination"]["registered_agents"].append("peer-fixture") + registry.write_text(json.dumps(registered)) + state = project / ".codex/goals" / GOAL / "ACTIVE_GOAL_STATE.md" + todos = parse_active_state_todos(state.read_text(), item_limit=None)["agent_todos"]["items"] + initialize_canonical_authority(runtime, GOAL, + build_todo_runtime_shadow_projection(goal_id=GOAL, todos=todos, handoff_mode="soft_claim"), + state_path=state, provider=provider) + common = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json"] + acceptance = [*common, "goal-acceptance"] + inspect = [*acceptance, "inspect", "--goal-id", GOAL] + quota = [*common, "quota", "should-run", "--goal-id", GOAL, "--agent-id", "codex-fixture"] + verify = [*acceptance, "verify", "--goal-id", GOAL, "--agent-id", "codex-fixture", "--execute"] + document = { + "scope": {"kind": "all_advancement"}, "objective": "Deliver both declared outcomes", "non_goals": [], + "criteria": [ + {"id": "local-artifact", "description": "Local artifact check", "validation_argv": [sys.executable, "-c", "pass"]}, + {"id": "remaining-outcome", "description": "Retain the second required outcome", + "validation_argv": [sys.executable, "-c", "from pathlib import Path; assert Path('required.txt').read_text() == 'accepted'"]}, + ], + "bindings": [{"todo_id": "todo_fixture0001", "criterion_ids": ["local-artifact"]}], + } + path = tmp_path / "acceptance.json" + path.write_text(json.dumps(document)) + code, basis = _invoke(inspect) + assert code == 0, basis + code, configured = _invoke([*acceptance, "configure", "--goal-id", GOAL, "--document", str(path), + "--expected-provider-revision", basis["provider_revision"], "--execute"]) + assert code == 0, configured + assert "verification_gap" not in configured["goal_acceptance_contract"] + code, completed = _invoke([*common, "todo", "complete", "--goal-id", GOAL, + "--todo-id", "todo_fixture0001", "--agent-id", "codex-fixture", "--no-follow-up", + "--evidence", "Local criterion validated; separate Goal criteria remain"]) + assert code == 0 and completed["changed"] is True, completed + assert completed["goal_acceptance_completion"]["results"] == [ + {"criterion_id": "local-artifact", "exit_code": 0, "passed": True}, + ] + + def requires_verification(expected_status): + code, observed = _invoke(inspect) + assert code == 0, observed + contract = observed["goal_acceptance_contract"] + assert contract["status"] == expected_status + assert [criterion["id"] for criterion in contract["criteria"]] == ["local-artifact", "remaining-outcome"] + code, decision = _invoke(quota) + assert code == 0, decision + assert decision["should_run"] is True + assert decision["effective_action"] == "autonomous_replan_required" + assert "terminal_state" not in decision["goal_frontier_projection"] + gap = next(gap for gap in decision["goal_frontier_projection"]["acceptance_gaps"] + if gap.get("canonical_verification_required")) + assert gap["criterion_ids"] == ["local-artifact", "remaining-outcome"] + assert decision["autonomous_replan_obligation"]["todo_actions"] == [] + return gap["frontier_revision"] + + original_gap = requires_verification("unverified") + assert effect_runtime._runtime_dir().resolve().is_relative_to(tmp_path.resolve()) + effect_runtime.restart_effect_runtime(wait_for_exit=True) + assert requires_verification("unverified") == original_gap + code, peer = _invoke([*quota[:-1], "peer-fixture"]) + assert code == 0 and peer["effective_action"] == "autonomous_replan_required", peer + assert peer["goal_frontier_projection"]["acceptance_gaps"][0]["criterion_ids"] == ["local-artifact", "remaining-outcome"] + code, plan = _invoke([*common, "turn", "plan", "--goal-id", GOAL, "--agent-id", "peer-fixture", + "--host", "generic-cli", "--execution-mode", "isolated-headless", + "--scan-root", str(project), "--include-transaction-detail"]) + assert code == 0, plan + assert plan["turn_envelope"]["effective_action"] == "autonomous_replan_required" + source = next(source for source in plan["turn_envelope"]["work_context"]["sources"] + if source["source"] == "goal_acceptance") + assert [criterion["id"] for criterion in source["content"]["contract"]["criteria"]] == ["local-artifact", "remaining-outcome"] + # The existing Todo-bound Host remains blocked without a selected Todo; + # retaining a Goal obligation grants no new execution authority. + assert plan["route"]["would_invoke_host"] is False + code, failed = _invoke(verify) + assert code == 1 and failed.get("goal_acceptance_contract", {}).get("status") == "failed", failed + assert requires_verification("failed") != original_gap + + (project / "required.txt").write_text("accepted") + code, passed = _invoke(verify) + assert code == 0 and passed["goal_acceptance_contract"]["status"] == "accepted", passed + code, finished = _invoke(quota) + assert code == 0 and finished["effective_action"] == "terminal_no_followup", finished + + # An authorized contract revision invalidates the prior success; the same + # criterion IDs survive, and the old receipt is retained as stale evidence. + document["criteria"][1]["description"] = "Recheck the retained second outcome" + path.write_text(json.dumps(document)) + code, current = _invoke(inspect) + assert code == 0, current + code, revised = _invoke([*acceptance, "configure", "--goal-id", GOAL, "--document", str(path), + "--expected-provider-revision", current["provider_revision"], "--execute"]) + assert code == 0 and revised["goal_acceptance_contract"]["revision"] == 2, revised + requires_verification("stale") + code, reverified = _invoke(verify) + assert code == 0 and reverified["goal_acceptance_contract"]["status"] == "accepted", reverified + assert _invoke(quota)[1]["effective_action"] == "terminal_no_followup" diff --git a/tests/control_plane_ts/goal_acceptance_closeout.test.ts b/tests/control_plane_ts/goal_acceptance_closeout.test.ts new file mode 100644 index 0000000000..4838fcac6e --- /dev/null +++ b/tests/control_plane_ts/goal_acceptance_closeout.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {coordinationTodoReadModel} from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import {acceptanceWorkGuard, goalAcceptanceTodoDigest, goalAcceptanceWorkDigest, + normalizeGoalAcceptanceDocument, projectGoalAcceptance} from "../../loopx/control_plane/goals/acceptance_contract.ts"; +import {projectReplanSemantics} from "../../loopx/control_plane/work_items/replan_semantics.ts"; + +function source(verification: "unverified" | "partial" | "failed" | "stale" | "accepted", + scope: "all_advancement" | "selected_work" | "legacy" = "all_advancement", open = false): JsonObject { + const todo = {schema_version: "todo_domain_record_v0", todo_id: "todo_local", role: "agent", task_class: "advancement_task", + status: open ? "open" : "done", done: !open, archive_state: "active", text: "Local work", no_followup: !open}; + const document = normalizeGoalAcceptanceDocument({objective: "Deliver both outcomes", non_goals: [], + ...(scope === "legacy" ? {} : {scope: scope === "selected_work" + ? {kind: scope, todo_ids: ["todo_local"]} : {kind: scope}}), + criteria: ["local", "remaining"].map(id => ({id, description: id, validation_argv: ["true"]})), + bindings: [{todo_id: "todo_local", criterion_ids: ["local"]}]}); + const revision = verification === "stale" ? 2 : 1; + const state: JsonObject = {schema_version: "loopx_goal_acceptance_v0", enabled: true, + revision, digest: canonicalAuthoritySha256(document), document, + bindings: [{todo_id: "todo_local", todo_semantic_digest: goalAcceptanceTodoDigest(todo), + revision, criterion_ids: ["local"], confirmed_by: "owner"}], verification: null}; + const head: JsonObject = {goal_id: "goal-a", handoff_mode: "soft_claim", todos: [todo], leases: [], + todo_read_model: coordinationTodoReadModel([todo], "loopx_todo_domain_read_record_v0"), goal_acceptance: state}; + if (verification !== "unverified") state.verification = {operation_id: "verified", contract_revision: 1, + contract_digest: state.digest, work_digest: goalAcceptanceWorkDigest(head, "goal-a", document.scope), + todo_id: verification === "partial" ? "todo_local" : null, + results: (verification === "partial" ? ["local"] : ["local", "remaining"]).map(criterion_id => ({criterion_id, + passed: !(verification === "failed" && criterion_id === "remaining"), + exit_code: verification === "failed" && criterion_id === "remaining" ? 1 : 0}))}; + return head; +} + +for (const status of ["unverified", "partial", "failed", "stale"] as const) { + test(`${status}: closed local work retains every Goal criterion and a canonical verification gap`, () => { + const head = source(status); + const before = JSON.stringify(head); + const result = projectGoalAcceptance(head, "goal-a"); + assert.equal(result.status, status); + const gap = result.verification_gap as JsonObject; + assert.equal(gap.canonical_verification_required, true); + assert.deepEqual(gap.criterion_ids, ["local", "remaining"]); + assert.equal(JSON.stringify(head), before, "projection cannot rewrite contract or historical evidence"); + assert.equal(projectGoalAcceptance(head, "goal-a").verification_gap != null, true); + }); +} + +test("current full verification clears the gap; a work change invalidates that success", () => { + const head = source("accepted"); + assert.equal(projectGoalAcceptance(head, "goal-a").verification_gap, undefined); + const records = head.todos as JsonObject[]; + records[0].text = "Changed required work"; + head.todo_read_model = coordinationTodoReadModel(records, "loopx_todo_domain_read_record_v0"); + const changed = projectGoalAcceptance(head, "goal-a"); + assert.equal(changed.status, "stale"); + assert.deepEqual((changed.verification_gap as JsonObject).criterion_ids, ["local", "remaining"]); +}); + +test("local scope, runnable work, disabled and absent contracts retain their existing boundary", () => { + for (const state of ["unverified", "partial", "failed", "stale", "accepted"] as const) { + assert.equal(projectGoalAcceptance(source(state, "selected_work"), "goal-a").verification_gap, undefined); + } + const open = source("unverified", "all_advancement", true); + assert.equal(projectGoalAcceptance(open, "goal-a").verification_gap, undefined); + assert.equal(acceptanceWorkGuard(open, "goal-a", "todo_local")?.allowed, true); + const off = source("unverified"); + (off.goal_acceptance as JsonObject).enabled = false; + assert.deepEqual(projectGoalAcceptance(off, "goal-a"), {enabled: false}); + delete off.goal_acceptance; + assert.deepEqual(projectGoalAcceptance(off, "goal-a"), {enabled: false}); + assert.ok(projectGoalAcceptance(source("unverified", "legacy"), "goal-a").verification_gap); +}); + +test("a fresh vision ACK cannot satisfy a canonical verification gap", () => { + const ordinary = {kind: "vision_acceptance_gap", generated_at: "2026-01-01T00:00:00Z"}; + const request = {operation: "vision_ack", acceptance_gaps: [ordinary], ack: {recorded: true, + generated_at: "2026-01-01T00:01:00Z", semantic_delta: {accepted: true, outcomes: ["fresh_vision_path_outcome"]}}}; + assert.equal(projectReplanSemantics(request).acknowledged, true); + assert.equal(projectReplanSemantics({...request, + acceptance_gaps: [{...ordinary, canonical_verification_required: true}]}).acknowledged, false); +}); + +test("verification gap identity distinguishes Goals and Goal instances with identical criteria", () => { + const first = source("unverified"); + const other = structuredClone(first); + other.goal_id = "goal-b"; + const fingerprint = (head: JsonObject) => + (projectGoalAcceptance(head, String(head.goal_id)).verification_gap as JsonObject).frontier_revision; + assert.notEqual(fingerprint(first), fingerprint(other)); + const instances = ["a", "b"].map(suffix => { + const head = source("unverified"); + const goal_ref = {goal_id: "goal-a", goal_instance_id: "ginst_" + suffix.repeat(32)}; + head.goal_acceptance_lifecycle = {schema_version: "loopx_goal_acceptance_lifecycle_v0", state: "active", goal_ref}; + Object.assign(head.goal_acceptance as JsonObject, + {schema_version: "loopx_goal_acceptance_v1", owner_goal_ref: goal_ref}); + return fingerprint(head); + }); + assert.notEqual(instances[0], instances[1]); +}); + +test("verification gap identity follows changed evidence, not repeated operation IDs", () => { + const head = source("failed"); + const fingerprint = () => (projectGoalAcceptance(head, "goal-a").verification_gap as JsonObject).frontier_revision; + const original = fingerprint(); + const receipt = (head.goal_acceptance as JsonObject).verification as JsonObject; + receipt.operation_id = "another-identical-check"; + assert.equal(fingerprint(), original); + receipt.results = [{criterion_id: "local", passed: false, exit_code: 1}, + {criterion_id: "remaining", passed: true, exit_code: 0}]; + assert.notEqual(fingerprint(), original, "a different failed criterion is new evidence"); +});