From 59fa43a4ffc50dbaa7bc52e11a90839f9f670dae Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Mon, 5 Oct 2026 22:37:55 +0800
Subject: [PATCH 1/7] fix(collaboration): bind returned outputs to current
checks
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
loopx/collaboration_mcp.py | 23 +------
.../control_plane/collaboration/delegation.ts | 43 ++++++++++++
.../collaboration/delegation_results.py | 44 ++++++++++++
.../control_plane/effect_runtime_handlers.ts | 1 +
tests/control_plane_ts/delegation.test.ts | 29 +++++++-
tests/test_chat_team_evidence.py | 8 +++
tests/test_local_delegation.py | 69 ++++++++++++++++++-
7 files changed, 192 insertions(+), 25 deletions(-)
diff --git a/loopx/collaboration_mcp.py b/loopx/collaboration_mcp.py
index e5563fe80f..cbd7e20490 100644
--- a/loopx/collaboration_mcp.py
+++ b/loopx/collaboration_mcp.py
@@ -1324,28 +1324,7 @@ def _validate(self, binding: dict) -> dict:
return delegation_validation.validate(self, binding)
def _accepted(self, binding: dict) -> dict:
- validation = self._validate(binding)
- if not validation["plan"]["canonical_done"]:
- raise ValueError("delegation requires current canonical completion")
- workspace = Path(binding["workspace"]).resolve()
- artifacts = []
- for ref in binding["output_refs"]:
- path = workspace / ref
- if not path.resolve().is_relative_to(workspace) or path.is_symlink() or not path.is_file():
- raise ValueError("delegation artifact unavailable or outside workspace")
- if path.stat().st_size > 128_000:
- raise ValueError("delegation artifact exceeds bounded return size")
- with os.fdopen(os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0)), "rb") as stream:
- if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
- raise ValueError("delegation artifact must be a regular file")
- content = stream.read(128_001)
- if len(content) > 128_000:
- raise ValueError("delegation artifact exceeds bounded return size")
- artifacts.append({"ref": ref, "sha256": hashlib.sha256(content).hexdigest(),
- "text": content.decode("utf-8")})
- if len(json.dumps(artifacts).encode()) > 64_000:
- raise ValueError("delegation aggregate return exceeds limit")
- return {"artifacts": artifacts, "validation": validation["plan"]["observation"]}
+ return delegation_results.accepted_result(self, binding)
def execute(self, operation_id: str) -> None:
path = self.path(operation_id)
diff --git a/loopx/control_plane/collaboration/delegation.ts b/loopx/control_plane/collaboration/delegation.ts
index d1ca3c599d..c5e9c911d0 100644
--- a/loopx/control_plane/collaboration/delegation.ts
+++ b/loopx/control_plane/collaboration/delegation.ts
@@ -66,6 +66,49 @@ export function delegationValidationPlan(params: JsonObject): JsonObject {
return {todo_id: todo.todo_id, state: "ready", source, observation,
effects, canonical_done: todo.done === true && todo.status === "done"};
}
+
+/** Fresh host check observation, not an independent-verifier or persisted receipt.
+ * Host IO brackets the actual checks; only stable declared output versions return. */
+export function delegationCheckedArtifacts(params: JsonObject): JsonObject {
+ const binding = requireJsonObject(params.binding, "delegation binding");
+ const plan = requireJsonObject(params.plan, "current validation plan");
+ requireThat(plan.todo_id === binding.todo_id && plan.state === "ready" && plan.canonical_done === true,
+ "delegation requires current canonical completion");
+ const observation = requireJsonObject(plan.observation, "current validation observation");
+ requireThat((observation.source === "goal_acceptance" || observation.source === "todo_validation")
+ && typeof observation.basis_sha256 === "string" && BARE_SHA256_PATTERN.test(observation.basis_sha256)
+ && Number.isInteger(observation.check_count) && Number(observation.check_count) > 0
+ && Number.isInteger(observation.pinned_file_count) && Number(observation.pinned_file_count) >= 0,
+ "current validation basis required");
+ requireThat(typeof params.checked_at === "string"
+ && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(params.checked_at)
+ && Number.isFinite(Date.parse(params.checked_at)), "host check time required");
+ const refs = binding.output_refs;
+ requireThat(Array.isArray(refs) && refs.length > 0
+ && refs.length <= 20 && new Set(refs).size === refs.length,
+ "declared output refs required");
+ const versions = (value: unknown) => {
+ requireThat(Array.isArray(value) && value.length === refs.length,
+ "declared output versions required");
+ const rows = value.map(item => {
+ const row = requireJsonObject(item, "output version");
+ requireThat(text(row.ref) && !row.ref.startsWith("/") && !row.ref.includes("\\")
+ && !row.ref.split("/").includes("..") && refs.includes(row.ref)
+ && typeof row.sha256 === "string" && BARE_SHA256_PATTERN.test(row.sha256),
+ "valid declared output version required");
+ return {ref: row.ref, sha256: row.sha256};
+ });
+ requireThat(new Set(rows.map(row => row.ref)).size === rows.length, "duplicate output version");
+ return rows.sort((a, b) => a.ref.localeCompare(b.ref));
+ };
+ const before = versions(params.before);
+ const after = versions(params.after);
+ requireThat(canonicalAuthoritySha256(before) === canonicalAuthoritySha256(after),
+ "delegation output changed during validation; recheck the original output");
+ return {source: observation.source, basis_sha256: observation.basis_sha256,
+ check_count: observation.check_count, pinned_file_count: observation.pinned_file_count,
+ checked_at: params.checked_at, output_versions: after};
+}
export function selectDelegationBinding(params: JsonObject): JsonObject {
const config = requireJsonObject(params.config, "delegation configuration");
requireThat(config.schema_version === "loopx_local_delegation_v0", "unsupported delegation configuration");
diff --git a/loopx/control_plane/collaboration/delegation_results.py b/loopx/control_plane/collaboration/delegation_results.py
index 81275ec2ac..34b2956051 100644
--- a/loopx/control_plane/collaboration/delegation_results.py
+++ b/loopx/control_plane/collaboration/delegation_results.py
@@ -5,12 +5,56 @@
"""
from __future__ import annotations
+from datetime import datetime, timezone
+import hashlib
+import json
+import os
+from pathlib import Path
+import stat
+
from .inbox import _entry, _read, _write
from .peers import input_readiness, require_operation_id
from ..effect_runtime import effect_runtime_result, EffectRuntimeRemoteError
from ...file_lock import exclusive_file_lock
+def _artifacts(binding):
+ """Bounded host IO; the typed owner decides version/check relationships."""
+ workspace = Path(binding["workspace"]).resolve()
+ artifacts = []
+ for ref in binding["output_refs"]:
+ path = workspace / ref
+ if not path.resolve().is_relative_to(workspace) or path.is_symlink() or not path.is_file():
+ raise ValueError("delegation artifact unavailable or outside workspace")
+ if path.stat().st_size > 128_000:
+ raise ValueError("delegation artifact exceeds bounded return size")
+ with os.fdopen(os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)
+ | getattr(os, "O_NOFOLLOW", 0)), "rb") as stream:
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
+ raise ValueError("delegation artifact must be a regular file")
+ content = stream.read(128_001)
+ if len(content) > 128_000:
+ raise ValueError("delegation artifact exceeds bounded return size")
+ artifacts.append({"ref": ref, "sha256": hashlib.sha256(content).hexdigest(),
+ "text": content.decode("utf-8")})
+ if len(json.dumps(artifacts).encode()) > 64_000:
+ raise ValueError("delegation aggregate return exceeds limit")
+ return artifacts
+
+
+def accepted_result(service, binding):
+ before = _artifacts(binding)
+ validation = service._validate(binding) # Executes the current canonical rules.
+ after = _artifacts(binding)
+ observation = effect_runtime_result("collaboration.delegation.checked_artifacts", {
+ "binding": binding, "plan": validation["plan"],
+ "before": [{"ref": row["ref"], "sha256": row["sha256"]} for row in before],
+ "after": [{"ref": row["ref"], "sha256": row["sha256"]} for row in after],
+ "checked_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
+ })
+ return {"artifacts": after, "validation": observation}
+
+
def operation_brief(service, row):
identity = row["identity"]
return _entry(service.root, service.goal_id, identity["binding"]["agent_id"], identity["request_id"])["brief"]
diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts
index 5099f3cb14..74481a7dd3 100644
--- a/loopx/control_plane/effect_runtime_handlers.ts
+++ b/loopx/control_plane/effect_runtime_handlers.ts
@@ -570,6 +570,7 @@ export function createEffectRuntimeHandlers(
["turn.selection.rejection", lazyHandler(() => import("./turn_driver/selection_rejection.ts"), ({projectTurnSelectionRejection}) => projectTurnSelectionRejection)],
["collaboration.delegation.preflight", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationPreflight}) => delegationPreflight)],
["collaboration.delegation.validation_plan", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationValidationPlan}) => delegationValidationPlan)],
+ ["collaboration.delegation.checked_artifacts", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationCheckedArtifacts}) => delegationCheckedArtifacts)],
["collaboration.delegation.turn_plan", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationTurnPlanDecision}) => delegationTurnPlanDecision)],
["collaboration.delegation.inventory_query", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationInventoryQuery}) => delegationInventoryQuery)],
["collaboration.delegation.inventory_item", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationInventoryItem}) => delegationInventoryItem)],
diff --git a/tests/control_plane_ts/delegation.test.ts b/tests/control_plane_ts/delegation.test.ts
index e29666e242..9f5ed40641 100644
--- a/tests/control_plane_ts/delegation.test.ts
+++ b/tests/control_plane_ts/delegation.test.ts
@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
-import {recordDelegationAdoption, decideDelegationStop, decideDelegationWakeObservation, delegationInventoryItem, delegationInventoryQuery, delegationPreflight, delegationTurnPlanDecision, delegationValidationPlan, recoverValidatedDelegationSettlement, selectDelegationBinding, transitionDelegationObservation} from "../../loopx/control_plane/collaboration/delegation.ts";
+import {recordDelegationAdoption, decideDelegationStop, decideDelegationWakeObservation, delegationCheckedArtifacts, delegationInventoryItem, delegationInventoryQuery, delegationPreflight, delegationTurnPlanDecision, delegationValidationPlan, recoverValidatedDelegationSettlement, selectDelegationBinding, transitionDelegationObservation} from "../../loopx/control_plane/collaboration/delegation.ts";
import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts";
import {projectTurnSelectionRejection} from "../../loopx/control_plane/turn_driver/selection_rejection.ts";
@@ -117,6 +117,33 @@ test("current validation provenance identifies definitions without exporting pri
assert.doesNotMatch(JSON.stringify(combined), /private|Independent verification|validation_argv/);
});
+test("fresh host checks bind stable declared output versions, without attesting independence", () => {
+ const plan = delegationValidationPlan({binding, declaration, basis: {...validationBasis,
+ todo: {...validationTodo, status: "done", done: true}}});
+ const outputs = [{ref: "output.json", sha256: "a".repeat(64)}];
+ const checkedAt = "2026-01-02T03:04:05.123456Z";
+ const args = {binding, plan, before: outputs, after: outputs, checked_at: checkedAt};
+ assert.deepEqual(delegationCheckedArtifacts(args), {...plan.observation as object,
+ checked_at: checkedAt, output_versions: outputs});
+ assert.deepEqual(delegationCheckedArtifacts({...args, after: [{...outputs[0], private_log: "never export"}]}),
+ delegationCheckedArtifacts(args));
+ assert.throws(() => delegationCheckedArtifacts({...args, after: [{...outputs[0], sha256: "b".repeat(64)}]}),
+ /output changed during validation/);
+ for (const versions of [[], [outputs[0], outputs[0]], [{...outputs[0], ref: "other.json"}],
+ [{...outputs[0], ref: "/private/output.json"}], [{...outputs[0], sha256: "not a digest"}]]) {
+ assert.throws(() => delegationCheckedArtifacts({...args, after: versions}));
+ assert.throws(() => delegationCheckedArtifacts({...args, before: versions}));
+ }
+ for (const patch of [{canonical_done: false}, {state: "unbound"}, {todo_id: "other"},
+ {observation: {...plan.observation as object, check_count: 0}}])
+ assert.throws(() => delegationCheckedArtifacts({...args, plan: {...plan, ...patch}}));
+ for (const time of [null, "invalid", "2026-01-02", "2026-01-02T03:04:05+00:00"])
+ assert.throws(() => delegationCheckedArtifacts({...args, checked_at: time}), /host check time/);
+ const two = [{ref: "output.json", sha256: "a".repeat(64)}, {ref: "second.txt", sha256: "c".repeat(64)}];
+ assert.deepEqual(delegationCheckedArtifacts({...args, binding: {...binding, output_refs: ["output.json", "second.txt"]},
+ before: two, after: [...two].reverse()}).output_versions, two);
+});
+
test("same explicit grant contract applies to a coordinator and an ordinary member", () => {
assert.deepEqual(selectDelegationBinding(params), binding);
assert.deepEqual(selectDelegationBinding({...params, agent_id: "analyst"}), binding);
diff --git a/tests/test_chat_team_evidence.py b/tests/test_chat_team_evidence.py
index 26be31ba3b..5c7248f7fb 100644
--- a/tests/test_chat_team_evidence.py
+++ b/tests/test_chat_team_evidence.py
@@ -53,6 +53,14 @@ def read(body=None):
# not momentary liveness sampled at different times.
for key in ("operation_id", "request_id", "agent_id", "todo_id", "artifacts"):
assert observed[key] == cli_result[key] == accepted[key]
+ # Every transport reruns real checks; time is fresh, the checked versions
+ # and current rule definition match the report actually returned.
+ for result in (observed, cli_result, accepted):
+ assert result["validation"]["checked_at"].endswith("Z")
+ assert result["validation"]["output_versions"] == [
+ {"ref": row["ref"], "sha256": row["sha256"]} for row in result["artifacts"]]
+ assert {key: value for key, value in observed["validation"].items() if key != "checked_at"} == {
+ key: value for key, value in cli_result["validation"].items() if key != "checked_at"}
assert observed["artifacts"][0]["text"] == (root / "analyst/initial/output.json").read_text()
assert store.load_session(sid) == before
assert (root / "analyst/initial/host-invocations").read_text() == "1"
diff --git a/tests/test_local_delegation.py b/tests/test_local_delegation.py
index a56836e474..dd3d9a7b9f 100644
--- a/tests/test_local_delegation.py
+++ b/tests/test_local_delegation.py
@@ -396,8 +396,13 @@ async def disconnect_requester():
# The fixture rule pins the validator, oracle module and source material.
assert validation["check_count"] == 1 and validation["pinned_file_count"] == 3
assert len(validation["basis_sha256"]) == 64
- assert set(validation) == {"source", "check_count", "pinned_file_count", "basis_sha256"}
- assert reconnected.read("analysis-1")["validation"] == validation
+ assert set(validation) == {"source", "check_count", "pinned_file_count", "basis_sha256", "checked_at", "output_versions"}
+ assert validation["checked_at"].endswith("Z")
+ assert validation["output_versions"] == [{"ref": row["ref"], "sha256": row["sha256"]} for row in result["artifacts"]]
+ fresh_validation = reconnected.read("analysis-1")["validation"]
+ assert fresh_validation["checked_at"] > validation["checked_at"]
+ assert {key: value for key, value in fresh_validation.items() if key != "checked_at"} == {
+ key: value for key, value in validation.items() if key != "checked_at"}
assert (root / "analyst" / "initial" / "host-invocations").read_text() == "1"
assert not (root / "analyst" / "initial" / "DELEGATION.json").exists()
assert demo.canonical_tasks(root)["todo_analyst-initial"]["done"]
@@ -433,6 +438,66 @@ async def disconnect_requester():
reconnected.read("analysis-1")
+def test_accepted_output_cannot_change_between_check_and_return(service, monkeypatch):
+ """Real owner checks; inject a writer at the subsequent artifact-read boundary."""
+ root, runner = service
+ runner.start("analysis", "analysis-1", brief())
+ assert wait(runner)["status"] == "accepted"
+ binding = runner._bound(_read(runner.path("analysis-1")))
+ output = root / "analyst" / "initial" / "output.json"
+ original = output.read_bytes()
+ validate = runner._validate
+
+ def change_after_checks(value):
+ checked = validate(value) # Execute the real configured oracle first.
+ output.write_text("{}")
+ return checked
+
+ monkeypatch.setattr(runner, "_validate", change_after_checks)
+ try:
+ # _accepted also feeds the first journaled return, before a saved hash exists.
+ with pytest.raises(Exception, match="output changed during validation"):
+ runner._accepted(binding)
+ finally:
+ output.write_bytes(original)
+ monkeypatch.setattr(runner, "_validate", validate)
+ assert runner.read("analysis-1")["artifacts"][0]["text"] == original.decode()
+ assert (root / "analyst" / "initial" / "host-invocations").read_text() == "1"
+
+
+def test_first_return_withholds_changed_output_and_recovers_same_operation(service, monkeypatch):
+ root, runner = service
+ monkeypatch.setattr(runner, "_spawn", lambda _operation_id: None)
+ output = root / "analyst" / "initial" / "output.json"
+ original = output.read_bytes()
+ validate = runner._validate
+
+ def change_before_first_return(value):
+ checked = validate(value)
+ if checked["plan"]["canonical_done"]:
+ output.write_text("{}")
+ return checked
+
+ monkeypatch.setattr(runner, "_validate", change_before_first_return)
+ runner.start("analysis", "analysis-1", brief())
+ try:
+ runner.execute("analysis-1")
+ journal = _read(runner.path("analysis-1"))
+ assert journal["status"] != "accepted"
+ assert not journal.get("artifacts")
+ assert "output changed during validation" in journal["error"]
+ finally:
+ output.write_bytes(original)
+ monkeypatch.setattr(runner, "_validate", validate)
+ runner.resume("analysis-1")
+ runner.execute("analysis-1")
+ result = runner.read("analysis-1")
+ assert result["status"] == "accepted", result
+ assert result["artifacts"][0]["text"] == original.decode()
+ assert result["validation"]["output_versions"][0]["sha256"] == result["artifacts"][0]["sha256"]
+ assert (root / "analyst" / "initial" / "host-invocations").read_text() == "1"
+
+
def test_host_timeout_removes_private_delegation_bootstrap(service, monkeypatch):
root, runner = service
monkeypatch.setattr(runner, "_spawn", lambda _operation_id: None)
From f53d7a2f580cd771e3046b5f54ddddc4705bc914 Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Mon, 5 Oct 2026 22:40:34 +0800
Subject: [PATCH 2/7] feat(frontend): show current delegation check records
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
.../smoke/team-evidence-return-smoke.mjs | 67 +++++++++++++++++-
apps/presentation/dashboard/src/data/chat.ts | 3 +-
.../personal-workspace/goal-team-evidence.tsx | 12 ++++
.../rfcs/live-team-workspace-v0.md | 2 +
.../team-check-records-desktop.png | Bin 0 -> 125120 bytes
.../team-check-records-mobile.png | Bin 0 -> 49553 bytes
.../team-check-records-unavailable.png | Bin 0 -> 112472 bytes
docs/reference/local-delegation.md | 14 ++--
8 files changed, 91 insertions(+), 7 deletions(-)
create mode 100644 docs/assets/personal-workspace/team-check-records-desktop.png
create mode 100644 docs/assets/personal-workspace/team-check-records-mobile.png
create mode 100644 docs/assets/personal-workspace/team-check-records-unavailable.png
diff --git a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs
index 038892d933..be3e6d841a 100644
--- a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs
+++ b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs
@@ -30,13 +30,78 @@ try {
await page.getByRole("button", {name: "团队执行情况", exact: true}).click();
const dialog = page.getByRole("dialog", {name: "团队执行情况"});
const openEvidence = dialog.getByRole("button", {name: "查看证据与反馈", exact: true});
+ const firstRead = page.waitForResponse(response => response.request().method() === "POST"
+ && response.request().postDataJSON()?.operation === "read"
+ && response.request().postDataJSON()?.operation_id === "accepted-analysis");
await openEvidence.first().click();
+ const legacyReadback = await (await firstRead).json();
const evidence = dialog.getByRole("region", {name: "执行证据"});
const original = () => evidence.getByLabel("证据内容: report.json");
await original().waitFor();
// Older readbacks remain readable without invented provenance.
await evidence.getByText("本次验收依据", {exact: true}).click();
await evidence.getByText("此运行时未提供验收依据标识。", {exact: true}).waitFor();
+ // Transport fixtures exercise presentation only; real File/SQLite checks are
+ // qualified by test_local_delegation, never inferred from these synthetic rows.
+ const checkTime = "2026-01-02T03:04:05.123456Z";
+ const outputVersions = [{ref: "report.json", sha256: "d".repeat(64)}, {ref: "report.md", sha256: "9".repeat(64)}];
+ const currentValidation = {source: "goal_acceptance", basis_sha256: "7".repeat(64), check_count: 1,
+ pinned_file_count: 3, checked_at: checkTime, output_versions: outputVersions};
+ let validation = {...currentValidation, checked_at: undefined, output_versions: undefined};
+ let checkUnavailable = false;
+ const checkedRead = route => {
+ const body = route.request().postDataJSON();
+ if (body?.operation !== "read" || body.operation_id !== "accepted-analysis") return route.fallback();
+ api.loopxModeRequests.push({sessionId: configured.sessionId, ...body});
+ return checkUnavailable ? route.fulfill({status: 409, json: {error: "delegation output changed during validation"}})
+ : route.fulfill({json: {...legacyReadback, validation}});
+ };
+ await page.route("**/api/chat/sessions/*/loopx", checkedRead);
+ const recheck = evidence.getByRole("button", {name: "重新读取证据", exact: true});
+ await recheck.click();
+ await original().waitFor();
+ await evidence.getByText("本次验收依据", {exact: true}).click();
+ await evidence.getByText("未提供与当前产物匹配的检查时间及版本记录。", {exact: true}).waitFor();
+ assert.equal(await evidence.locator("time").count(), 0, "A legacy basis does not invent check records");
+ validation = currentValidation;
+ await recheck.click();
+ await original().waitFor();
+ await evidence.getByText("本次验收依据", {exact: true}).click();
+ const checkObservation = evidence.locator("details").filter({has: page.getByText("本次验收依据", {exact: true})});
+ await checkObservation.getByText(checkTime, {exact: true}).waitFor();
+ for (const version of outputVersions) await checkObservation.getByText(version.sha256, {exact: true}).waitFor();
+ assert.equal(await checkObservation.getByText("未提供与当前产物匹配的检查时间及版本记录。", {exact: true}).count(), 0);
+ await mkdir(outputDir, {recursive: true});
+ await checkObservation.scrollIntoViewIfNeeded();
+ await page.screenshot({path: resolve(outputDir, "team-checked-output-desktop.png"), animations: "disabled"});
+ await page.setViewportSize({width: 390, height: 844});
+ await page.emulateMedia({reducedMotion: "reduce"});
+ assert.ok(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth));
+ await checkObservation.getByText(outputVersions[1].sha256, {exact: true}).scrollIntoViewIfNeeded();
+ await page.screenshot({path: resolve(outputDir, "team-checked-output-mobile.png"), animations: "disabled"});
+ await page.setViewportSize({width: 1512, height: 980});
+ // Another version's witness must never appear as a check of this report.
+ validation = {...validation, output_versions: [{ref: "report.json", sha256: "f".repeat(64)}, outputVersions[1]]};
+ await recheck.click();
+ await original().waitFor();
+ await evidence.getByText("本次验收依据", {exact: true}).click();
+ await evidence.getByText("未提供与当前产物匹配的检查时间及版本记录。", {exact: true}).waitFor();
+ assert.equal(await checkObservation.locator("time").count(), 0);
+ assert.equal(await checkObservation.getByText("f".repeat(64), {exact: true}).count(), 0);
+ checkUnavailable = true;
+ await recheck.click();
+ await evidence.getByRole("alert").filter({hasText: "已清除上次证据"}).waitFor();
+ assert.equal(await original().count(), 0);
+ assert.equal(await evidence.getByText("本次验收依据", {exact: true}).count(), 0);
+ await page.screenshot({path: resolve(outputDir, "team-checked-output-unavailable.png"), animations: "disabled"});
+ checkUnavailable = false;
+ validation = {...validation, output_versions: outputVersions};
+ await recheck.focus();
+ await page.keyboard.press("Enter");
+ await original().waitFor();
+ await evidence.getByText("本次验收依据", {exact: true}).click();
+ await checkObservation.getByText(checkTime, {exact: true}).waitFor();
+ await page.unroute("**/api/chat/sessions/*/loopx", checkedRead);
await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click();
const verificationGap = evidence.getByText("当前读回未提供独立验收者与指定版本回执。", {exact: true});
await verificationGap.waitFor({timeout: 3000});
@@ -168,7 +233,7 @@ try {
assert.ok(await openEvidence.first().evaluate(el => el === document.activeElement));
assert.equal(api.turnRequests.length, 0);
assert.equal(api.loopxModeRequests.filter(row => row.operation === "message").length, 0);
- console.log("team-evidence-return: passed (packaged navigation, downstream loss/revocation/restoration, core loss, keyboard return, fresh evidence/list, pagination, mobile and no execution)");
+ console.log("team-evidence-return: passed (packaged checked outputs, navigation, downstream loss/revocation/restoration, core loss, keyboard return, fresh evidence/list, pagination, mobile and no execution)");
} finally {
await workspace?.close();
await browser?.close();
diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts
index 56fc25e55e..c1c5e945a0 100644
--- a/apps/presentation/dashboard/src/data/chat.ts
+++ b/apps/presentation/dashboard/src/data/chat.ts
@@ -1144,7 +1144,8 @@ export type DelegationReadback = {
status: string; worker_active: boolean; recovery_required: boolean;
artifacts?: Array<{ref: string; sha256: string; text: string}>; error?: string;
validation?: {source: "goal_acceptance" | "todo_validation"; basis_sha256: string;
- check_count: number; pinned_file_count: number};
+ check_count: number; pinned_file_count: number; checked_at?: string;
+ output_versions?: Array<{ref: string; sha256: string}>};
dependencies?: DelegationDependency[]; adoptions?: DelegationAdoption[];
};
export function readLoopXTeamWork(sessionId: string, operationId: string) {
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx
index a7f1550b08..087e214d72 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx
@@ -58,6 +58,13 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres
: (zh ? "投递状态待核实" : "Delivery requires reconciliation");
const hasCorrectionPath = Boolean(result?.dependencies?.some(link => link.relation === "revises")
&& result?.dependencies?.some(link => link.relation === "responds_to"));
+ // Defensive transport readback: never present a witness for another report version.
+ const checkedOutputs = result?.validation?.output_versions;
+ const checkedAt = result?.validation?.checked_at;
+ const hasCheckedOutputs = Boolean(checkedAt && Number.isFinite(Date.parse(checkedAt))
+ && checkedOutputs?.length && checkedOutputs.length === result?.artifacts?.length
+ && new Set(checkedOutputs.map(row => row.ref)).size === checkedOutputs.length
+ && checkedOutputs.every(row => result?.artifacts?.some(artifact => artifact.ref === row.ref && artifact.sha256 === row.sha256)));
return {zh ? "执行证据" : "Execution evidence"}
{zh ? "本次读取重跑了当前规则,并核对产物版本。规则标识不证明独立复核者或异议已解决。" : "This read reran the current rules and checked output versions. The rule identity does not attest an independent reviewer or resolve an objection."}
+ {hasCheckedOutputs ? <> +{zh ? "本次检查完成于" : "Checks completed at"}{" "}
+{zh ? "检查前后保持一致的产物版本" : "Output versions unchanged across these checks"}
+ {checkedOutputs!.map(row =>{row.ref}{" · "}{row.sha256}
{zh ? "未提供与当前产物匹配的检查时间及版本记录。" : "Check time and version records matching this report are unavailable."}
}{result.validation.basis_sha256}
> : {zh ? "此运行时未提供验收依据标识。" : "This runtime did not provide the validation basis identity."}
} : null} diff --git a/docs/architecture/rfcs/live-team-workspace-v0.md b/docs/architecture/rfcs/live-team-workspace-v0.md index 53a395742d..e0e31a13e7 100644 --- a/docs/architecture/rfcs/live-team-workspace-v0.md +++ b/docs/architecture/rfcs/live-team-workspace-v0.md @@ -393,6 +393,8 @@ Implementation checkpoint ([#5587](https://github.com/loopx-project/loopx/pull/5 This remains a presentation/readback slice. `responds_to` does not certify an objection. Current native acceptance validates the canonical task and configured validators, but its readback does not expose an independent verifier identity and exact-version receipt. The revision therefore shows that missing evidence explicitly, even beside a valid requester adoption. The evidence reader separately exposes the current validation source, definition digest and check/file-pin counts from the existing typed validation plan. CLI and HTTP readback carry the same path-free observation after the original checks pass; failed validation or changed pins withdraw it with the report. This rule identity is not a persisted success or independent-verifier receipt. The frontend must consume such evidence from the existing acceptance owner when available; it cannot infer it from the reviewer name, relationship or artifact hash. +The current host-check observation additionally names its UTC completion time and the declared output versions that remained unchanged before and after those checks. A changed output withdraws even the first return; the evidence detail displays matching records and leaves older or mismatched records unavailable. This reuses the delegation TS owner and read-only Chat/CLI projection, adds no verifier configuration or independent semantic verdict, and does not close Section 9's independent exact-version acceptance gap. + Packaged desktop, 390px, keyboard/reduced-motion and pagination checks cover these returns and recovery. An isolated production SQLite/HTTP/CLI fixture also rejects changed output, withdraws its list acceptance and restores the original version without launching additional work. [Public-safe fixture views](../../reference/local-delegation.md#inspect-accepted-evidence-and-return) make these states reviewable. These checks do not establish a real objection, independent semantic acceptance, installed native behavior or L1 completion. The existing real correction episode must still be exercised through the packaged UI and independently read back with Section 9's missing/lost-observation cases. Task inspectors read the original request on demand through the existing Todo authority. Status/list summaries and thin reads retain their bounded budget; diff --git a/docs/assets/personal-workspace/team-check-records-desktop.png b/docs/assets/personal-workspace/team-check-records-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..d04a1318c74550fc996523984b4804094ff8527f GIT binary patch literal 125120 zcmb5WcR1F68$RA5GkX(mlpV4%!)?!-J3F$9?2(X>l|7?~$ZdyYZz5#0>@69EL{>(| z@4D6JIga1=ukY6%&vP6;;(fnf*ErAfITbkRgk);PT?$`I(2p! z?+kp0^4uEVsZ*q_Sxx9ubwh~aW==2=0k(y3pPK#apb+waY~QV@hRI><5{#SvdY{0Sl=9|n5V
zm?+U+d-slwX{=a&Xzh$PIdh&0`2UUnZ;>6fB<}x>oWqUDX1Pw4-$7IXHlw3p)K=*#
z!3u_thi^kvFKW`N|5kz>i;$6(9efE52NGPjpRtwpX~z|Zm6w*54GHiJWl~F8|2u88
zH{1=(_&ac(5`&_Mps?kgg(c2vlx4#-?F|J{f@x{&4!L@WVQl$CtmZ56`9J?_3L*w!
zy&;Ds5JqegrE?JRCd8j et!ztxl)ru~v@uvCCLC#u
zc=8;+_pI 6vLACGhHU4b!E;L;O(Kw{rppA&f}z&)-d9Zp4prkY4uq*Co$oq-?4h1nNA=CyG`WE5
zNOZiAeR|H)2i}_AGz_2w`c$vqsMY2-sIbJRrUfsOCCN}>aOY)KJnLd|2&NxXkN59A
z_KC1E%SxgDf@SKWt?k2
zhLd}c^?)f)8mM!fI=@&9f8&gQUHdHuWr8)n2aS}?c(c^cXg=ybe#a@PhyaxgBmNxg
z;T3BUB7@YoB