From e589229c11685373c95e070b6af69bfd6710989d Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:12:34 +0800 Subject: [PATCH 01/14] feat(capability): add typed external evidence lifecycle Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/capabilities/catalog.py | 2 + .../external_research/__init__.py | 1 + .../external_research/catalog_entry.py | 53 +++ loopx/capabilities/external_research/cli.py | 202 ++++++++++ .../external_research/contract.ts | 345 ++++++++++++++++++ loopx/cli.py | 12 + .../control_plane/effect_runtime_handlers.ts | 8 + .../test_external_evidence_cli.py | 171 +++++++++ .../external_evidence_research.test.ts | 180 +++++++++ 9 files changed, 974 insertions(+) create mode 100644 loopx/capabilities/external_research/__init__.py create mode 100644 loopx/capabilities/external_research/catalog_entry.py create mode 100644 loopx/capabilities/external_research/cli.py create mode 100644 loopx/capabilities/external_research/contract.ts create mode 100644 tests/capabilities/test_external_evidence_cli.py create mode 100644 tests/control_plane_ts/external_evidence_research.test.ts diff --git a/loopx/capabilities/catalog.py b/loopx/capabilities/catalog.py index 0a71aeba13..606b763d0a 100644 --- a/loopx/capabilities/catalog.py +++ b/loopx/capabilities/catalog.py @@ -26,6 +26,7 @@ from .deep_research.catalog_entry import DEEP_RESEARCH_CATALOG_ENTRY from .public_safe_outbound.catalog_entry import PUBLIC_SAFE_OUTBOUND_CATALOG_ENTRY from .connector_registry.catalog_entry import CONNECTOR_REGISTRY_CATALOG_ENTRY +from .external_research.catalog_entry import EXTERNAL_RESEARCH_CATALOG_ENTRY from .reliability_diagnostics.catalog_entry import RELIABILITY_DIAGNOSTICS_CATALOG_ENTRY from .registry import CapabilityRegistry @@ -53,6 +54,7 @@ DEEP_RESEARCH_CATALOG_ENTRY, PUBLIC_SAFE_OUTBOUND_CATALOG_ENTRY, CONNECTOR_REGISTRY_CATALOG_ENTRY, + EXTERNAL_RESEARCH_CATALOG_ENTRY, RELIABILITY_DIAGNOSTICS_CATALOG_ENTRY, ) # Preserve the original import surface while routing all reads through the registry. diff --git a/loopx/capabilities/external_research/__init__.py b/loopx/capabilities/external_research/__init__.py new file mode 100644 index 0000000000..2af54eda5c --- /dev/null +++ b/loopx/capabilities/external_research/__init__.py @@ -0,0 +1 @@ +"""Provider-neutral external evidence planning and admission.""" diff --git a/loopx/capabilities/external_research/catalog_entry.py b/loopx/capabilities/external_research/catalog_entry.py new file mode 100644 index 0000000000..417f50af21 --- /dev/null +++ b/loopx/capabilities/external_research/catalog_entry.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Any + + +EXTERNAL_RESEARCH_CATALOG_ENTRY: dict[str, Any] = { + "id": "external-evidence-research", + "origin": "builtin", + "visibility": "public", + "provider_id": "loopx-core", + "documentation": { + "source_root": "loopx/capabilities/external_research", + "site_root": "capabilities/external-evidence-research", + "canonical": "README.md", + }, + "title": "Auditable external evidence research", + "status": "active-preview", + "real_world_anchor": ( + "a decision-bound research question executed through either a host research " + "method or a connector provider, with source-level provenance" + ), + "user_value": ( + "Plan one evidence request, select only a currently ready provider, and admit " + "or reject a compact provenance receipt without copying raw provider content." + ), + "next_real_step": ( + "run `loopx external-evidence plan --help`, then provide a current provider " + "inventory and admit the returned provider receipt" + ), + "entry_command": "loopx external-evidence plan --help", + "commands": [ + { + "command": "loopx external-evidence plan ... --provider-inventory-json providers.json", + "purpose": "Bind object, user activity, decision, and evidence kinds to one ready provider.", + "write_boundary": "read-only", + }, + { + "command": "loopx external-evidence admit --plan-json plan.json --receipt-json receipt.json ...", + "purpose": "Validate source provenance and record the parent admit/reject decision.", + "write_boundary": "read-only typed reduction; caller owns durable writeback", + }, + { + "command": "loopx external-evidence retire --admission-json admission.json ...", + "purpose": "Prove admitted sources reached a downstream projection before retirement.", + "write_boundary": "read-only", + }, + ], + "implemented_protocols": ["external_evidence_research_v0"], + "smokes": [ + "node --no-warnings --experimental-strip-types --test tests/control_plane_ts/external_evidence_research.test.ts", + "python -m pytest tests/capabilities/test_external_evidence_cli.py -q", + ], +} diff --git a/loopx/capabilities/external_research/cli.py b/loopx/capabilities/external_research/cli.py new file mode 100644 index 0000000000..e3b5b474f0 --- /dev/null +++ b/loopx/capabilities/external_research/cli.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import argparse +import json +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +from ...control_plane.effect_runtime import effect_runtime_result +from ..connector_registry.core import load_connector_registry + + +PrintPayload = Callable[[dict[str, object], str, Callable[[dict[str, object]], str]], None] +AddFormat = Callable[[argparse.ArgumentParser], None] +FormatSelector = Callable[..., str] +MAX_INPUT_BYTES = 1_000_000 + + +def _load_object(path_text: str, *, label: str) -> dict[str, Any]: + path = Path(path_text).expanduser() + try: + raw = path.read_bytes() + if len(raw) > MAX_INPUT_BYTES: + raise ValueError(f"{label} exceeds the {MAX_INPUT_BYTES}-byte limit") + value = json.loads(raw) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"{label} must be a readable JSON object") from exc + if not isinstance(value, dict): + raise ValueError(f"{label} must be a JSON object") + return value + + +def _provider_inventory(value: Mapping[str, Any]) -> list[dict[str, object]]: + providers = value.get("providers") + if not isinstance(providers, list): + raise ValueError("provider inventory requires a providers array") + if not all(isinstance(provider, Mapping) for provider in providers): + raise ValueError("provider inventory entries must be objects") + return [dict(provider) for provider in providers] + + +def _connector_inventory(path_text: str | None) -> list[dict[str, object]]: + if not path_text: + return [] + state = load_connector_registry(Path(path_text).expanduser()) + return [ + { + "provider_id": f"connector:{connector['id']}", + "provider_kind": "connector", + "protocol": "external_evidence_research_v0", + "declared": True, + "installed": False, + "enabled": False, + "ready": False, + "unavailable_reason": "connector_registry_is_inventory_not_readiness", + } + for connector in state.get("connectors", []) + if isinstance(connector, Mapping) and connector.get("id") + ] + + +def _merge_providers( + registry_providers: list[dict[str, object]], + observed_providers: list[dict[str, object]], +) -> list[dict[str, object]]: + merged = { + str(provider["provider_id"]): provider + for provider in registry_providers + if provider.get("provider_id") + } + for provider in observed_providers: + provider_id = provider.get("provider_id") + if not isinstance(provider_id, str) or not provider_id: + raise ValueError("provider inventory entries require provider_id") + merged[provider_id] = provider + return list(merged.values()) + + +def _render(payload: dict[str, object]) -> str: + lines = ["# LoopX External Evidence", ""] + for field in ( + "status", + "request_id", + "provider_id", + "provider_kind", + "disposition", + "retire_ready", + "blocker", + "reason", + ): + if field in payload: + lines.append(f"- {field}: `{payload.get(field)}`") + selected = payload.get("selected_provider") + if isinstance(selected, Mapping): + lines.append(f"- selected_provider: `{selected.get('provider_id')}`") + return "\n".join(lines) + "\n" + + +def register_external_evidence_commands( + subparsers: argparse._SubParsersAction, + add_subcommand_format: AddFormat, +) -> None: + parser = subparsers.add_parser( + "external-evidence", + help="Plan, admit, and retire auditable external evidence.", + ) + actions = parser.add_subparsers(dest="external_evidence_action", required=True) + + plan = actions.add_parser("plan", help="Select one currently ready evidence provider.") + plan.add_argument("--objective", required=True) + plan.add_argument("--user-activity", required=True) + plan.add_argument("--decision", required=True) + plan.add_argument("--evidence-kind", action="append", required=True) + plan.add_argument("--constraint", action="append", default=[]) + plan.add_argument("--provider-inventory-json", required=True) + plan.add_argument("--connector-registry") + plan.add_argument("--preferred-provider-id") + add_subcommand_format(plan) + + admit = actions.add_parser("admit", help="Validate a provider receipt and parent decision.") + admit.add_argument("--plan-json", required=True) + admit.add_argument("--receipt-json", required=True) + admit.add_argument("--decision", choices=["admit", "reject"], required=True) + admit.add_argument("--reason", required=True) + admit.add_argument("--admit-source", action="append", default=[]) + add_subcommand_format(admit) + + retire = actions.add_parser("retire", help="Check downstream projection coverage before retirement.") + retire.add_argument("--admission-json", required=True) + retire.add_argument("--downstream-source", action="append", default=[]) + add_subcommand_format(retire) + + +def handle_external_evidence_command( + args: argparse.Namespace, + *, + output_format: FormatSelector, + print_payload: PrintPayload, +) -> int | None: + if args.command != "external-evidence": + return None + try: + if args.external_evidence_action == "plan": + inventory = _load_object( + args.provider_inventory_json, + label="external evidence provider inventory", + ) + providers = _merge_providers( + _connector_inventory(args.connector_registry), + _provider_inventory(inventory), + ) + payload = effect_runtime_result( + "external_evidence.plan", + { + "request": { + "objective": args.objective, + "user_activity": args.user_activity, + "decision": args.decision, + "evidence_kinds": args.evidence_kind, + "constraints": args.constraint, + }, + "providers": providers, + "preferred_provider_id": args.preferred_provider_id, + }, + ) + elif args.external_evidence_action == "admit": + payload = effect_runtime_result( + "external_evidence.admit", + { + "plan": _load_object(args.plan_json, label="external evidence plan"), + "receipt": _load_object(args.receipt_json, label="external evidence receipt"), + "decision": { + "disposition": args.decision, + "reason": args.reason, + "admitted_source_refs": args.admit_source, + }, + }, + ) + elif args.external_evidence_action == "retire": + payload = effect_runtime_result( + "external_evidence.retire", + { + "admission": _load_object( + args.admission_json, + label="external evidence admission", + ), + "downstream_source_refs": args.downstream_source, + }, + ) + else: + raise ValueError("external-evidence requires plan, admit, or retire") + except (RuntimeError, ValueError) as exc: + payload = { + "ok": False, + "schema_version": "loopx_external_evidence_error_v0", + "status": "invalid_request", + "error": str(exc), + } + print_payload(payload, output_format(args), _render) + return 1 + print_payload(payload, output_format(args), _render) + return 0 diff --git a/loopx/capabilities/external_research/contract.ts b/loopx/capabilities/external_research/contract.ts new file mode 100644 index 0000000000..dc0285928b --- /dev/null +++ b/loopx/capabilities/external_research/contract.ts @@ -0,0 +1,345 @@ +import { createHash } from "node:crypto"; + +import type { JsonObject } from "../../control_plane/effect_program.ts"; +import { EffectRuntimeRequestError } from "../../control_plane/effect_runtime_errors.ts"; +import { + requireJsonObject, + requireNonEmptyString, + requireStringLiteral, +} from "../../control_plane/runtime_decode.ts"; + +export const EXTERNAL_EVIDENCE_REQUEST_SCHEMA_VERSION = + "loopx_external_evidence_request_v0"; +export const EXTERNAL_EVIDENCE_PLAN_SCHEMA_VERSION = + "loopx_external_evidence_plan_v0"; +export const EXTERNAL_EVIDENCE_RECEIPT_SCHEMA_VERSION = + "loopx_external_evidence_receipt_v0"; +export const EXTERNAL_EVIDENCE_ADMISSION_SCHEMA_VERSION = + "loopx_external_evidence_admission_v0"; +export const EXTERNAL_EVIDENCE_RETIREMENT_SCHEMA_VERSION = + "loopx_external_evidence_retirement_v0"; + +const PROVIDER_KINDS = ["method", "connector"] as const; +const RECEIPT_STATUSES = ["succeeded", "failed", "no_evidence"] as const; +const EVIDENCE_BASES = ["stated", "observed", "tested", "inferred"] as const; +const ADMISSION_DECISIONS = ["admit", "reject"] as const; +const SHA256_RE = /^sha256:[0-9a-f]{64}$/; +const PROVIDER_ID_RE = /^[a-z][a-z0-9_.:-]{1,95}$/; +const SOURCE_REF_RE = /^(https?:\/\/|[a-z][a-z0-9+.-]*:\/\/|urn:)/; + +function requireThat(value: unknown, message: string): asserts value { + if (!value) throw new EffectRuntimeRequestError(message); +} + +function boundedText(value: unknown, label: string, max = 4096): string { + const result = requireNonEmptyString(value, label).trim(); + requireThat(result.length <= max, `${label} exceeds ${max} characters`); + return result; +} + +function boundedStrings( + value: unknown, + label: string, + maxItems = 16, + maxText = 256, +): string[] { + requireThat(Array.isArray(value), `${label} must be an array`); + requireThat(value.length <= maxItems, `${label} has too many items`); + return value.map((item, index) => + boundedText(item, `${label}[${index}]`, maxText) + ); +} + +function canonicalValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalValue); + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, canonicalValue(child)]), + ); +} + +function digest(value: unknown): string { + return `sha256:${createHash("sha256") + .update(JSON.stringify(canonicalValue(value)), "utf8") + .digest("hex")}`; +} + +function normalizeRequest(value: unknown): JsonObject { + const request = requireJsonObject(value, "external evidence request"); + const normalized: JsonObject = { + schema_version: EXTERNAL_EVIDENCE_REQUEST_SCHEMA_VERSION, + objective: boundedText(request.objective, "request.objective"), + user_activity: boundedText(request.user_activity, "request.user_activity"), + decision: boundedText(request.decision, "request.decision"), + evidence_kinds: boundedStrings( + request.evidence_kinds, + "request.evidence_kinds", + 8, + 96, + ), + constraints: request.constraints === undefined + ? [] + : boundedStrings(request.constraints, "request.constraints", 16, 256), + }; + requireThat( + (normalized.evidence_kinds as string[]).length > 0, + "request.evidence_kinds must not be empty", + ); + normalized.request_id = digest(normalized); + return normalized; +} + +function normalizeProvider(value: unknown, index: number): JsonObject { + const provider = requireJsonObject(value, `providers[${index}]`); + const providerId = boundedText( + provider.provider_id, + `providers[${index}].provider_id`, + 96, + ); + requireThat(PROVIDER_ID_RE.test(providerId), `providers[${index}].provider_id is invalid`); + const providerKind = requireStringLiteral( + provider.provider_kind, + PROVIDER_KINDS, + `providers[${index}].provider_kind`, + ); + requireThat( + provider.protocol === "external_evidence_research_v0", + `providers[${index}].protocol is unsupported`, + ); + for (const field of ["declared", "installed", "enabled", "ready"] as const) { + requireThat( + typeof provider[field] === "boolean", + `providers[${index}].${field} must be boolean`, + ); + } + const ready = provider.ready === true; + requireThat( + !ready || ( + provider.declared === true && + provider.installed === true && + provider.enabled === true + ), + `providers[${index}] cannot be ready before declared, installed, and enabled`, + ); + const unavailableReason = provider.unavailable_reason === null || + provider.unavailable_reason === undefined + ? null + : boundedText( + provider.unavailable_reason, + `providers[${index}].unavailable_reason`, + 512, + ); + requireThat( + ready || unavailableReason !== null, + `providers[${index}] requires unavailable_reason when not ready`, + ); + return { + provider_id: providerId, + provider_kind: providerKind, + protocol: "external_evidence_research_v0", + declared: provider.declared, + installed: provider.installed, + enabled: provider.enabled, + ready, + unavailable_reason: unavailableReason, + }; +} + +export function planExternalEvidenceRequest(params: JsonObject): JsonObject { + const request = normalizeRequest(params.request); + requireThat(Array.isArray(params.providers), "providers must be an array"); + requireThat(params.providers.length <= 64, "providers has too many items"); + const providers = params.providers.map(normalizeProvider); + requireThat( + new Set(providers.map((provider) => provider.provider_id)).size === providers.length, + "provider ids must be unique", + ); + const preferredProviderId = params.preferred_provider_id === undefined || + params.preferred_provider_id === null + ? null + : boundedText(params.preferred_provider_id, "preferred_provider_id", 96); + if (preferredProviderId !== null) { + requireThat( + providers.some((provider) => provider.provider_id === preferredProviderId), + "preferred provider is not in the current inventory", + ); + } + const readyProviders = providers.filter((provider) => provider.ready === true); + const selected = preferredProviderId === null + ? readyProviders[0] ?? null + : readyProviders.find((provider) => provider.provider_id === preferredProviderId) ?? null; + const status = selected === null ? "blocked" : "ready"; + return { + schema_version: EXTERNAL_EVIDENCE_PLAN_SCHEMA_VERSION, + status, + request, + provider_candidates: providers, + selected_provider: selected, + execution_envelope: selected === null + ? null + : { + schema_version: "loopx_external_evidence_execution_envelope_v0", + request_id: request.request_id, + provider_id: selected.provider_id, + provider_kind: selected.provider_kind, + protocol: selected.protocol, + authority: "read_external_sources_only", + raw_content_persistence: "provider_private", + result_contract: EXTERNAL_EVIDENCE_RECEIPT_SCHEMA_VERSION, + }, + blocker: selected === null + ? preferredProviderId === null + ? "no_ready_provider" + : "preferred_provider_not_ready" + : null, + }; +} + +function sourceRecord(value: unknown, index: number): JsonObject { + const source = requireJsonObject(value, `receipt.sources[${index}]`); + const sourceRef = boundedText(source.source_ref, `receipt.sources[${index}].source_ref`, 2048); + requireThat( + SOURCE_REF_RE.test(sourceRef) && !sourceRef.startsWith("file://"), + `receipt.sources[${index}].source_ref must be a non-file provenance URI`, + ); + const contentDigest = boundedText( + source.content_digest, + `receipt.sources[${index}].content_digest`, + 71, + ); + requireThat(SHA256_RE.test(contentDigest), `receipt.sources[${index}].content_digest is invalid`); + return { + source_ref: sourceRef, + source_family: boundedText( + source.source_family, + `receipt.sources[${index}].source_family`, + 128, + ), + basis: requireStringLiteral( + source.basis, + EVIDENCE_BASES, + `receipt.sources[${index}].basis`, + ), + finding: boundedText(source.finding, `receipt.sources[${index}].finding`, 4096), + limitation: source.limitation === null || source.limitation === undefined + ? null + : boundedText(source.limitation, `receipt.sources[${index}].limitation`, 2048), + publication_date: source.publication_date === null || source.publication_date === undefined + ? null + : boundedText( + source.publication_date, + `receipt.sources[${index}].publication_date`, + 64, + ), + accessed_at: boundedText(source.accessed_at, `receipt.sources[${index}].accessed_at`, 64), + content_digest: contentDigest, + }; +} + +export function evaluateExternalEvidenceAdmission(params: JsonObject): JsonObject { + const plan = requireJsonObject(params.plan, "external evidence plan"); + requireThat( + plan.schema_version === EXTERNAL_EVIDENCE_PLAN_SCHEMA_VERSION && plan.status === "ready", + "external evidence admission requires a ready plan", + ); + const request = requireJsonObject(plan.request, "external evidence plan request"); + const selected = requireJsonObject(plan.selected_provider, "external evidence selected provider"); + const receipt = requireJsonObject(params.receipt, "external evidence receipt"); + requireThat( + receipt.schema_version === EXTERNAL_EVIDENCE_RECEIPT_SCHEMA_VERSION, + "external evidence receipt schema is invalid", + ); + requireThat(receipt.request_id === request.request_id, "receipt request_id does not match the plan"); + requireThat(receipt.provider_id === selected.provider_id, "receipt provider_id does not match the plan"); + requireThat(receipt.provider_kind === selected.provider_kind, "receipt provider_kind does not match the plan"); + const status = requireStringLiteral(receipt.status, RECEIPT_STATUSES, "receipt.status"); + requireThat(Array.isArray(receipt.sources), "receipt.sources must be an array"); + requireThat(receipt.sources.length <= 64, "receipt.sources has too many items"); + const sources = receipt.sources.map(sourceRecord); + requireThat( + new Set(sources.map((source) => source.source_ref)).size === sources.length, + "receipt source refs must be unique", + ); + requireThat(status !== "succeeded" || sources.length > 0, "a succeeded receipt requires evidence sources"); + requireThat(status === "succeeded" || sources.length === 0, "failed or no_evidence receipts cannot carry admitted sources"); + const decision = requireJsonObject(params.decision, "parent admission decision"); + const disposition = requireStringLiteral( + decision.disposition, + ADMISSION_DECISIONS, + "decision.disposition", + ); + const reason = boundedText(decision.reason, "decision.reason", 2048); + const admittedRefs = decision.admitted_source_refs === undefined + ? [] + : boundedStrings(decision.admitted_source_refs, "decision.admitted_source_refs", 64, 2048); + const availableRefs = new Set(sources.map((source) => source.source_ref as string)); + requireThat( + admittedRefs.every((sourceRef) => availableRefs.has(sourceRef)), + "decision.admitted_source_refs must refer to receipt sources", + ); + requireThat( + disposition !== "admit" || (status === "succeeded" && admittedRefs.length > 0), + "admit requires a succeeded receipt and at least one admitted source", + ); + requireThat( + disposition !== "reject" || admittedRefs.length === 0, + "reject cannot carry admitted sources", + ); + const admitted = sources.filter((source) => admittedRefs.includes(source.source_ref as string)); + return { + schema_version: EXTERNAL_EVIDENCE_ADMISSION_SCHEMA_VERSION, + request_id: request.request_id, + provider_id: selected.provider_id, + provider_kind: selected.provider_kind, + receipt_status: status, + disposition, + reason, + admitted_source_refs: admittedRefs, + downstream_projection: { + schema_version: "loopx_external_evidence_projection_v0", + request_id: request.request_id, + objective: request.objective, + decision: request.decision, + disposition, + sources: admitted, + summary: boundedText(receipt.summary, "receipt.summary", 4096), + limitations: receipt.limitations === undefined + ? [] + : boundedStrings(receipt.limitations, "receipt.limitations", 16, 1024), + }, + }; +} + +export function projectExternalEvidenceRetirement(params: JsonObject): JsonObject { + const admission = requireJsonObject(params.admission, "external evidence admission"); + requireThat( + admission.schema_version === EXTERNAL_EVIDENCE_ADMISSION_SCHEMA_VERSION, + "external evidence admission schema is invalid", + ); + const admittedRefs = boundedStrings( + admission.admitted_source_refs, + "admission.admitted_source_refs", + 64, + 2048, + ); + const downstreamRefs = params.downstream_source_refs === undefined + ? [] + : boundedStrings(params.downstream_source_refs, "downstream_source_refs", 64, 2048); + const covered = new Set(downstreamRefs); + const missing = admittedRefs.filter((sourceRef) => !covered.has(sourceRef)); + const retireReady = admission.disposition === "reject" || missing.length === 0; + return { + schema_version: EXTERNAL_EVIDENCE_RETIREMENT_SCHEMA_VERSION, + request_id: admission.request_id, + status: retireReady ? "retire_ready" : "retained", + retire_ready: retireReady, + missing_downstream_source_refs: missing, + reason: admission.disposition === "reject" + ? "parent_rejected" + : retireReady + ? "all_admitted_sources_projected" + : "admitted_sources_not_yet_projected", + }; +} diff --git a/loopx/cli.py b/loopx/cli.py index 1d4b093589..d0e08d4a41 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -72,6 +72,10 @@ handle_connector_command, register_connector_commands, ) +from .capabilities.external_research.cli import ( + handle_external_evidence_command, + register_external_evidence_commands, +) from .cli_commands import ( handle_turn_command, handle_benchmark_command, @@ -312,6 +316,8 @@ def build_parser() -> LoopXArgumentParser: register_connector_commands(sub, add_subcommand_format) + register_external_evidence_commands(sub, add_subcommand_format) + register_ml_experiment_commands(sub, add_subcommand_format) _register_demo_commands(sub, add_subcommand_format) @@ -697,6 +703,12 @@ def main(argv: list[str] | None = None) -> int: if connector_result is not None: return connector_result + external_evidence_result = handle_external_evidence_command( + args, output_format=output_format, print_payload=print_payload, + ) + if external_evidence_result is not None: + return external_evidence_result + registry_admin_result = handle_registry_admin_command( args, registry_path=registry_path, diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 8f9d036dd5..c5500c3b56 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -198,6 +198,11 @@ import { } from "./collaboration/return_delivery.ts"; import { normalizeCollaborationRequest } from "./collaboration/semantic_request.ts"; +import { + evaluateExternalEvidenceAdmission, + planExternalEvidenceRequest, + projectExternalEvidenceRetirement, +} from "../capabilities/external_research/contract.ts"; type EffectRuntimeHandler = (params: JsonObject) => unknown | Promise; @@ -659,6 +664,9 @@ export function createEffectRuntimeHandlers( "collaboration.request.normalize", (params) => normalizeCollaborationRequest(params.request), ], + ["external_evidence.plan", planExternalEvidenceRequest], + ["external_evidence.admit", evaluateExternalEvidenceAdmission], + ["external_evidence.retire", projectExternalEvidenceRetirement], [ "manager.return_delivery.normalize_attempt", (params) => normalizeManagerReturnDeliveryAttempt(params.attempt), diff --git a/tests/capabilities/test_external_evidence_cli.py b/tests/capabilities/test_external_evidence_cli.py new file mode 100644 index 0000000000..7c8bbe0251 --- /dev/null +++ b/tests/capabilities/test_external_evidence_cli.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +from loopx.capabilities.external_research import cli + + +def _print_payload(payload, _format, _renderer): + _print_payload.payload = payload + + +def test_plan_projects_registry_as_inventory_not_readiness(tmp_path: Path, monkeypatch) -> None: + provider_path = tmp_path / "providers.json" + provider_path.write_text( + json.dumps( + { + "providers": [ + { + "provider_id": "host:external-research", + "provider_kind": "method", + "protocol": "external_evidence_research_v0", + "declared": True, + "installed": True, + "enabled": True, + "ready": True, + "unavailable_reason": None, + } + ] + } + ), + encoding="utf-8", + ) + registry_path = tmp_path / "connectors.json" + registry_path.write_text( + json.dumps( + { + "schema_version": "connector_registry_v1", + "connectors": [ + { + "id": "official-docs", + "name": "Official docs", + "layer": "L1", + "kind": "documentation", + "status": "supported", + "value_tier": "P1", + } + ], + "usage": {}, + } + ), + encoding="utf-8", + ) + captured = {} + + def fake_runtime(method, params): + captured["method"] = method + captured["params"] = params + return {"schema_version": "loopx_external_evidence_plan_v0", "status": "ready"} + + monkeypatch.setattr(cli, "effect_runtime_result", fake_runtime) + args = argparse.Namespace( + command="external-evidence", + external_evidence_action="plan", + objective="Inspect current behavior", + user_activity="Choose a provider", + decision="Whether to adopt", + evidence_kind=["current_behavior"], + constraint=[], + provider_inventory_json=str(provider_path), + connector_registry=str(registry_path), + preferred_provider_id="host:external-research", + ) + assert cli.handle_external_evidence_command( + args, + output_format=lambda _args: "json", + print_payload=_print_payload, + ) == 0 + assert captured["method"] == "external_evidence.plan" + providers = captured["params"]["providers"] + connector = next(row for row in providers if row["provider_id"] == "connector:official-docs") + assert connector["ready"] is False + assert connector["unavailable_reason"] == "connector_registry_is_inventory_not_readiness" + + +def test_admit_passes_parent_decision_to_typed_owner(tmp_path: Path, monkeypatch) -> None: + plan_path = tmp_path / "plan.json" + receipt_path = tmp_path / "receipt.json" + plan_path.write_text(json.dumps({"schema_version": "loopx_external_evidence_plan_v0"})) + receipt_path.write_text(json.dumps({"schema_version": "loopx_external_evidence_receipt_v0"})) + captured = {} + + def fake_runtime(method, params): + captured["method"] = method + captured["params"] = params + return {"schema_version": "loopx_external_evidence_admission_v0", "disposition": "reject"} + + monkeypatch.setattr(cli, "effect_runtime_result", fake_runtime) + args = argparse.Namespace( + command="external-evidence", + external_evidence_action="admit", + plan_json=str(plan_path), + receipt_json=str(receipt_path), + decision="reject", + reason="Insufficient direct evidence", + admit_source=[], + ) + assert cli.handle_external_evidence_command( + args, + output_format=lambda _args: "json", + print_payload=_print_payload, + ) == 0 + assert captured["method"] == "external_evidence.admit" + assert captured["params"]["decision"]["disposition"] == "reject" + + +def test_source_cli_reaches_typescript_owner(tmp_path: Path) -> None: + provider_path = tmp_path / "providers.json" + provider_path.write_text( + json.dumps( + { + "providers": [ + { + "provider_id": "host:external-research", + "provider_kind": "method", + "protocol": "external_evidence_research_v0", + "declared": True, + "installed": True, + "enabled": True, + "ready": True, + "unavailable_reason": None, + } + ] + } + ), + encoding="utf-8", + ) + result = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "external-evidence", + "plan", + "--objective", + "Inspect current behavior", + "--user-activity", + "Choose a provider", + "--decision", + "Whether to adopt", + "--evidence-kind", + "current_behavior", + "--provider-inventory-json", + str(provider_path), + "--preferred-provider-id", + "host:external-research", + "--format", + "json", + ], + cwd=Path(__file__).resolve().parents[2], + check=True, + capture_output=True, + text=True, + ) + payload = json.loads(result.stdout) + assert payload["schema_version"] == "loopx_external_evidence_plan_v0" + assert payload["status"] == "ready" + assert payload["selected_provider"]["provider_id"] == "host:external-research" diff --git a/tests/control_plane_ts/external_evidence_research.test.ts b/tests/control_plane_ts/external_evidence_research.test.ts new file mode 100644 index 0000000000..6f9e2273e3 --- /dev/null +++ b/tests/control_plane_ts/external_evidence_research.test.ts @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + evaluateExternalEvidenceAdmission, + planExternalEvidenceRequest, + projectExternalEvidenceRetirement, +} from "../../loopx/capabilities/external_research/contract.ts"; + +const request = { + objective: "Compare current provider behavior", + user_activity: "Choose a research provider for a decision", + decision: "Whether the observed evidence is strong enough to adopt", + evidence_kinds: ["current_behavior", "counterexample"], + constraints: ["public sources only"], +}; + +const methodProvider = { + provider_id: "host:external-research", + provider_kind: "method", + protocol: "external_evidence_research_v0", + declared: true, + installed: true, + enabled: true, + ready: true, + unavailable_reason: null, +}; + +const registryOnlyConnector = { + provider_id: "connector:official-docs", + provider_kind: "connector", + protocol: "external_evidence_research_v0", + declared: true, + installed: false, + enabled: false, + ready: false, + unavailable_reason: "connector_registry_is_inventory_not_readiness", +}; + +function plan() { + return planExternalEvidenceRequest({ + request, + providers: [methodProvider, registryOnlyConnector], + preferred_provider_id: "host:external-research", + }); +} + +function receipt(requestId: unknown) { + return { + schema_version: "loopx_external_evidence_receipt_v0", + request_id: requestId, + provider_id: methodProvider.provider_id, + provider_kind: methodProvider.provider_kind, + status: "succeeded", + sources: [ + { + source_ref: "https://example.com/original", + source_family: "example-release", + basis: "observed", + finding: "The current release exposes the required interaction.", + limitation: "The page does not prove backend durability.", + publication_date: "2026-09-19", + accessed_at: "2026-09-20T10:00:00Z", + content_digest: `sha256:${"a".repeat(64)}`, + }, + ], + summary: "One direct source supports the interaction claim.", + limitations: ["No durability test was performed."], + }; +} + +test("plans one ready provider without treating registry presence as readiness", () => { + const result = plan(); + assert.equal(result.status, "ready"); + assert.equal( + (result.selected_provider as Record).provider_id, + "host:external-research", + ); + assert.equal( + (result.execution_envelope as Record).authority, + "read_external_sources_only", + ); + assert.match( + String((result.request as Record).request_id), + /^sha256:[0-9a-f]{64}$/, + ); +}); +test("blocks when every provider is inventory-only", () => { + const result = planExternalEvidenceRequest({ + request, + providers: [registryOnlyConnector], + }); + assert.equal(result.status, "blocked"); + assert.equal(result.selected_provider, null); + assert.equal(result.blocker, "no_ready_provider"); +}); + +test("rejects a provider that claims ready without lifecycle readiness", () => { + assert.throws( + () => planExternalEvidenceRequest({ + request, + providers: [{ ...registryOnlyConnector, ready: true }], + }), + /cannot be ready/, + ); +}); + +test("admits exact source refs and exposes only compact provenance", () => { + const currentPlan = plan(); + const requestId = (currentPlan.request as Record).request_id; + const result = evaluateExternalEvidenceAdmission({ + plan: currentPlan, + receipt: receipt(requestId), + decision: { + disposition: "admit", + reason: "The source directly answers the interaction question.", + admitted_source_refs: ["https://example.com/original"], + }, + }); + assert.equal(result.disposition, "admit"); + const projection = result.downstream_projection as Record; + assert.equal((projection.sources as unknown[]).length, 1); + assert.equal(Object.hasOwn(projection, "raw_content"), false); +}); + +test("admission fails closed on stale plan identity and local file provenance", () => { + const currentPlan = plan(); + const requestId = (currentPlan.request as Record).request_id; + assert.throws( + () => evaluateExternalEvidenceAdmission({ + plan: currentPlan, + receipt: { ...receipt(requestId), request_id: `sha256:${"b".repeat(64)}` }, + decision: { + disposition: "admit", + reason: "stale", + admitted_source_refs: ["https://example.com/original"], + }, + }), + /request_id does not match/, + ); + const localReceipt = receipt(requestId); + localReceipt.sources[0].source_ref = "file:///tmp/raw-transcript"; + assert.throws( + () => evaluateExternalEvidenceAdmission({ + plan: currentPlan, + receipt: localReceipt, + decision: { + disposition: "admit", + reason: "local path", + admitted_source_refs: ["file:///tmp/raw-transcript"], + }, + }), + /non-file provenance URI/, + ); +}); + +test("retirement waits for downstream use of every admitted source", () => { + const currentPlan = plan(); + const requestId = (currentPlan.request as Record).request_id; + const admission = evaluateExternalEvidenceAdmission({ + plan: currentPlan, + receipt: receipt(requestId), + decision: { + disposition: "admit", + reason: "direct evidence", + admitted_source_refs: ["https://example.com/original"], + }, + }); + assert.equal( + projectExternalEvidenceRetirement({ admission, downstream_source_refs: [] }).status, + "retained", + ); + assert.equal( + projectExternalEvidenceRetirement({ + admission, + downstream_source_refs: ["https://example.com/original"], + }).status, + "retire_ready", + ); +}); From 822321a903694dba382eabe2bcd6be62d12bc1f3 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:12:56 +0800 Subject: [PATCH 02/14] docs(rfc): define external evidence provider boundary Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- ...xternal-evidence-research-capability-v0.md | 80 ++++++++++++++++ ...l-evidence-research-capability-v0.zh-CN.md | 68 ++++++++++++++ .../rfcs/loopx-overall-roadmap-v0.md | 1 + .../rfcs/loopx-overall-roadmap-v0.zh-CN.md | 1 + .../capabilities/external_research/README.md | 92 +++++++++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 docs/architecture/rfcs/external-evidence-research-capability-v0.md create mode 100644 docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md create mode 100644 loopx/capabilities/external_research/README.md diff --git a/docs/architecture/rfcs/external-evidence-research-capability-v0.md b/docs/architecture/rfcs/external-evidence-research-capability-v0.md new file mode 100644 index 0000000000..9ff86f94f8 --- /dev/null +++ b/docs/architecture/rfcs/external-evidence-research-capability-v0.md @@ -0,0 +1,80 @@ +# RFC: External Evidence Research Capability v0 + +- Status: Draft implementation slice +- Scope: provider-neutral research planning, provenance admission, projection, + and retirement +- Roadmap: S8 capabilities and domain integration +- Language note: the Chinese version is a semantic mirror; drift is a defect. + +## Problem + +LoopX currently has useful but separate pieces: host research methods, +connector inventory, provider lifecycle, managed Turn contracts, and downstream +evidence consumers. A registry row can say `supported` without proving that the +provider is installed, enabled, ready, called, or accepted. Conversely, a host +research method can produce good evidence without a typed receipt that other +LoopX callers can inspect. + +The product needs one outcome capability, not a generic connector executor: +turn a decision-bound research question into compact evidence whose provenance, +admission, use, and retirement are observable. + +## Decision + +Add capability `external-evidence-research` with protocol +`external_evidence_research_v0` and lifecycle: + +`discover → select → provider execute → provenance receipt → parent admit/reject → downstream projection → retire`. + +The request must name object, user activity, decision, evidence kinds, and +constraints. A provider is selectable only when current readback says all four +of `declared`, `installed`, `enabled`, and `ready`. Provider kinds are `method` +and `connector`; their execution remains with their existing owner. + +The receipt binds the exact request and selected provider. Each admitted source +has a direct non-file reference, source family, evidence basis (`stated`, +`observed`, `tested`, or `inferred`), finding, limitation, relevant dates, and a +content digest. Raw provider content is never part of the Core projection. + +The parent agent explicitly admits or rejects evidence. Rejection can retire; +admission remains retained until downstream readback covers every admitted +source reference. + +## Ownership and TypeScript migration + +This slice follows the TypeScript migration RFC without claiming a whole +control-plane promotion. TypeScript owns the pure typed decisions and is exposed +through the existing effect runtime. Python owns only CLI parsing, local JSON +input, and transport. The PR deletes no active connector path and creates no +second persisted authority. + +Connector registry remains inventory and telemetry. `supported` never maps to +`ready=true`; explicit provider lifecycle observation may override the +inventory-only row for the same provider id. + +## Product surfaces + +- CLI: `external-evidence plan|admit|retire`. +- Managed Turn: the same three effect-runtime methods. +- Frontend/Lark: not changed in this Core slice. A companion slice should render + the same typed plan/admission projection and readback; it must not invent a + second registry or lifecycle. + +## Acceptance + +- inventory-only connectors cannot be selected; +- method and connector providers use one protocol and receipt contract; +- stale request/provider identity, file provenance, and unsupported evidence + basis fail closed; +- admitted source refs are a subset of receipt sources; +- retirement waits for downstream coverage of every admitted source; +- CLI and effect-runtime TypeScript tests pass from the source checkout. + +## Non-goals + +- a universal browser/search engine; +- provider credential storage; +- raw page or transcript persistence; +- automatic evidence admission; +- trading, publishing, or other downstream effect authority; +- treating registration or usage counters as proof of evidence quality. diff --git a/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md b/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md new file mode 100644 index 0000000000..1cd3e094a5 --- /dev/null +++ b/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md @@ -0,0 +1,68 @@ +# RFC:外部证据研究能力 v0 + +- 状态:Draft implementation slice +- 范围:provider-neutral 的研究规划、provenance 准入、投影与退休 +- 路线图:S8 能力与领域集成 +- 语言说明:本文件与英文版语义镜像;语义漂移属于缺陷。 + +## 问题 + +LoopX 已有 host 研究方法、connector 库存、provider 生命周期、managed Turn 合同和 +下游证据消费者,但它们仍是分散的。一条 registry 记录可以显示 `supported`,却不能 +证明 provider 已安装、已启用、ready、被真实调用或被父 Agent 采纳。反过来,host +研究方法也可能产出高质量证据,但没有可供其他 LoopX caller 检查的类型化回执。 + +产品需要的是一个结果能力,而不是通用 connector executor:把面向决策的研究问题 +转换成 provenance、准入、消费与退休均可观察的紧凑证据。 + +## 决策 + +新增 `external-evidence-research` capability,协议为 +`external_evidence_research_v0`,生命周期为: + +`discover → select → provider execute → provenance receipt → parent admit/reject → downstream projection → retire`。 + +请求必须声明对象、用户活动、决策、证据类型与约束。只有当前读回同时证明 +`declared`、`installed`、`enabled`、`ready` 的 provider 才能被选择。provider +分为 `method` 与 `connector`;执行仍归各自既有 owner。 + +回执绑定精确请求与已选 provider。每条被采纳来源都包含直接且非文件型引用、来源 +家族、证据基础(`stated`、`observed`、`tested` 或 `inferred`)、发现、局限、相关 +日期和内容摘要。Core 投影永不携带 provider 原始内容。 + +父 Agent 必须显式采纳或拒绝。拒绝后可以退休;采纳后必须等下游读回覆盖全部被采纳 +source ref,才能退休。 + +## 所有权与 TypeScript 迁移 + +本切片遵循 TypeScript 迁移 RFC,但不宣称整个控制面已 promote。纯类型化决策由 +TypeScript 拥有,并通过既有 effect runtime 暴露;Python 仅拥有 CLI 参数、本地 JSON +输入与 transport。本 PR 不删除现有 connector 路径,也不建立第二份持久权威。 + +Connector registry 继续只拥有库存与遥测。`supported` 绝不映射为 `ready=true`;只有 +显式 provider 生命周期观察,才可覆盖同 provider id 的 inventory-only 行。 + +## 产品入口 + +- CLI:`external-evidence plan|admit|retire`; +- Managed Turn:复用同三个 effect-runtime 方法; +- Frontend/Lark:本 Core 切片不修改。后续 companion slice 只渲染同源 plan/admission + 投影与读回,不建立第二个 registry 或生命周期。 + +## 验收 + +- inventory-only connector 不可被选择; +- method 与 connector provider 使用同一协议与回执; +- 过期请求/provider 身份、文件 provenance、未知证据基础均 fail closed; +- 被采纳 source ref 必须是回执来源的子集; +- 全部被采纳来源完成下游覆盖前不得退休; +- CLI 与 effect-runtime TypeScript 测试在源码 checkout 中通过。 + +## 非目标 + +- 通用浏览器或搜索引擎; +- provider 凭据存储; +- 原始页面/逐字稿持久化; +- 自动采纳证据; +- 交易、发布或其他下游 effect 权限; +- 把注册或使用计数当作证据质量证明。 diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 023aff1773..72ead1eb23 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -250,6 +250,7 @@ This maps **all 30 primary RFCs** at the scope baseline, counting language mirro | [Provider-Neutral Turn-Start Inbox Hook v0](provider-neutral-turn-start-inbox-hook-v0.md) | S3/S8 | Implemented with explicit configuration | P0 hardening: bounded read→semantic triage→ACK/replay; preserve default-off and private provider cursors | | [Provider-Neutral Post-Writeback Capability Hooks v0](provider-neutral-post-writeback-capability-hooks-v0.md) | S3/S8 | Draft; first periodic-report vertical implemented | P1: R3 return/successors reuse durable intent; isolate hook failure, no primary-transaction coupling/direct effects | | [Agent IM, LoopX, And OpenViking Collaboration v0](agent-im-openviking-collaboration-v0.md) | S3/S6/S8 | Draft; three-owner integration unqualified | P1/P2: separate IM delivery, LoopX work authority and OV context; reconnect/revoke/source-loss cases | +| [External Evidence Research Capability v0](external-evidence-research-capability-v0.md) | S8/S11 | Draft; typed Core plan/admission/retirement and CLI slice implemented | P1: qualify one host-method and one connector execution with the same provenance receipt; then add frontend/Lark projection companions | | [Per-Goal Usage, Token, and Cost Surfacing v0](goal-usage-token-cost-v0.md) | S7/S5 | Draft; Codex aggregate/cost display slice exists | P0 observation→P1 provider coverage: unknown is not zero, deduplicate accounting, price source/freshness; usage grants no budget | | [Intelligent Review and Dynamic Presentation Surfaces v0](intelligent-review-presentation-surfaces-v0.md) | S5 | Draft; action/attention verticals and local delivery-chain/acceptance review implemented | P1: cross-channel disclosure and governed amendment/settlement review; local visibility does not qualify G2 | | [Human Attention Wishlist v0](human-attention-wishlist-v0.md) | S5/S11 | Draft; Held | P3: reopen only on repeated second real need; sidecar cannot alter gates/quota/scheduling | diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index 6c46533375..65149539ef 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -219,6 +219,7 @@ canonical Todos 和相关 PR。 | [Provider-Neutral Turn-Start Inbox Hook v0](provider-neutral-turn-start-inbox-hook-v0.md) | S3/S8 | 显式配置下已实现 | P0 硬化:有界读→语义 triage→ACK/replay;默认关闭与 provider 私有 cursor 保持 | | [Provider-Neutral Post-Writeback Capability Hooks v0](provider-neutral-post-writeback-capability-hooks-v0.zh-CN.md) | S3/S8 | Draft;periodic-report 首个 vertical 已实现 | P1:R3 返回/后继复用 durable intent;hook 失败隔离,不能加入主事务或直接执行 effect | | [Agent IM, LoopX, And OpenViking Collaboration v0](agent-im-openviking-collaboration-v0.md) | S3/S6/S8 | Draft;三 owner 集成仍待资格 | P1/P2:IM 投递、LoopX work authority、OV context 分离;断线重放/权限撤销/来源失效 | +| [外部证据研究能力 v0](external-evidence-research-capability-v0.zh-CN.md) | S8/S11 | Draft;已实现类型化 Core plan/admission/retirement 与 CLI 切片 | P1:用同一 provenance 回执分别验收一个 host-method 与一个 connector 的真实执行,再补 frontend/Lark 同源投影 | | [Per-Goal Usage, Token, and Cost Surfacing v0](goal-usage-token-cost-v0.md) | S7/S5 | Draft;Codex aggregate/cost 展示已有切片 | P0 观测→P1 多 provider:未知不作零、重复扣费去重、价格来源/时效;usage 不自动授权预算 | | [Intelligent Review and Dynamic Presentation Surfaces v0](intelligent-review-presentation-surfaces-v0.zh-CN.md) | S5 | Draft;action/attention 纵切及本地交付链/验收复盘已实现 | P1:跨渠道披露和受治理的修订/结算复盘;本地可见性不代表 G2 通过 | | [Human Attention Wishlist v0](human-attention-wishlist-v0.zh-CN.md) | S5/S11 | Draft;Held | P3:第二个重复真实需求出现才重开;sidecar 不改变 gate/quota/调度 | diff --git a/loopx/capabilities/external_research/README.md b/loopx/capabilities/external_research/README.md new file mode 100644 index 0000000000..d78a92e5e4 --- /dev/null +++ b/loopx/capabilities/external_research/README.md @@ -0,0 +1,92 @@ +# External Evidence Research / 外部证据研究 + +`external-evidence-research` is LoopX's provider-neutral contract for turning a +decision-bound research question into compact, auditable evidence. It unifies +two provider classes without pretending they are the same implementation: + +- a host method such as the `external-research` skill; and +- a connector provider such as an official-document search integration. + +`external-evidence-research` 是 LoopX 面向决策的通用外部证据合同。它统一两类 +provider 的调用与回执语义,但不把两者伪装成同一种实现: + +- host 提供的研究方法,例如 `external-research` skill; +- connector provider,例如官方文档搜索连接器。 + +## Contract / 合同 + +The lifecycle is: + +1. `plan`: bind **object + user activity + decision** and required evidence + kinds to one provider that is currently declared, installed, enabled, and + ready; +2. provider execution: the selected host method or connector reads external + sources under its own adapter and permission boundary; +3. `admit`: validate the exact request/provider identity and source-level + provenance, then record the parent agent's admit/reject decision; +4. downstream projection: pass only compact findings, limitations, direct + references, evidence basis, dates, and content digests; +5. `retire`: retire rejected evidence immediately, or admitted evidence only + after every admitted source reference appears in downstream readback. + +生命周期为:`plan` 绑定“对象 + 用户活动 + 决策”并选择当前真实 ready 的 +provider;provider 在自己的权限边界内执行;`admit` 校验请求、provider 与逐来源 +provenance,并记录父 Agent 的采纳/拒绝;下游只投影紧凑证据;被采纳的来源全部完成 +下游读回后才可 `retire`。 + +The connector registry is only inventory and telemetry. A connector row marked +`supported` is projected as `ready=false` until a current provider lifecycle +readback proves installation, enablement, and readiness. Registration never +counts as execution or evidence coverage. + +Connector registry 仅拥有库存与遥测。即使 connector 标为 `supported`,在当前 +provider 生命周期读回证明 installed/enabled/ready 之前仍投影为 `ready=false`。 +注册不等于调用,更不等于证据覆盖。 + +## CLI / 命令行 + +```bash +loopx external-evidence plan \ + --objective "Compare current behavior" \ + --user-activity "Choose an implementation" \ + --decision "Whether to adopt it" \ + --evidence-kind current_behavior \ + --evidence-kind counterexample \ + --provider-inventory-json providers.json \ + --format json + +loopx external-evidence admit \ + --plan-json plan.json \ + --receipt-json receipt.json \ + --decision admit \ + --reason "Direct source answers the decision" \ + --admit-source https://example.com/original \ + --format json + +loopx external-evidence retire \ + --admission-json admission.json \ + --downstream-source https://example.com/original \ + --format json +``` + +Provider inventory is an observation, not authority. A ready provider row uses +protocol `external_evidence_research_v0` and carries explicit `declared`, +`installed`, `enabled`, and `ready` booleans. Raw pages, transcripts, cookies, +credentials, and private notes remain provider-private. + +## Ownership and product surfaces / 归属与产品入口 + +- The TypeScript contract owns request identity, provider admission, provenance + validation, parent admission, compact projection, and retirement readiness. +- Python adapts the existing CLI and effect-runtime transport; it does not + reimplement those decisions. +- Managed Turn callers can invoke the same effect-runtime methods: + `external_evidence.plan`, `external_evidence.admit`, and + `external_evidence.retire`. +- Frontend and Lark are companion slices. They should render the same plan and + admission projection; neither gets an independent provider registry or + evidence state machine. + +TypeScript 是请求身份、provider 准入、provenance 校验、父 Agent 采纳、紧凑投影与 +退休条件的唯一语义 owner。Python 仅适配 CLI 与 effect-runtime transport。Managed +Turn 复用同一方法;frontend/Lark 后续只渲染同源投影,不新建 registry 或状态机。 From 6c4ca5286ff65efe93e23ffe2a8efd539a0759d1 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:14:32 +0800 Subject: [PATCH 03/14] fix(capability): bind external evidence receipt identity Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../external-evidence-research-capability-v0.md | 5 +++-- ...xternal-evidence-research-capability-v0.zh-CN.md | 2 +- loopx/capabilities/external_research/README.md | 7 ++++--- loopx/capabilities/external_research/contract.ts | 13 +++++++++++++ .../external_evidence_research.test.ts | 3 +++ 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/architecture/rfcs/external-evidence-research-capability-v0.md b/docs/architecture/rfcs/external-evidence-research-capability-v0.md index 9ff86f94f8..881496d878 100644 --- a/docs/architecture/rfcs/external-evidence-research-capability-v0.md +++ b/docs/architecture/rfcs/external-evidence-research-capability-v0.md @@ -31,8 +31,9 @@ constraints. A provider is selectable only when current readback says all four of `declared`, `installed`, `enabled`, and `ready`. Provider kinds are `method` and `connector`; their execution remains with their existing owner. -The receipt binds the exact request and selected provider. Each admitted source -has a direct non-file reference, source family, evidence basis (`stated`, +The receipt binds the exact request, selected provider, completion time, and a +digest over the complete receipt. Each admitted source has a direct non-file +reference, source family, evidence basis (`stated`, `observed`, `tested`, or `inferred`), finding, limitation, relevant dates, and a content digest. Raw provider content is never part of the Core projection. diff --git a/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md b/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md index 1cd3e094a5..2efbc4e5e1 100644 --- a/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md +++ b/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md @@ -26,7 +26,7 @@ LoopX 已有 host 研究方法、connector 库存、provider 生命周期、mana `declared`、`installed`、`enabled`、`ready` 的 provider 才能被选择。provider 分为 `method` 与 `connector`;执行仍归各自既有 owner。 -回执绑定精确请求与已选 provider。每条被采纳来源都包含直接且非文件型引用、来源 +回执绑定精确请求、已选 provider、完成时间与完整回执 digest。每条被采纳来源都包含直接且非文件型引用、来源 家族、证据基础(`stated`、`observed`、`tested` 或 `inferred`)、发现、局限、相关 日期和内容摘要。Core 投影永不携带 provider 原始内容。 diff --git a/loopx/capabilities/external_research/README.md b/loopx/capabilities/external_research/README.md index d78a92e5e4..c944fca47a 100644 --- a/loopx/capabilities/external_research/README.md +++ b/loopx/capabilities/external_research/README.md @@ -23,15 +23,16 @@ The lifecycle is: 2. provider execution: the selected host method or connector reads external sources under its own adapter and permission boundary; 3. `admit`: validate the exact request/provider identity and source-level - provenance, then record the parent agent's admit/reject decision; + provenance, bind the complete receipt digest, then record the parent agent's + admit/reject decision; 4. downstream projection: pass only compact findings, limitations, direct references, evidence basis, dates, and content digests; 5. `retire`: retire rejected evidence immediately, or admitted evidence only after every admitted source reference appears in downstream readback. 生命周期为:`plan` 绑定“对象 + 用户活动 + 决策”并选择当前真实 ready 的 -provider;provider 在自己的权限边界内执行;`admit` 校验请求、provider 与逐来源 -provenance,并记录父 Agent 的采纳/拒绝;下游只投影紧凑证据;被采纳的来源全部完成 +provider;provider 在自己的权限边界内执行;`admit` 校验请求、provider、完成时间、 +完整 receipt digest 与逐来源 provenance,并记录父 Agent 的采纳/拒绝;下游只投影紧凑证据;被采纳的来源全部完成 下游读回后才可 `retire`。 The connector registry is only inventory and telemetry. A connector row marked diff --git a/loopx/capabilities/external_research/contract.ts b/loopx/capabilities/external_research/contract.ts index dc0285928b..7488ff0fbf 100644 --- a/loopx/capabilities/external_research/contract.ts +++ b/loopx/capabilities/external_research/contract.ts @@ -264,6 +264,8 @@ export function evaluateExternalEvidenceAdmission(params: JsonObject): JsonObjec ); requireThat(status !== "succeeded" || sources.length > 0, "a succeeded receipt requires evidence sources"); requireThat(status === "succeeded" || sources.length === 0, "failed or no_evidence receipts cannot carry admitted sources"); + const completedAt = boundedText(receipt.completed_at, "receipt.completed_at", 64); + const receiptDigest = digest(receipt); const decision = requireJsonObject(params.decision, "parent admission decision"); const disposition = requireStringLiteral( decision.disposition, @@ -288,12 +290,22 @@ export function evaluateExternalEvidenceAdmission(params: JsonObject): JsonObjec "reject cannot carry admitted sources", ); const admitted = sources.filter((source) => admittedRefs.includes(source.source_ref as string)); + const admissionIdentity = { + request_id: request.request_id, + provider_id: selected.provider_id, + receipt_digest: receiptDigest, + disposition, + admitted_source_refs: admittedRefs, + }; return { schema_version: EXTERNAL_EVIDENCE_ADMISSION_SCHEMA_VERSION, + admission_id: digest(admissionIdentity), request_id: request.request_id, provider_id: selected.provider_id, provider_kind: selected.provider_kind, receipt_status: status, + receipt_digest: receiptDigest, + completed_at: completedAt, disposition, reason, admitted_source_refs: admittedRefs, @@ -332,6 +344,7 @@ export function projectExternalEvidenceRetirement(params: JsonObject): JsonObjec const retireReady = admission.disposition === "reject" || missing.length === 0; return { schema_version: EXTERNAL_EVIDENCE_RETIREMENT_SCHEMA_VERSION, + admission_id: admission.admission_id, request_id: admission.request_id, status: retireReady ? "retire_ready" : "retained", retire_ready: retireReady, diff --git a/tests/control_plane_ts/external_evidence_research.test.ts b/tests/control_plane_ts/external_evidence_research.test.ts index 6f9e2273e3..f0fd6225d3 100644 --- a/tests/control_plane_ts/external_evidence_research.test.ts +++ b/tests/control_plane_ts/external_evidence_research.test.ts @@ -66,6 +66,7 @@ function receipt(requestId: unknown) { ], summary: "One direct source supports the interaction claim.", limitations: ["No durability test was performed."], + completed_at: "2026-09-20T10:01:00Z", }; } @@ -118,6 +119,8 @@ test("admits exact source refs and exposes only compact provenance", () => { }, }); assert.equal(result.disposition, "admit"); + assert.match(String(result.admission_id), /^sha256:[0-9a-f]{64}$/); + assert.match(String(result.receipt_digest), /^sha256:[0-9a-f]{64}$/); const projection = result.downstream_projection as Record; assert.equal((projection.sources as unknown[]).length, 1); assert.equal(Object.hasOwn(projection, "raw_content"), false); From 02e16ca3d6a3a73cebc89dc29a0ce91e755064e7 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:29:55 +0800 Subject: [PATCH 04/14] fix(runtime): keep external evidence owner in package boundary Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../capabilities/external_evidence.ts} | 6 +++--- loopx/control_plane/effect_runtime_handlers.ts | 2 +- tests/control_plane_ts/external_evidence_research.test.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename loopx/{capabilities/external_research/contract.ts => control_plane/capabilities/external_evidence.ts} (98%) diff --git a/loopx/capabilities/external_research/contract.ts b/loopx/control_plane/capabilities/external_evidence.ts similarity index 98% rename from loopx/capabilities/external_research/contract.ts rename to loopx/control_plane/capabilities/external_evidence.ts index 7488ff0fbf..5d1a915b4b 100644 --- a/loopx/capabilities/external_research/contract.ts +++ b/loopx/control_plane/capabilities/external_evidence.ts @@ -1,12 +1,12 @@ import { createHash } from "node:crypto"; -import type { JsonObject } from "../../control_plane/effect_program.ts"; -import { EffectRuntimeRequestError } from "../../control_plane/effect_runtime_errors.ts"; +import type { JsonObject } from "../effect_program.ts"; +import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { requireJsonObject, requireNonEmptyString, requireStringLiteral, -} from "../../control_plane/runtime_decode.ts"; +} from "../runtime_decode.ts"; export const EXTERNAL_EVIDENCE_REQUEST_SCHEMA_VERSION = "loopx_external_evidence_request_v0"; diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index c5500c3b56..f731bb8173 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -202,7 +202,7 @@ import { evaluateExternalEvidenceAdmission, planExternalEvidenceRequest, projectExternalEvidenceRetirement, -} from "../capabilities/external_research/contract.ts"; +} from "./capabilities/external_evidence.ts"; type EffectRuntimeHandler = (params: JsonObject) => unknown | Promise; diff --git a/tests/control_plane_ts/external_evidence_research.test.ts b/tests/control_plane_ts/external_evidence_research.test.ts index f0fd6225d3..1ef7d31b4f 100644 --- a/tests/control_plane_ts/external_evidence_research.test.ts +++ b/tests/control_plane_ts/external_evidence_research.test.ts @@ -5,7 +5,7 @@ import { evaluateExternalEvidenceAdmission, planExternalEvidenceRequest, projectExternalEvidenceRetirement, -} from "../../loopx/capabilities/external_research/contract.ts"; +} from "../../loopx/control_plane/capabilities/external_evidence.ts"; const request = { objective: "Compare current provider behavior", From 6bdabad3f66b8f8950c8f90d62007a99a17cbb2a Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:46:59 +0800 Subject: [PATCH 05/14] feat(capability): add external evidence discovery Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- ...xternal-evidence-research-capability-v0.md | 12 +- ...l-evidence-research-capability-v0.zh-CN.md | 9 +- .../capabilities/external_research/README.md | 26 +++-- .../external_research/catalog_entry.py | 15 ++- loopx/capabilities/external_research/cli.py | 46 +++++++- .../capabilities/external_evidence.ts | 47 +++++++- .../control_plane/effect_runtime_handlers.ts | 2 + .../test_external_evidence_cli.py | 110 ++++++++++++++++++ .../external_evidence_research.test.ts | 30 +++++ 9 files changed, 269 insertions(+), 28 deletions(-) diff --git a/docs/architecture/rfcs/external-evidence-research-capability-v0.md b/docs/architecture/rfcs/external-evidence-research-capability-v0.md index 881496d878..64aa23238b 100644 --- a/docs/architecture/rfcs/external-evidence-research-capability-v0.md +++ b/docs/architecture/rfcs/external-evidence-research-capability-v0.md @@ -31,6 +31,12 @@ constraints. A provider is selectable only when current readback says all four of `declared`, `installed`, `enabled`, and `ready`. Provider kinds are `method` and `connector`; their execution remains with their existing owner. +Discovery is a read-only typed projection. It reports method/connector counts, +ready and unavailable counts, and ready provider ids. It also carries an +explicit truth contract: registry presence and `supported` status are not +readiness, and discovery itself observes neither provider execution nor +evidence coverage. + The receipt binds the exact request, selected provider, completion time, and a digest over the complete receipt. Each admitted source has a direct non-file reference, source family, evidence basis (`stated`, @@ -55,8 +61,8 @@ inventory-only row for the same provider id. ## Product surfaces -- CLI: `external-evidence plan|admit|retire`. -- Managed Turn: the same three effect-runtime methods. +- CLI: `external-evidence discover|plan|admit|retire`. +- Managed Turn: the same four effect-runtime methods. - Frontend/Lark: not changed in this Core slice. A companion slice should render the same typed plan/admission projection and readback; it must not invent a second registry or lifecycle. @@ -64,6 +70,8 @@ inventory-only row for the same provider id. ## Acceptance - inventory-only connectors cannot be selected; +- discovery distinguishes empty, inventory-only, and ready inventories without + claiming execution or evidence coverage; - method and connector providers use one protocol and receipt contract; - stale request/provider identity, file provenance, and unsupported evidence basis fail closed; diff --git a/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md b/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md index 2efbc4e5e1..526c115ede 100644 --- a/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md +++ b/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md @@ -26,6 +26,10 @@ LoopX 已有 host 研究方法、connector 库存、provider 生命周期、mana `declared`、`installed`、`enabled`、`ready` 的 provider 才能被选择。provider 分为 `method` 与 `connector`;执行仍归各自既有 owner。 +Discovery 是只读类型化投影。它报告 method/connector 数量、ready/unavailable 数量和 +ready provider id,并携带显式真值合同:registry presence 与 `supported` 都不等于 +readiness,discovery 本身既不证明 provider 已执行,也不证明已有证据覆盖。 + 回执绑定精确请求、已选 provider、完成时间与完整回执 digest。每条被采纳来源都包含直接且非文件型引用、来源 家族、证据基础(`stated`、`observed`、`tested` 或 `inferred`)、发现、局限、相关 日期和内容摘要。Core 投影永不携带 provider 原始内容。 @@ -44,14 +48,15 @@ Connector registry 继续只拥有库存与遥测。`supported` 绝不映射为 ## 产品入口 -- CLI:`external-evidence plan|admit|retire`; -- Managed Turn:复用同三个 effect-runtime 方法; +- CLI:`external-evidence discover|plan|admit|retire`; +- Managed Turn:复用同四个 effect-runtime 方法; - Frontend/Lark:本 Core 切片不修改。后续 companion slice 只渲染同源 plan/admission 投影与读回,不建立第二个 registry 或生命周期。 ## 验收 - inventory-only connector 不可被选择; +- discovery 能区分 empty、inventory-only 与 ready 库存,且不冒充执行或证据覆盖; - method 与 connector provider 使用同一协议与回执; - 过期请求/provider 身份、文件 provenance、未知证据基础均 fail closed; - 被采纳 source ref 必须是回执来源的子集; diff --git a/loopx/capabilities/external_research/README.md b/loopx/capabilities/external_research/README.md index c944fca47a..d69a22aeed 100644 --- a/loopx/capabilities/external_research/README.md +++ b/loopx/capabilities/external_research/README.md @@ -17,21 +17,25 @@ provider 的调用与回执语义,但不把两者伪装成同一种实现: The lifecycle is: -1. `plan`: bind **object + user activity + decision** and required evidence +1. `discover`: project method and connector inventory plus current readiness, + while explicitly keeping registry presence, execution, and evidence coverage + false unless separately observed; +2. `plan`: bind **object + user activity + decision** and required evidence kinds to one provider that is currently declared, installed, enabled, and ready; -2. provider execution: the selected host method or connector reads external +3. provider execution: the selected host method or connector reads external sources under its own adapter and permission boundary; -3. `admit`: validate the exact request/provider identity and source-level +4. `admit`: validate the exact request/provider identity and source-level provenance, bind the complete receipt digest, then record the parent agent's admit/reject decision; -4. downstream projection: pass only compact findings, limitations, direct +5. downstream projection: pass only compact findings, limitations, direct references, evidence basis, dates, and content digests; -5. `retire`: retire rejected evidence immediately, or admitted evidence only +6. `retire`: retire rejected evidence immediately, or admitted evidence only after every admitted source reference appears in downstream readback. -生命周期为:`plan` 绑定“对象 + 用户活动 + 决策”并选择当前真实 ready 的 -provider;provider 在自己的权限边界内执行;`admit` 校验请求、provider、完成时间、 +生命周期为:`discover` 只读投影 method/connector 库存与当前 readiness,并明确区分 +registry presence、真实执行和证据覆盖;`plan` 绑定“对象 + 用户活动 + 决策”并选择当前 +真实 ready 的 provider;provider 在自己的权限边界内执行;`admit` 校验请求、provider、完成时间、 完整 receipt digest 与逐来源 provenance,并记录父 Agent 的采纳/拒绝;下游只投影紧凑证据;被采纳的来源全部完成 下游读回后才可 `retire`。 @@ -47,6 +51,10 @@ provider 生命周期读回证明 installed/enabled/ready 之前仍投影为 `re ## CLI / 命令行 ```bash +loopx external-evidence discover \ + --connector-registry \ + --format json + loopx external-evidence plan \ --objective "Compare current behavior" \ --user-activity "Choose an implementation" \ @@ -82,12 +90,12 @@ credentials, and private notes remain provider-private. - Python adapts the existing CLI and effect-runtime transport; it does not reimplement those decisions. - Managed Turn callers can invoke the same effect-runtime methods: - `external_evidence.plan`, `external_evidence.admit`, and + `external_evidence.discover`, `external_evidence.plan`, `external_evidence.admit`, and `external_evidence.retire`. - Frontend and Lark are companion slices. They should render the same plan and admission projection; neither gets an independent provider registry or evidence state machine. -TypeScript 是请求身份、provider 准入、provenance 校验、父 Agent 采纳、紧凑投影与 +TypeScript 是 discovery 真值边界、请求身份、provider 准入、provenance 校验、父 Agent 采纳、紧凑投影与 退休条件的唯一语义 owner。Python 仅适配 CLI 与 effect-runtime transport。Managed Turn 复用同一方法;frontend/Lark 后续只渲染同源投影,不新建 registry 或状态机。 diff --git a/loopx/capabilities/external_research/catalog_entry.py b/loopx/capabilities/external_research/catalog_entry.py index 417f50af21..aad737d02d 100644 --- a/loopx/capabilities/external_research/catalog_entry.py +++ b/loopx/capabilities/external_research/catalog_entry.py @@ -20,15 +20,20 @@ "method or a connector provider, with source-level provenance" ), "user_value": ( - "Plan one evidence request, select only a currently ready provider, and admit " - "or reject a compact provenance receipt without copying raw provider content." + "Discover method and connector inventory, select only a currently ready provider, " + "and admit or reject a compact provenance receipt without copying raw provider content." ), "next_real_step": ( - "run `loopx external-evidence plan --help`, then provide a current provider " - "inventory and admit the returned provider receipt" + "run `loopx external-evidence discover --connector-registry`, then provide a current " + "provider inventory to plan and admit the returned provider receipt" ), - "entry_command": "loopx external-evidence plan --help", + "entry_command": "loopx external-evidence discover --help", "commands": [ + { + "command": "loopx external-evidence discover --connector-registry", + "purpose": "Project method and connector inventory without claiming readiness or execution.", + "write_boundary": "read-only", + }, { "command": "loopx external-evidence plan ... --provider-inventory-json providers.json", "purpose": "Bind object, user activity, decision, and evidence kinds to one ready provider.", diff --git a/loopx/capabilities/external_research/cli.py b/loopx/capabilities/external_research/cli.py index e3b5b474f0..16fea3baec 100644 --- a/loopx/capabilities/external_research/cli.py +++ b/loopx/capabilities/external_research/cli.py @@ -40,9 +40,10 @@ def _provider_inventory(value: Mapping[str, Any]) -> list[dict[str, object]]: def _connector_inventory(path_text: str | None) -> list[dict[str, object]]: - if not path_text: + if path_text is None: return [] - state = load_connector_registry(Path(path_text).expanduser()) + registry_path = None if path_text == "" else Path(path_text).expanduser() + state = load_connector_registry(registry_path) return [ { "provider_id": f"connector:{connector['id']}", @@ -93,6 +94,17 @@ def _render(payload: dict[str, object]) -> str: selected = payload.get("selected_provider") if isinstance(selected, Mapping): lines.append(f"- selected_provider: `{selected.get('provider_id')}`") + summary = payload.get("summary") + if isinstance(summary, Mapping): + for field in ( + "provider_count", + "method_count", + "connector_count", + "ready_count", + "unavailable_count", + ): + if field in summary: + lines.append(f"- {field}: `{summary.get(field)}`") return "\n".join(lines) + "\n" @@ -106,6 +118,14 @@ def register_external_evidence_commands( ) actions = parser.add_subparsers(dest="external_evidence_action", required=True) + discover = actions.add_parser( + "discover", + help="Project method and connector inventory without claiming execution readiness.", + ) + discover.add_argument("--provider-inventory-json") + discover.add_argument("--connector-registry", nargs="?", const="") + add_subcommand_format(discover) + plan = actions.add_parser("plan", help="Select one currently ready evidence provider.") plan.add_argument("--objective", required=True) plan.add_argument("--user-activity", required=True) @@ -113,7 +133,7 @@ def register_external_evidence_commands( plan.add_argument("--evidence-kind", action="append", required=True) plan.add_argument("--constraint", action="append", default=[]) plan.add_argument("--provider-inventory-json", required=True) - plan.add_argument("--connector-registry") + plan.add_argument("--connector-registry", nargs="?", const="") plan.add_argument("--preferred-provider-id") add_subcommand_format(plan) @@ -140,7 +160,23 @@ def handle_external_evidence_command( if args.command != "external-evidence": return None try: - if args.external_evidence_action == "plan": + if args.external_evidence_action == "discover": + providers = _merge_providers( + _connector_inventory(args.connector_registry), + [] + if args.provider_inventory_json is None + else _provider_inventory( + _load_object( + args.provider_inventory_json, + label="external evidence provider inventory", + ) + ), + ) + payload = effect_runtime_result( + "external_evidence.discover", + {"providers": providers}, + ) + elif args.external_evidence_action == "plan": inventory = _load_object( args.provider_inventory_json, label="external evidence provider inventory", @@ -188,7 +224,7 @@ def handle_external_evidence_command( }, ) else: - raise ValueError("external-evidence requires plan, admit, or retire") + raise ValueError("external-evidence requires discover, plan, admit, or retire") except (RuntimeError, ValueError) as exc: payload = { "ok": False, diff --git a/loopx/control_plane/capabilities/external_evidence.ts b/loopx/control_plane/capabilities/external_evidence.ts index 5d1a915b4b..82e2a6655b 100644 --- a/loopx/control_plane/capabilities/external_evidence.ts +++ b/loopx/control_plane/capabilities/external_evidence.ts @@ -10,6 +10,8 @@ import { export const EXTERNAL_EVIDENCE_REQUEST_SCHEMA_VERSION = "loopx_external_evidence_request_v0"; +export const EXTERNAL_EVIDENCE_DISCOVERY_SCHEMA_VERSION = + "loopx_external_evidence_discovery_v0"; export const EXTERNAL_EVIDENCE_PLAN_SCHEMA_VERSION = "loopx_external_evidence_plan_v0"; export const EXTERNAL_EVIDENCE_RECEIPT_SCHEMA_VERSION = @@ -147,15 +149,50 @@ function normalizeProvider(value: unknown, index: number): JsonObject { }; } -export function planExternalEvidenceRequest(params: JsonObject): JsonObject { - const request = normalizeRequest(params.request); - requireThat(Array.isArray(params.providers), "providers must be an array"); - requireThat(params.providers.length <= 64, "providers has too many items"); - const providers = params.providers.map(normalizeProvider); +function normalizeProviders(value: unknown): JsonObject[] { + requireThat(Array.isArray(value), "providers must be an array"); + requireThat(value.length <= 64, "providers has too many items"); + const providers = value.map(normalizeProvider); requireThat( new Set(providers.map((provider) => provider.provider_id)).size === providers.length, "provider ids must be unique", ); + return providers; +} + +export function projectExternalEvidenceDiscovery(params: JsonObject): JsonObject { + const providers = normalizeProviders(params.providers); + const readyProviders = providers.filter((provider) => provider.ready === true); + const methodCount = providers.filter((provider) => provider.provider_kind === "method").length; + const connectorCount = providers.length - methodCount; + return { + schema_version: EXTERNAL_EVIDENCE_DISCOVERY_SCHEMA_VERSION, + status: providers.length === 0 + ? "empty" + : readyProviders.length > 0 + ? "ready" + : "inventory_only", + providers, + summary: { + provider_count: providers.length, + method_count: methodCount, + connector_count: connectorCount, + ready_count: readyProviders.length, + unavailable_count: providers.length - readyProviders.length, + }, + ready_provider_ids: readyProviders.map((provider) => provider.provider_id), + truth_contract: { + registry_presence_is_readiness: false, + supported_status_is_readiness: false, + execution_observed: false, + evidence_coverage_observed: false, + }, + }; +} + +export function planExternalEvidenceRequest(params: JsonObject): JsonObject { + const request = normalizeRequest(params.request); + const providers = normalizeProviders(params.providers); const preferredProviderId = params.preferred_provider_id === undefined || params.preferred_provider_id === null ? null diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index f731bb8173..b7141ab88c 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -201,6 +201,7 @@ import { normalizeCollaborationRequest } from "./collaboration/semantic_request. import { evaluateExternalEvidenceAdmission, planExternalEvidenceRequest, + projectExternalEvidenceDiscovery, projectExternalEvidenceRetirement, } from "./capabilities/external_evidence.ts"; @@ -664,6 +665,7 @@ export function createEffectRuntimeHandlers( "collaboration.request.normalize", (params) => normalizeCollaborationRequest(params.request), ], + ["external_evidence.discover", projectExternalEvidenceDiscovery], ["external_evidence.plan", planExternalEvidenceRequest], ["external_evidence.admit", evaluateExternalEvidenceAdmission], ["external_evidence.retire", projectExternalEvidenceRetirement], diff --git a/tests/capabilities/test_external_evidence_cli.py b/tests/capabilities/test_external_evidence_cli.py index 7c8bbe0251..bb718cd78b 100644 --- a/tests/capabilities/test_external_evidence_cli.py +++ b/tests/capabilities/test_external_evidence_cli.py @@ -13,6 +13,69 @@ def _print_payload(payload, _format, _renderer): _print_payload.payload = payload +def test_discover_projects_connector_registry_as_inventory_only( + tmp_path: Path, monkeypatch +) -> None: + registry_path = tmp_path / "connectors.json" + registry_path.write_text( + json.dumps( + { + "schema_version": "connector_registry_v1", + "connectors": [ + { + "id": "official-docs", + "name": "Official docs", + "layer": "L1", + "kind": "documentation", + "status": "supported", + "value_tier": "P1", + } + ], + "usage": {}, + } + ), + encoding="utf-8", + ) + captured = {} + + def fake_runtime(method, params): + captured["method"] = method + captured["params"] = params + return { + "schema_version": "loopx_external_evidence_discovery_v0", + "status": "inventory_only", + } + + monkeypatch.setattr(cli, "effect_runtime_result", fake_runtime) + args = argparse.Namespace( + command="external-evidence", + external_evidence_action="discover", + provider_inventory_json=None, + connector_registry=str(registry_path), + ) + assert cli.handle_external_evidence_command( + args, + output_format=lambda _args: "json", + print_payload=_print_payload, + ) == 0 + assert captured["method"] == "external_evidence.discover" + connector = next( + provider + for provider in captured["params"]["providers"] + if provider["provider_id"] == "connector:official-docs" + ) + assert connector == { + "provider_id": "connector:official-docs", + "provider_kind": "connector", + "protocol": "external_evidence_research_v0", + "declared": True, + "installed": False, + "enabled": False, + "ready": False, + "unavailable_reason": "connector_registry_is_inventory_not_readiness", + } + + def test_plan_projects_registry_as_inventory_not_readiness(tmp_path: Path, monkeypatch) -> None: provider_path = tmp_path / "providers.json" provider_path.write_text( @@ -169,3 +232,50 @@ def test_source_cli_reaches_typescript_owner(tmp_path: Path) -> None: assert payload["schema_version"] == "loopx_external_evidence_plan_v0" assert payload["status"] == "ready" assert payload["selected_provider"]["provider_id"] == "host:external-research" + + +def test_source_cli_discovers_inventory_without_claiming_readiness(tmp_path: Path) -> None: + registry_path = tmp_path / "connectors.json" + registry_path.write_text( + json.dumps( + { + "schema_version": "connector_registry_v1", + "connectors": [ + { + "id": "official-docs", + "name": "Official docs", + "layer": "L1", + "kind": "documentation", + "status": "supported", + "value_tier": "P1", + } + ], + "usage": {}, + } + ), + encoding="utf-8", + ) + result = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "external-evidence", + "discover", + "--connector-registry", + str(registry_path), + "--format", + "json", + ], + cwd=Path(__file__).resolve().parents[2], + check=True, + capture_output=True, + text=True, + ) + payload = json.loads(result.stdout) + assert payload["schema_version"] == "loopx_external_evidence_discovery_v0" + assert payload["status"] == "inventory_only" + assert payload["summary"]["provider_count"] >= 1 + assert payload["summary"]["connector_count"] == payload["summary"]["provider_count"] + assert payload["summary"]["ready_count"] == 0 + assert payload["truth_contract"]["execution_observed"] is False diff --git a/tests/control_plane_ts/external_evidence_research.test.ts b/tests/control_plane_ts/external_evidence_research.test.ts index 1ef7d31b4f..3022ed5c35 100644 --- a/tests/control_plane_ts/external_evidence_research.test.ts +++ b/tests/control_plane_ts/external_evidence_research.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; import { evaluateExternalEvidenceAdmission, planExternalEvidenceRequest, + projectExternalEvidenceDiscovery, projectExternalEvidenceRetirement, } from "../../loopx/control_plane/capabilities/external_evidence.ts"; @@ -70,6 +71,35 @@ function receipt(requestId: unknown) { }; } +test("discovers method and connector inventory without claiming execution", () => { + const result = projectExternalEvidenceDiscovery({ + providers: [methodProvider, registryOnlyConnector], + }); + assert.equal(result.status, "ready"); + assert.deepEqual(result.ready_provider_ids, ["host:external-research"]); + assert.deepEqual(result.summary, { + provider_count: 2, + method_count: 1, + connector_count: 1, + ready_count: 1, + unavailable_count: 1, + }); + assert.deepEqual(result.truth_contract, { + registry_presence_is_readiness: false, + supported_status_is_readiness: false, + execution_observed: false, + evidence_coverage_observed: false, + }); +}); + +test("reports connector-only discovery as inventory-only", () => { + const result = projectExternalEvidenceDiscovery({ + providers: [registryOnlyConnector], + }); + assert.equal(result.status, "inventory_only"); + assert.deepEqual(result.ready_provider_ids, []); +}); + test("plans one ready provider without treating registry presence as readiness", () => { const result = plan(); assert.equal(result.status, "ready"); From a37a14def871f1cf157bd03909a50fcf4ff573db Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:03:35 +0800 Subject: [PATCH 06/14] feat(capability): bind external evidence execution receipts Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- ...xternal-evidence-research-capability-v0.md | 12 +- ...l-evidence-research-capability-v0.zh-CN.md | 9 +- .../capabilities/external_research/README.md | 19 ++- .../external_research/catalog_entry.py | 11 +- loopx/capabilities/external_research/cli.py | 50 ++++++-- .../capabilities/external_evidence.ts | 60 ++++++++- .../control_plane/effect_runtime_handlers.ts | 2 + .../test_external_evidence_cli.py | 116 ++++++++++++++---- .../external_evidence_research.test.ts | 32 +++++ 9 files changed, 265 insertions(+), 46 deletions(-) diff --git a/docs/architecture/rfcs/external-evidence-research-capability-v0.md b/docs/architecture/rfcs/external-evidence-research-capability-v0.md index 64aa23238b..b5c13f26ba 100644 --- a/docs/architecture/rfcs/external-evidence-research-capability-v0.md +++ b/docs/architecture/rfcs/external-evidence-research-capability-v0.md @@ -37,6 +37,12 @@ explicit truth contract: registry presence and `supported` status are not readiness, and discovery itself observes neither provider execution nor evidence coverage. +Provider execution stays with the existing method or connector owner. Core's +`receipt` boundary validates the returned identity and provenance against the +exact ready plan, records that execution was observed, and still does not claim +evidence completeness, admission, or automatic promotion. Failure and empty +evidence remain fail-open to the caller's original-source path. + The receipt binds the exact request, selected provider, completion time, and a digest over the complete receipt. Each admitted source has a direct non-file reference, source family, evidence basis (`stated`, @@ -61,8 +67,8 @@ inventory-only row for the same provider id. ## Product surfaces -- CLI: `external-evidence discover|plan|admit|retire`. -- Managed Turn: the same four effect-runtime methods. +- CLI: `external-evidence discover|plan|receipt|admit|retire`. +- Managed Turn: the same five effect-runtime methods. - Frontend/Lark: not changed in this Core slice. A companion slice should render the same typed plan/admission projection and readback; it must not invent a second registry or lifecycle. @@ -73,6 +79,8 @@ inventory-only row for the same provider id. - discovery distinguishes empty, inventory-only, and ready inventories without claiming execution or evidence coverage; - method and connector providers use one protocol and receipt contract; +- an observed provider receipt is bound to the exact plan without implying + evidence coverage, admission, or promotion; - stale request/provider identity, file provenance, and unsupported evidence basis fail closed; - admitted source refs are a subset of receipt sources; diff --git a/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md b/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md index 526c115ede..4a411bd863 100644 --- a/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md +++ b/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md @@ -30,6 +30,10 @@ Discovery 是只读类型化投影。它报告 method/connector 数量、ready/u ready provider id,并携带显式真值合同:registry presence 与 `supported` 都不等于 readiness,discovery 本身既不证明 provider 已执行,也不证明已有证据覆盖。 +Provider 执行仍归既有 method 或 connector owner。Core 的 `receipt` 边界把返回身份和 +provenance 与精确 ready plan 绑定,记录“执行已观察”,但仍不宣称证据完整、已被采纳 +或可自动晋升。执行失败或空证据时,对 caller 的原始来源路径保持 fail-open。 + 回执绑定精确请求、已选 provider、完成时间与完整回执 digest。每条被采纳来源都包含直接且非文件型引用、来源 家族、证据基础(`stated`、`observed`、`tested` 或 `inferred`)、发现、局限、相关 日期和内容摘要。Core 投影永不携带 provider 原始内容。 @@ -48,8 +52,8 @@ Connector registry 继续只拥有库存与遥测。`supported` 绝不映射为 ## 产品入口 -- CLI:`external-evidence discover|plan|admit|retire`; -- Managed Turn:复用同四个 effect-runtime 方法; +- CLI:`external-evidence discover|plan|receipt|admit|retire`; +- Managed Turn:复用同五个 effect-runtime 方法; - Frontend/Lark:本 Core 切片不修改。后续 companion slice 只渲染同源 plan/admission 投影与读回,不建立第二个 registry 或生命周期。 @@ -58,6 +62,7 @@ Connector registry 继续只拥有库存与遥测。`supported` 绝不映射为 - inventory-only connector 不可被选择; - discovery 能区分 empty、inventory-only 与 ready 库存,且不冒充执行或证据覆盖; - method 与 connector provider 使用同一协议与回执; +- 已观察 provider 回执必须绑定精确 plan,且不得冒充证据覆盖、采纳或晋升; - 过期请求/provider 身份、文件 provenance、未知证据基础均 fail closed; - 被采纳 source ref 必须是回执来源的子集; - 全部被采纳来源完成下游覆盖前不得退休; diff --git a/loopx/capabilities/external_research/README.md b/loopx/capabilities/external_research/README.md index d69a22aeed..22f5c2b0e7 100644 --- a/loopx/capabilities/external_research/README.md +++ b/loopx/capabilities/external_research/README.md @@ -25,17 +25,20 @@ The lifecycle is: ready; 3. provider execution: the selected host method or connector reads external sources under its own adapter and permission boundary; -4. `admit`: validate the exact request/provider identity and source-level +4. `receipt`: validate the observed execution against the exact plan without + claiming evidence coverage, admission, or automatic promotion; +5. `admit`: validate the exact request/provider identity and source-level provenance, bind the complete receipt digest, then record the parent agent's admit/reject decision; -5. downstream projection: pass only compact findings, limitations, direct +6. downstream projection: pass only compact findings, limitations, direct references, evidence basis, dates, and content digests; -6. `retire`: retire rejected evidence immediately, or admitted evidence only +7. `retire`: retire rejected evidence immediately, or admitted evidence only after every admitted source reference appears in downstream readback. 生命周期为:`discover` 只读投影 method/connector 库存与当前 readiness,并明确区分 registry presence、真实执行和证据覆盖;`plan` 绑定“对象 + 用户活动 + 决策”并选择当前 -真实 ready 的 provider;provider 在自己的权限边界内执行;`admit` 校验请求、provider、完成时间、 +真实 ready 的 provider;provider 在自己的权限边界内执行;`receipt` 把执行观察绑定精确 plan, +但不冒充证据覆盖、采纳或自动晋升;`admit` 校验请求、provider、完成时间、 完整 receipt digest 与逐来源 provenance,并记录父 Agent 的采纳/拒绝;下游只投影紧凑证据;被采纳的来源全部完成 下游读回后才可 `retire`。 @@ -64,6 +67,11 @@ loopx external-evidence plan \ --provider-inventory-json providers.json \ --format json +loopx external-evidence receipt \ + --plan-json plan.json \ + --receipt-json receipt.json \ + --format json + loopx external-evidence admit \ --plan-json plan.json \ --receipt-json receipt.json \ @@ -90,7 +98,8 @@ credentials, and private notes remain provider-private. - Python adapts the existing CLI and effect-runtime transport; it does not reimplement those decisions. - Managed Turn callers can invoke the same effect-runtime methods: - `external_evidence.discover`, `external_evidence.plan`, `external_evidence.admit`, and + `external_evidence.discover`, `external_evidence.plan`, + `external_evidence.receipt`, `external_evidence.admit`, and `external_evidence.retire`. - Frontend and Lark are companion slices. They should render the same plan and admission projection; neither gets an independent provider registry or diff --git a/loopx/capabilities/external_research/catalog_entry.py b/loopx/capabilities/external_research/catalog_entry.py index aad737d02d..b3024204c6 100644 --- a/loopx/capabilities/external_research/catalog_entry.py +++ b/loopx/capabilities/external_research/catalog_entry.py @@ -21,7 +21,8 @@ ), "user_value": ( "Discover method and connector inventory, select only a currently ready provider, " - "and admit or reject a compact provenance receipt without copying raw provider content." + "bind an observed execution receipt to its exact plan, and admit or reject compact " + "provenance without copying raw provider content." ), "next_real_step": ( "run `loopx external-evidence discover --connector-registry`, then provide a current " @@ -39,6 +40,14 @@ "purpose": "Bind object, user activity, decision, and evidence kinds to one ready provider.", "write_boundary": "read-only", }, + { + "command": "loopx external-evidence receipt --plan-json plan.json --receipt-json receipt.json", + "purpose": ( + "Bind observed provider execution to the exact plan without claiming " + "coverage, admission, or promotion." + ), + "write_boundary": "read-only typed reduction; provider owner performs execution", + }, { "command": "loopx external-evidence admit --plan-json plan.json --receipt-json receipt.json ...", "purpose": "Validate source provenance and record the parent admit/reject decision.", diff --git a/loopx/capabilities/external_research/cli.py b/loopx/capabilities/external_research/cli.py index 16fea3baec..b89c53a08e 100644 --- a/loopx/capabilities/external_research/cli.py +++ b/loopx/capabilities/external_research/cli.py @@ -10,7 +10,9 @@ from ..connector_registry.core import load_connector_registry -PrintPayload = Callable[[dict[str, object], str, Callable[[dict[str, object]], str]], None] +PrintPayload = Callable[ + [dict[str, object], str, Callable[[dict[str, object]], str]], None +] AddFormat = Callable[[argparse.ArgumentParser], None] FormatSelector = Callable[..., str] MAX_INPUT_BYTES = 1_000_000 @@ -114,7 +116,7 @@ def register_external_evidence_commands( ) -> None: parser = subparsers.add_parser( "external-evidence", - help="Plan, admit, and retire auditable external evidence.", + help="Discover, plan, receipt, admit, and retire auditable external evidence.", ) actions = parser.add_subparsers(dest="external_evidence_action", required=True) @@ -126,7 +128,9 @@ def register_external_evidence_commands( discover.add_argument("--connector-registry", nargs="?", const="") add_subcommand_format(discover) - plan = actions.add_parser("plan", help="Select one currently ready evidence provider.") + plan = actions.add_parser( + "plan", help="Select one currently ready evidence provider." + ) plan.add_argument("--objective", required=True) plan.add_argument("--user-activity", required=True) plan.add_argument("--decision", required=True) @@ -137,7 +141,17 @@ def register_external_evidence_commands( plan.add_argument("--preferred-provider-id") add_subcommand_format(plan) - admit = actions.add_parser("admit", help="Validate a provider receipt and parent decision.") + receipt = actions.add_parser( + "receipt", + help="Validate and bind an observed provider execution receipt to its exact plan.", + ) + receipt.add_argument("--plan-json", required=True) + receipt.add_argument("--receipt-json", required=True) + add_subcommand_format(receipt) + + admit = actions.add_parser( + "admit", help="Validate a provider receipt and parent decision." + ) admit.add_argument("--plan-json", required=True) admit.add_argument("--receipt-json", required=True) admit.add_argument("--decision", choices=["admit", "reject"], required=True) @@ -145,7 +159,9 @@ def register_external_evidence_commands( admit.add_argument("--admit-source", action="append", default=[]) add_subcommand_format(admit) - retire = actions.add_parser("retire", help="Check downstream projection coverage before retirement.") + retire = actions.add_parser( + "retire", help="Check downstream projection coverage before retirement." + ) retire.add_argument("--admission-json", required=True) retire.add_argument("--downstream-source", action="append", default=[]) add_subcommand_format(retire) @@ -199,12 +215,28 @@ def handle_external_evidence_command( "preferred_provider_id": args.preferred_provider_id, }, ) + elif args.external_evidence_action == "receipt": + payload = effect_runtime_result( + "external_evidence.receipt", + { + "plan": _load_object( + args.plan_json, label="external evidence plan" + ), + "receipt": _load_object( + args.receipt_json, label="external evidence receipt" + ), + }, + ) elif args.external_evidence_action == "admit": payload = effect_runtime_result( "external_evidence.admit", { - "plan": _load_object(args.plan_json, label="external evidence plan"), - "receipt": _load_object(args.receipt_json, label="external evidence receipt"), + "plan": _load_object( + args.plan_json, label="external evidence plan" + ), + "receipt": _load_object( + args.receipt_json, label="external evidence receipt" + ), "decision": { "disposition": args.decision, "reason": args.reason, @@ -224,7 +256,9 @@ def handle_external_evidence_command( }, ) else: - raise ValueError("external-evidence requires discover, plan, admit, or retire") + raise ValueError( + "external-evidence requires discover, plan, receipt, admit, or retire" + ) except (RuntimeError, ValueError) as exc: payload = { "ok": False, diff --git a/loopx/control_plane/capabilities/external_evidence.ts b/loopx/control_plane/capabilities/external_evidence.ts index 82e2a6655b..73af12a73e 100644 --- a/loopx/control_plane/capabilities/external_evidence.ts +++ b/loopx/control_plane/capabilities/external_evidence.ts @@ -14,6 +14,8 @@ export const EXTERNAL_EVIDENCE_DISCOVERY_SCHEMA_VERSION = "loopx_external_evidence_discovery_v0"; export const EXTERNAL_EVIDENCE_PLAN_SCHEMA_VERSION = "loopx_external_evidence_plan_v0"; +export const EXTERNAL_EVIDENCE_EXECUTION_SCHEMA_VERSION = + "loopx_external_evidence_execution_v0"; export const EXTERNAL_EVIDENCE_RECEIPT_SCHEMA_VERSION = "loopx_external_evidence_receipt_v0"; export const EXTERNAL_EVIDENCE_ADMISSION_SCHEMA_VERSION = @@ -275,15 +277,14 @@ function sourceRecord(value: unknown, index: number): JsonObject { }; } -export function evaluateExternalEvidenceAdmission(params: JsonObject): JsonObject { - const plan = requireJsonObject(params.plan, "external evidence plan"); +function normalizeExecutionReceipt(plan: JsonObject, value: unknown): JsonObject { requireThat( plan.schema_version === EXTERNAL_EVIDENCE_PLAN_SCHEMA_VERSION && plan.status === "ready", - "external evidence admission requires a ready plan", + "external evidence execution requires a ready plan", ); const request = requireJsonObject(plan.request, "external evidence plan request"); const selected = requireJsonObject(plan.selected_provider, "external evidence selected provider"); - const receipt = requireJsonObject(params.receipt, "external evidence receipt"); + const receipt = requireJsonObject(value, "external evidence receipt"); requireThat( receipt.schema_version === EXTERNAL_EVIDENCE_RECEIPT_SCHEMA_VERSION, "external evidence receipt schema is invalid", @@ -301,7 +302,56 @@ export function evaluateExternalEvidenceAdmission(params: JsonObject): JsonObjec ); requireThat(status !== "succeeded" || sources.length > 0, "a succeeded receipt requires evidence sources"); requireThat(status === "succeeded" || sources.length === 0, "failed or no_evidence receipts cannot carry admitted sources"); - const completedAt = boundedText(receipt.completed_at, "receipt.completed_at", 64); + return { + schema_version: EXTERNAL_EVIDENCE_RECEIPT_SCHEMA_VERSION, + request_id: request.request_id, + provider_id: selected.provider_id, + provider_kind: selected.provider_kind, + status, + sources, + summary: boundedText(receipt.summary, "receipt.summary", 4096), + limitations: receipt.limitations === undefined + ? [] + : boundedStrings(receipt.limitations, "receipt.limitations", 16, 1024), + completed_at: boundedText(receipt.completed_at, "receipt.completed_at", 64), + }; +} + +export function recordExternalEvidenceExecution(params: JsonObject): JsonObject { + const plan = requireJsonObject(params.plan, "external evidence plan"); + const receipt = normalizeExecutionReceipt(plan, params.receipt); + const executionIdentity = { + request_id: receipt.request_id, + provider_id: receipt.provider_id, + receipt_digest: digest(receipt), + }; + return { + schema_version: EXTERNAL_EVIDENCE_EXECUTION_SCHEMA_VERSION, + execution_id: digest(executionIdentity), + request_id: receipt.request_id, + provider_id: receipt.provider_id, + provider_kind: receipt.provider_kind, + status: receipt.status, + receipt, + truth_contract: { + provider_execution_observed: true, + evidence_produced: receipt.status === "succeeded", + evidence_coverage_observed: false, + automatic_admission: false, + automatic_promotion: false, + raw_source_fallback_allowed: true, + }, + }; +} + +export function evaluateExternalEvidenceAdmission(params: JsonObject): JsonObject { + const plan = requireJsonObject(params.plan, "external evidence plan"); + const request = requireJsonObject(plan.request, "external evidence plan request"); + const selected = requireJsonObject(plan.selected_provider, "external evidence selected provider"); + const receipt = normalizeExecutionReceipt(plan, params.receipt); + const status = receipt.status as string; + const sources = receipt.sources as JsonObject[]; + const completedAt = receipt.completed_at as string; const receiptDigest = digest(receipt); const decision = requireJsonObject(params.decision, "parent admission decision"); const disposition = requireStringLiteral( diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index b7141ab88c..b0104f5b14 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -203,6 +203,7 @@ import { planExternalEvidenceRequest, projectExternalEvidenceDiscovery, projectExternalEvidenceRetirement, + recordExternalEvidenceExecution, } from "./capabilities/external_evidence.ts"; type EffectRuntimeHandler = (params: JsonObject) => unknown | Promise; @@ -667,6 +668,7 @@ export function createEffectRuntimeHandlers( ], ["external_evidence.discover", projectExternalEvidenceDiscovery], ["external_evidence.plan", planExternalEvidenceRequest], + ["external_evidence.receipt", recordExternalEvidenceExecution], ["external_evidence.admit", evaluateExternalEvidenceAdmission], ["external_evidence.retire", projectExternalEvidenceRetirement], [ diff --git a/tests/capabilities/test_external_evidence_cli.py b/tests/capabilities/test_external_evidence_cli.py index bb718cd78b..d742675bc9 100644 --- a/tests/capabilities/test_external_evidence_cli.py +++ b/tests/capabilities/test_external_evidence_cli.py @@ -53,11 +53,14 @@ def fake_runtime(method, params): provider_inventory_json=None, connector_registry=str(registry_path), ) - assert cli.handle_external_evidence_command( - args, - output_format=lambda _args: "json", - print_payload=_print_payload, - ) == 0 + assert ( + cli.handle_external_evidence_command( + args, + output_format=lambda _args: "json", + print_payload=_print_payload, + ) + == 0 + ) assert captured["method"] == "external_evidence.discover" connector = next( provider @@ -76,7 +79,9 @@ def fake_runtime(method, params): } -def test_plan_projects_registry_as_inventory_not_readiness(tmp_path: Path, monkeypatch) -> None: +def test_plan_projects_registry_as_inventory_not_readiness( + tmp_path: Path, monkeypatch +) -> None: provider_path = tmp_path / "providers.json" provider_path.write_text( json.dumps( @@ -137,29 +142,46 @@ def fake_runtime(method, params): connector_registry=str(registry_path), preferred_provider_id="host:external-research", ) - assert cli.handle_external_evidence_command( - args, - output_format=lambda _args: "json", - print_payload=_print_payload, - ) == 0 + assert ( + cli.handle_external_evidence_command( + args, + output_format=lambda _args: "json", + print_payload=_print_payload, + ) + == 0 + ) assert captured["method"] == "external_evidence.plan" providers = captured["params"]["providers"] - connector = next(row for row in providers if row["provider_id"] == "connector:official-docs") + connector = next( + row for row in providers if row["provider_id"] == "connector:official-docs" + ) assert connector["ready"] is False - assert connector["unavailable_reason"] == "connector_registry_is_inventory_not_readiness" + assert ( + connector["unavailable_reason"] + == "connector_registry_is_inventory_not_readiness" + ) -def test_admit_passes_parent_decision_to_typed_owner(tmp_path: Path, monkeypatch) -> None: +def test_admit_passes_parent_decision_to_typed_owner( + tmp_path: Path, monkeypatch +) -> None: plan_path = tmp_path / "plan.json" receipt_path = tmp_path / "receipt.json" - plan_path.write_text(json.dumps({"schema_version": "loopx_external_evidence_plan_v0"})) - receipt_path.write_text(json.dumps({"schema_version": "loopx_external_evidence_receipt_v0"})) + plan_path.write_text( + json.dumps({"schema_version": "loopx_external_evidence_plan_v0"}) + ) + receipt_path.write_text( + json.dumps({"schema_version": "loopx_external_evidence_receipt_v0"}) + ) captured = {} def fake_runtime(method, params): captured["method"] = method captured["params"] = params - return {"schema_version": "loopx_external_evidence_admission_v0", "disposition": "reject"} + return { + "schema_version": "loopx_external_evidence_admission_v0", + "disposition": "reject", + } monkeypatch.setattr(cli, "effect_runtime_result", fake_runtime) args = argparse.Namespace( @@ -171,15 +193,61 @@ def fake_runtime(method, params): reason="Insufficient direct evidence", admit_source=[], ) - assert cli.handle_external_evidence_command( - args, - output_format=lambda _args: "json", - print_payload=_print_payload, - ) == 0 + assert ( + cli.handle_external_evidence_command( + args, + output_format=lambda _args: "json", + print_payload=_print_payload, + ) + == 0 + ) assert captured["method"] == "external_evidence.admit" assert captured["params"]["decision"]["disposition"] == "reject" +def test_receipt_passes_observed_execution_to_typed_owner( + tmp_path: Path, monkeypatch +) -> None: + plan_path = tmp_path / "plan.json" + receipt_path = tmp_path / "receipt.json" + plan_path.write_text( + json.dumps({"schema_version": "loopx_external_evidence_plan_v0"}) + ) + receipt_path.write_text( + json.dumps({"schema_version": "loopx_external_evidence_receipt_v0"}) + ) + captured = {} + + def fake_runtime(method, params): + captured["method"] = method + captured["params"] = params + return { + "schema_version": "loopx_external_evidence_execution_v0", + "status": "succeeded", + } + + monkeypatch.setattr(cli, "effect_runtime_result", fake_runtime) + args = argparse.Namespace( + command="external-evidence", + external_evidence_action="receipt", + plan_json=str(plan_path), + receipt_json=str(receipt_path), + ) + assert ( + cli.handle_external_evidence_command( + args, + output_format=lambda _args: "json", + print_payload=_print_payload, + ) + == 0 + ) + assert captured["method"] == "external_evidence.receipt" + assert ( + captured["params"]["plan"]["schema_version"] + == "loopx_external_evidence_plan_v0" + ) + + def test_source_cli_reaches_typescript_owner(tmp_path: Path) -> None: provider_path = tmp_path / "providers.json" provider_path.write_text( @@ -234,7 +302,9 @@ def test_source_cli_reaches_typescript_owner(tmp_path: Path) -> None: assert payload["selected_provider"]["provider_id"] == "host:external-research" -def test_source_cli_discovers_inventory_without_claiming_readiness(tmp_path: Path) -> None: +def test_source_cli_discovers_inventory_without_claiming_readiness( + tmp_path: Path, +) -> None: registry_path = tmp_path / "connectors.json" registry_path.write_text( json.dumps( diff --git a/tests/control_plane_ts/external_evidence_research.test.ts b/tests/control_plane_ts/external_evidence_research.test.ts index 3022ed5c35..40ba30286a 100644 --- a/tests/control_plane_ts/external_evidence_research.test.ts +++ b/tests/control_plane_ts/external_evidence_research.test.ts @@ -6,6 +6,7 @@ import { planExternalEvidenceRequest, projectExternalEvidenceDiscovery, projectExternalEvidenceRetirement, + recordExternalEvidenceExecution, } from "../../loopx/control_plane/capabilities/external_evidence.ts"; const request = { @@ -136,6 +137,37 @@ test("rejects a provider that claims ready without lifecycle readiness", () => { ); }); +test("records provider execution without claiming coverage or admission", () => { + const currentPlan = plan(); + const requestId = (currentPlan.request as Record).request_id; + const result = recordExternalEvidenceExecution({ + plan: currentPlan, + receipt: receipt(requestId), + }); + assert.equal(result.status, "succeeded"); + assert.match(String(result.execution_id), /^sha256:[0-9a-f]{64}$/); + assert.deepEqual(result.truth_contract, { + provider_execution_observed: true, + evidence_produced: true, + evidence_coverage_observed: false, + automatic_admission: false, + automatic_promotion: false, + raw_source_fallback_allowed: true, + }); +}); + +test("execution receipt fails closed on stale provider identity", () => { + const currentPlan = plan(); + const requestId = (currentPlan.request as Record).request_id; + assert.throws( + () => recordExternalEvidenceExecution({ + plan: currentPlan, + receipt: { ...receipt(requestId), provider_id: "connector:stale" }, + }), + /provider_id does not match/, + ); +}); + test("admits exact source refs and exposes only compact provenance", () => { const currentPlan = plan(); const requestId = (currentPlan.request as Record).request_id; From d38b474d006fd2e89026be8eb4e71b155e09bed1 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:33:52 +0800 Subject: [PATCH 07/14] fix(capability): bind receipts to canonical evidence plans Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/capabilities/external_research/cli.py | 1 + .../capabilities/external_evidence.ts | 150 ++++++++++++++---- .../external_evidence_research.test.ts | 83 ++++++++-- 3 files changed, 188 insertions(+), 46 deletions(-) diff --git a/loopx/capabilities/external_research/cli.py b/loopx/capabilities/external_research/cli.py index b89c53a08e..1b3d2d1363 100644 --- a/loopx/capabilities/external_research/cli.py +++ b/loopx/capabilities/external_research/cli.py @@ -83,6 +83,7 @@ def _render(payload: dict[str, object]) -> str: lines = ["# LoopX External Evidence", ""] for field in ( "status", + "plan_id", "request_id", "provider_id", "provider_kind", diff --git a/loopx/control_plane/capabilities/external_evidence.ts b/loopx/control_plane/capabilities/external_evidence.ts index 73af12a73e..09b731912c 100644 --- a/loopx/control_plane/capabilities/external_evidence.ts +++ b/loopx/control_plane/capabilities/external_evidence.ts @@ -210,30 +210,103 @@ export function planExternalEvidenceRequest(params: JsonObject): JsonObject { ? readyProviders[0] ?? null : readyProviders.find((provider) => provider.provider_id === preferredProviderId) ?? null; const status = selected === null ? "blocked" : "ready"; - return { + const executionEnvelope = selected === null + ? null + : { + schema_version: "loopx_external_evidence_execution_envelope_v0", + request_id: request.request_id, + provider_id: selected.provider_id, + provider_kind: selected.provider_kind, + protocol: selected.protocol, + authority: "read_external_sources_only", + raw_content_persistence: "provider_private", + result_contract: EXTERNAL_EVIDENCE_RECEIPT_SCHEMA_VERSION, + }; + const plan = { schema_version: EXTERNAL_EVIDENCE_PLAN_SCHEMA_VERSION, status, request, provider_candidates: providers, selected_provider: selected, - execution_envelope: selected === null - ? null - : { - schema_version: "loopx_external_evidence_execution_envelope_v0", - request_id: request.request_id, - provider_id: selected.provider_id, - provider_kind: selected.provider_kind, - protocol: selected.protocol, - authority: "read_external_sources_only", - raw_content_persistence: "provider_private", - result_contract: EXTERNAL_EVIDENCE_RECEIPT_SCHEMA_VERSION, - }, + execution_envelope: executionEnvelope, blocker: selected === null ? preferredProviderId === null ? "no_ready_provider" : "preferred_provider_not_ready" : null, }; + const planId = digest(plan); + return { + ...plan, + plan_id: planId, + execution_envelope: executionEnvelope === null + ? null + : { ...executionEnvelope, plan_id: planId }, + }; +} + +function normalizeReadyPlan(value: unknown): JsonObject { + const plan = requireJsonObject(value, "external evidence plan"); + requireThat( + plan.schema_version === EXTERNAL_EVIDENCE_PLAN_SCHEMA_VERSION && plan.status === "ready", + "external evidence execution requires a ready plan", + ); + requireThat(plan.blocker === null, "a ready external evidence plan cannot have a blocker"); + const request = normalizeRequest(plan.request); + const suppliedRequest = requireJsonObject(plan.request, "external evidence plan request"); + requireThat( + suppliedRequest.request_id === request.request_id, + "external evidence plan request_id does not match its normalized request", + ); + const providers = normalizeProviders(plan.provider_candidates); + const selected = normalizeProvider(plan.selected_provider, 0); + requireThat(selected.ready === true, "external evidence selected provider is not ready"); + const selectedCandidate = providers.find( + (provider) => provider.provider_id === selected.provider_id, + ); + requireThat( + selectedCandidate !== undefined && digest(selectedCandidate) === digest(selected), + "external evidence selected provider does not match its provider candidate", + ); + const planId = boundedText(plan.plan_id, "external evidence plan.plan_id", 71); + requireThat(SHA256_RE.test(planId), "external evidence plan.plan_id is invalid"); + const executionEnvelope = { + schema_version: "loopx_external_evidence_execution_envelope_v0", + request_id: request.request_id, + provider_id: selected.provider_id, + provider_kind: selected.provider_kind, + protocol: selected.protocol, + authority: "read_external_sources_only", + raw_content_persistence: "provider_private", + result_contract: EXTERNAL_EVIDENCE_RECEIPT_SCHEMA_VERSION, + }; + const suppliedEnvelope = requireJsonObject( + plan.execution_envelope, + "external evidence execution envelope", + ); + requireThat( + suppliedEnvelope.plan_id === planId && + digest(suppliedEnvelope) === digest({ ...executionEnvelope, plan_id: planId }), + "external evidence execution envelope does not match the plan", + ); + const normalizedPlan = { + schema_version: EXTERNAL_EVIDENCE_PLAN_SCHEMA_VERSION, + status: "ready", + request, + provider_candidates: providers, + selected_provider: selected, + execution_envelope: executionEnvelope, + blocker: null, + }; + requireThat( + planId === digest(normalizedPlan), + "external evidence plan_id does not match the normalized ready plan", + ); + return { + ...normalizedPlan, + plan_id: planId, + execution_envelope: { ...executionEnvelope, plan_id: planId }, + }; } function sourceRecord(value: unknown, index: number): JsonObject { @@ -277,11 +350,11 @@ function sourceRecord(value: unknown, index: number): JsonObject { }; } -function normalizeExecutionReceipt(plan: JsonObject, value: unknown): JsonObject { - requireThat( - plan.schema_version === EXTERNAL_EVIDENCE_PLAN_SCHEMA_VERSION && plan.status === "ready", - "external evidence execution requires a ready plan", - ); +function normalizeExecutionReceipt( + planValue: unknown, + value: unknown, +): { plan: JsonObject; receipt: JsonObject } { + const plan = normalizeReadyPlan(planValue); const request = requireJsonObject(plan.request, "external evidence plan request"); const selected = requireJsonObject(plan.selected_provider, "external evidence selected provider"); const receipt = requireJsonObject(value, "external evidence receipt"); @@ -289,6 +362,7 @@ function normalizeExecutionReceipt(plan: JsonObject, value: unknown): JsonObject receipt.schema_version === EXTERNAL_EVIDENCE_RECEIPT_SCHEMA_VERSION, "external evidence receipt schema is invalid", ); + requireThat(receipt.plan_id === plan.plan_id, "receipt plan_id does not match the plan"); requireThat(receipt.request_id === request.request_id, "receipt request_id does not match the plan"); requireThat(receipt.provider_id === selected.provider_id, "receipt provider_id does not match the plan"); requireThat(receipt.provider_kind === selected.provider_kind, "receipt provider_kind does not match the plan"); @@ -303,24 +377,28 @@ function normalizeExecutionReceipt(plan: JsonObject, value: unknown): JsonObject requireThat(status !== "succeeded" || sources.length > 0, "a succeeded receipt requires evidence sources"); requireThat(status === "succeeded" || sources.length === 0, "failed or no_evidence receipts cannot carry admitted sources"); return { - schema_version: EXTERNAL_EVIDENCE_RECEIPT_SCHEMA_VERSION, - request_id: request.request_id, - provider_id: selected.provider_id, - provider_kind: selected.provider_kind, - status, - sources, - summary: boundedText(receipt.summary, "receipt.summary", 4096), - limitations: receipt.limitations === undefined - ? [] - : boundedStrings(receipt.limitations, "receipt.limitations", 16, 1024), - completed_at: boundedText(receipt.completed_at, "receipt.completed_at", 64), + plan, + receipt: { + schema_version: EXTERNAL_EVIDENCE_RECEIPT_SCHEMA_VERSION, + plan_id: plan.plan_id, + request_id: request.request_id, + provider_id: selected.provider_id, + provider_kind: selected.provider_kind, + status, + sources, + summary: boundedText(receipt.summary, "receipt.summary", 4096), + limitations: receipt.limitations === undefined + ? [] + : boundedStrings(receipt.limitations, "receipt.limitations", 16, 1024), + completed_at: boundedText(receipt.completed_at, "receipt.completed_at", 64), + }, }; } export function recordExternalEvidenceExecution(params: JsonObject): JsonObject { - const plan = requireJsonObject(params.plan, "external evidence plan"); - const receipt = normalizeExecutionReceipt(plan, params.receipt); + const { plan, receipt } = normalizeExecutionReceipt(params.plan, params.receipt); const executionIdentity = { + plan_id: plan.plan_id, request_id: receipt.request_id, provider_id: receipt.provider_id, receipt_digest: digest(receipt), @@ -328,6 +406,7 @@ export function recordExternalEvidenceExecution(params: JsonObject): JsonObject return { schema_version: EXTERNAL_EVIDENCE_EXECUTION_SCHEMA_VERSION, execution_id: digest(executionIdentity), + plan_id: plan.plan_id, request_id: receipt.request_id, provider_id: receipt.provider_id, provider_kind: receipt.provider_kind, @@ -345,10 +424,11 @@ export function recordExternalEvidenceExecution(params: JsonObject): JsonObject } export function evaluateExternalEvidenceAdmission(params: JsonObject): JsonObject { - const plan = requireJsonObject(params.plan, "external evidence plan"); + const normalized = normalizeExecutionReceipt(params.plan, params.receipt); + const plan = normalized.plan; + const receipt = normalized.receipt; const request = requireJsonObject(plan.request, "external evidence plan request"); const selected = requireJsonObject(plan.selected_provider, "external evidence selected provider"); - const receipt = normalizeExecutionReceipt(plan, params.receipt); const status = receipt.status as string; const sources = receipt.sources as JsonObject[]; const completedAt = receipt.completed_at as string; @@ -378,6 +458,7 @@ export function evaluateExternalEvidenceAdmission(params: JsonObject): JsonObjec ); const admitted = sources.filter((source) => admittedRefs.includes(source.source_ref as string)); const admissionIdentity = { + plan_id: plan.plan_id, request_id: request.request_id, provider_id: selected.provider_id, receipt_digest: receiptDigest, @@ -387,6 +468,7 @@ export function evaluateExternalEvidenceAdmission(params: JsonObject): JsonObjec return { schema_version: EXTERNAL_EVIDENCE_ADMISSION_SCHEMA_VERSION, admission_id: digest(admissionIdentity), + plan_id: plan.plan_id, request_id: request.request_id, provider_id: selected.provider_id, provider_kind: selected.provider_kind, @@ -398,6 +480,7 @@ export function evaluateExternalEvidenceAdmission(params: JsonObject): JsonObjec admitted_source_refs: admittedRefs, downstream_projection: { schema_version: "loopx_external_evidence_projection_v0", + plan_id: plan.plan_id, request_id: request.request_id, objective: request.objective, decision: request.decision, @@ -432,6 +515,7 @@ export function projectExternalEvidenceRetirement(params: JsonObject): JsonObjec return { schema_version: EXTERNAL_EVIDENCE_RETIREMENT_SCHEMA_VERSION, admission_id: admission.admission_id, + plan_id: admission.plan_id, request_id: admission.request_id, status: retireReady ? "retire_ready" : "retained", retire_ready: retireReady, diff --git a/tests/control_plane_ts/external_evidence_research.test.ts b/tests/control_plane_ts/external_evidence_research.test.ts index 40ba30286a..0692107516 100644 --- a/tests/control_plane_ts/external_evidence_research.test.ts +++ b/tests/control_plane_ts/external_evidence_research.test.ts @@ -47,10 +47,12 @@ function plan() { }); } -function receipt(requestId: unknown) { +function receipt(currentPlan: Record) { + const currentRequest = currentPlan.request as Record; return { schema_version: "loopx_external_evidence_receipt_v0", - request_id: requestId, + plan_id: currentPlan.plan_id, + request_id: currentRequest.request_id, provider_id: methodProvider.provider_id, provider_kind: methodProvider.provider_kind, status: "succeeded", @@ -116,6 +118,11 @@ test("plans one ready provider without treating registry presence as readiness", String((result.request as Record).request_id), /^sha256:[0-9a-f]{64}$/, ); + assert.match(String(result.plan_id), /^sha256:[0-9a-f]{64}$/); + assert.equal( + (result.execution_envelope as Record).plan_id, + result.plan_id, + ); }); test("blocks when every provider is inventory-only", () => { const result = planExternalEvidenceRequest({ @@ -139,12 +146,12 @@ test("rejects a provider that claims ready without lifecycle readiness", () => { test("records provider execution without claiming coverage or admission", () => { const currentPlan = plan(); - const requestId = (currentPlan.request as Record).request_id; const result = recordExternalEvidenceExecution({ plan: currentPlan, - receipt: receipt(requestId), + receipt: receipt(currentPlan), }); assert.equal(result.status, "succeeded"); + assert.equal(result.plan_id, currentPlan.plan_id); assert.match(String(result.execution_id), /^sha256:[0-9a-f]{64}$/); assert.deepEqual(result.truth_contract, { provider_execution_observed: true, @@ -158,22 +165,70 @@ test("records provider execution without claiming coverage or admission", () => test("execution receipt fails closed on stale provider identity", () => { const currentPlan = plan(); - const requestId = (currentPlan.request as Record).request_id; assert.throws( () => recordExternalEvidenceExecution({ plan: currentPlan, - receipt: { ...receipt(requestId), provider_id: "connector:stale" }, + receipt: { ...receipt(currentPlan), provider_id: "connector:stale" }, }), /provider_id does not match/, ); }); +test("execution receipt fails closed on stale plan identity", () => { + const currentPlan = plan(); + assert.throws( + () => recordExternalEvidenceExecution({ + plan: currentPlan, + receipt: { ...receipt(currentPlan), plan_id: `sha256:${"b".repeat(64)}` }, + }), + /plan_id does not match/, + ); +}); + +test("canonical ready-plan verification rejects semantic mutations", () => { + const mutations: Array<[ + string, + (value: Record) => void, + ]> = [ + ["objective", (value) => { + (value.request as Record).objective = "Use a different objective"; + }], + ["decision", (value) => { + (value.request as Record).decision = "Make a different decision"; + }], + ["constraints", (value) => { + (value.request as Record).constraints = ["private sources allowed"]; + }], + ["provider readiness", (value) => { + const candidates = value.provider_candidates as Array>; + candidates[0].ready = false; + candidates[0].unavailable_reason = "became unavailable"; + }], + ["execution envelope", (value) => { + (value.execution_envelope as Record).authority = "write_external_sources"; + }], + ]; + + for (const [label, mutate] of mutations) { + const currentPlan = plan(); + const mutatedPlan = structuredClone(currentPlan) as Record; + mutate(mutatedPlan); + assert.throws( + () => recordExternalEvidenceExecution({ + plan: mutatedPlan, + receipt: receipt(currentPlan), + }), + undefined, + label, + ); + } +}); + test("admits exact source refs and exposes only compact provenance", () => { const currentPlan = plan(); - const requestId = (currentPlan.request as Record).request_id; const result = evaluateExternalEvidenceAdmission({ plan: currentPlan, - receipt: receipt(requestId), + receipt: receipt(currentPlan), decision: { disposition: "admit", reason: "The source directly answers the interaction question.", @@ -181,6 +236,7 @@ test("admits exact source refs and exposes only compact provenance", () => { }, }); assert.equal(result.disposition, "admit"); + assert.equal(result.plan_id, currentPlan.plan_id); assert.match(String(result.admission_id), /^sha256:[0-9a-f]{64}$/); assert.match(String(result.receipt_digest), /^sha256:[0-9a-f]{64}$/); const projection = result.downstream_projection as Record; @@ -190,11 +246,13 @@ test("admits exact source refs and exposes only compact provenance", () => { test("admission fails closed on stale plan identity and local file provenance", () => { const currentPlan = plan(); - const requestId = (currentPlan.request as Record).request_id; assert.throws( () => evaluateExternalEvidenceAdmission({ plan: currentPlan, - receipt: { ...receipt(requestId), request_id: `sha256:${"b".repeat(64)}` }, + receipt: { + ...receipt(currentPlan), + request_id: `sha256:${"b".repeat(64)}`, + }, decision: { disposition: "admit", reason: "stale", @@ -203,7 +261,7 @@ test("admission fails closed on stale plan identity and local file provenance", }), /request_id does not match/, ); - const localReceipt = receipt(requestId); + const localReceipt = receipt(currentPlan); localReceipt.sources[0].source_ref = "file:///tmp/raw-transcript"; assert.throws( () => evaluateExternalEvidenceAdmission({ @@ -221,10 +279,9 @@ test("admission fails closed on stale plan identity and local file provenance", test("retirement waits for downstream use of every admitted source", () => { const currentPlan = plan(); - const requestId = (currentPlan.request as Record).request_id; const admission = evaluateExternalEvidenceAdmission({ plan: currentPlan, - receipt: receipt(requestId), + receipt: receipt(currentPlan), decision: { disposition: "admit", reason: "direct evidence", From 09e44bb709e56908ea3130dc2c912f10c567b214 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:33:52 +0800 Subject: [PATCH 08/14] docs(capability): explain external evidence plan identity Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../rfcs/external-evidence-research-capability-v0.md | 12 ++++++++++-- ...external-evidence-research-capability-v0.zh-CN.md | 9 ++++++++- loopx/capabilities/external_research/README.md | 11 +++++++---- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/docs/architecture/rfcs/external-evidence-research-capability-v0.md b/docs/architecture/rfcs/external-evidence-research-capability-v0.md index b5c13f26ba..97a0be3605 100644 --- a/docs/architecture/rfcs/external-evidence-research-capability-v0.md +++ b/docs/architecture/rfcs/external-evidence-research-capability-v0.md @@ -43,8 +43,14 @@ exact ready plan, records that execution was observed, and still does not claim evidence completeness, admission, or automatic promotion. Failure and empty evidence remain fail-open to the caller's original-source path. -The receipt binds the exact request, selected provider, completion time, and a -digest over the complete receipt. Each admitted source has a direct non-file +The plan carries a content-addressed `plan_id` over its normalized request, +provider candidates, selected ready provider, and execution envelope. The +provider receipt must echo that `plan_id`; Core reconstructs the canonical plan +and verifies the digest before it can report observed execution or admission. +The digest detects semantic plan mutation but grants no provider authority. + +The receipt binds that exact plan, completion time, and a digest over the +complete receipt. Each admitted source has a direct non-file reference, source family, evidence basis (`stated`, `observed`, `tested`, or `inferred`), finding, limitation, relevant dates, and a content digest. Raw provider content is never part of the Core projection. @@ -81,6 +87,8 @@ inventory-only row for the same provider id. - method and connector providers use one protocol and receipt contract; - an observed provider receipt is bound to the exact plan without implying evidence coverage, admission, or promotion; +- mutation of the request objective, decision, constraints, provider readiness, + or execution envelope fails canonical `plan_id` verification; - stale request/provider identity, file provenance, and unsupported evidence basis fail closed; - admitted source refs are a subset of receipt sources; diff --git a/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md b/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md index 4a411bd863..a744a4e93f 100644 --- a/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md +++ b/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md @@ -34,7 +34,12 @@ Provider 执行仍归既有 method 或 connector owner。Core 的 `receipt` 边 provenance 与精确 ready plan 绑定,记录“执行已观察”,但仍不宣称证据完整、已被采纳 或可自动晋升。执行失败或空证据时,对 caller 的原始来源路径保持 fail-open。 -回执绑定精确请求、已选 provider、完成时间与完整回执 digest。每条被采纳来源都包含直接且非文件型引用、来源 +plan 通过内容寻址的 `plan_id` 绑定规范化请求、provider candidates、已选 ready +provider 与 execution envelope。provider 回执必须回传该 `plan_id`;Core 只有重建 +canonical plan 并验证 digest 后,才能报告“执行已观察”或进入准入。该 digest 能检测 +plan 语义被改写,但不授予任何 provider 权限。 + +回执绑定该精确 plan、完成时间与完整回执 digest。每条被采纳来源都包含直接且非文件型引用、来源 家族、证据基础(`stated`、`observed`、`tested` 或 `inferred`)、发现、局限、相关 日期和内容摘要。Core 投影永不携带 provider 原始内容。 @@ -63,6 +68,8 @@ Connector registry 继续只拥有库存与遥测。`supported` 绝不映射为 - discovery 能区分 empty、inventory-only 与 ready 库存,且不冒充执行或证据覆盖; - method 与 connector provider 使用同一协议与回执; - 已观察 provider 回执必须绑定精确 plan,且不得冒充证据覆盖、采纳或晋升; +- 请求目标、决策、约束、provider readiness 或 execution envelope 被改写时, + canonical `plan_id` 验证必须 fail closed; - 过期请求/provider 身份、文件 provenance、未知证据基础均 fail closed; - 被采纳 source ref 必须是回执来源的子集; - 全部被采纳来源完成下游覆盖前不得退休; diff --git a/loopx/capabilities/external_research/README.md b/loopx/capabilities/external_research/README.md index 22f5c2b0e7..509d68718f 100644 --- a/loopx/capabilities/external_research/README.md +++ b/loopx/capabilities/external_research/README.md @@ -22,11 +22,13 @@ The lifecycle is: false unless separately observed; 2. `plan`: bind **object + user activity + decision** and required evidence kinds to one provider that is currently declared, installed, enabled, and - ready; + ready, then content-address the normalized request, candidate inventory, + selection, and execution envelope as `plan_id`; 3. provider execution: the selected host method or connector reads external sources under its own adapter and permission boundary; -4. `receipt`: validate the observed execution against the exact plan without - claiming evidence coverage, admission, or automatic promotion; +4. `receipt`: require the provider to echo `plan_id`, reconstruct and verify the + canonical plan, then validate the observed execution without claiming + evidence coverage, admission, or automatic promotion; 5. `admit`: validate the exact request/provider identity and source-level provenance, bind the complete receipt digest, then record the parent agent's admit/reject decision; @@ -37,7 +39,8 @@ The lifecycle is: 生命周期为:`discover` 只读投影 method/connector 库存与当前 readiness,并明确区分 registry presence、真实执行和证据覆盖;`plan` 绑定“对象 + 用户活动 + 决策”并选择当前 -真实 ready 的 provider;provider 在自己的权限边界内执行;`receipt` 把执行观察绑定精确 plan, +真实 ready 的 provider,并用 `plan_id` 对规范化请求、候选库存、选择和 execution envelope +做内容寻址;provider 在自己的权限边界内执行;`receipt` 回传并校验 `plan_id`,把执行观察绑定精确 plan, 但不冒充证据覆盖、采纳或自动晋升;`admit` 校验请求、provider、完成时间、 完整 receipt digest 与逐来源 provenance,并记录父 Agent 的采纳/拒绝;下游只投影紧凑证据;被采纳的来源全部完成 下游读回后才可 `retire`。 From fbf596c21b245d815e49ee994624911cab5fd59b Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:48:04 +0800 Subject: [PATCH 09/14] fix(capability): verify external evidence admission receipts Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../external_research/catalog_entry.py | 2 +- .../capabilities/external_evidence.ts | 189 ++++++++++++++---- .../control_plane/effect_runtime_handlers.ts | 4 +- .../test_external_evidence_cli.py | 2 +- .../external_evidence_research.test.ts | 61 +++++- 5 files changed, 203 insertions(+), 55 deletions(-) diff --git a/loopx/capabilities/external_research/catalog_entry.py b/loopx/capabilities/external_research/catalog_entry.py index b3024204c6..4784216ff1 100644 --- a/loopx/capabilities/external_research/catalog_entry.py +++ b/loopx/capabilities/external_research/catalog_entry.py @@ -21,7 +21,7 @@ ), "user_value": ( "Discover method and connector inventory, select only a currently ready provider, " - "bind an observed execution receipt to its exact plan, and admit or reject compact " + "bind a caller-presented provider receipt to its exact plan, and admit or reject compact " "provenance without copying raw provider content." ), "next_real_step": ( diff --git a/loopx/control_plane/capabilities/external_evidence.ts b/loopx/control_plane/capabilities/external_evidence.ts index 09b731912c..363ff896db 100644 --- a/loopx/control_plane/capabilities/external_evidence.ts +++ b/loopx/control_plane/capabilities/external_evidence.ts @@ -14,8 +14,8 @@ export const EXTERNAL_EVIDENCE_DISCOVERY_SCHEMA_VERSION = "loopx_external_evidence_discovery_v0"; export const EXTERNAL_EVIDENCE_PLAN_SCHEMA_VERSION = "loopx_external_evidence_plan_v0"; -export const EXTERNAL_EVIDENCE_EXECUTION_SCHEMA_VERSION = - "loopx_external_evidence_execution_v0"; +export const EXTERNAL_EVIDENCE_RECEIPT_OBSERVATION_SCHEMA_VERSION = + "loopx_external_evidence_receipt_observation_v0"; export const EXTERNAL_EVIDENCE_RECEIPT_SCHEMA_VERSION = "loopx_external_evidence_receipt_v0"; export const EXTERNAL_EVIDENCE_ADMISSION_SCHEMA_VERSION = @@ -309,43 +309,47 @@ function normalizeReadyPlan(value: unknown): JsonObject { }; } -function sourceRecord(value: unknown, index: number): JsonObject { - const source = requireJsonObject(value, `receipt.sources[${index}]`); - const sourceRef = boundedText(source.source_ref, `receipt.sources[${index}].source_ref`, 2048); +function sourceRecord( + value: unknown, + index: number, + label = "receipt.sources", +): JsonObject { + const source = requireJsonObject(value, `${label}[${index}]`); + const sourceRef = boundedText(source.source_ref, `${label}[${index}].source_ref`, 2048); requireThat( SOURCE_REF_RE.test(sourceRef) && !sourceRef.startsWith("file://"), - `receipt.sources[${index}].source_ref must be a non-file provenance URI`, + `${label}[${index}].source_ref must be a non-file provenance URI`, ); const contentDigest = boundedText( source.content_digest, - `receipt.sources[${index}].content_digest`, + `${label}[${index}].content_digest`, 71, ); - requireThat(SHA256_RE.test(contentDigest), `receipt.sources[${index}].content_digest is invalid`); + requireThat(SHA256_RE.test(contentDigest), `${label}[${index}].content_digest is invalid`); return { source_ref: sourceRef, source_family: boundedText( source.source_family, - `receipt.sources[${index}].source_family`, + `${label}[${index}].source_family`, 128, ), basis: requireStringLiteral( source.basis, EVIDENCE_BASES, - `receipt.sources[${index}].basis`, + `${label}[${index}].basis`, ), - finding: boundedText(source.finding, `receipt.sources[${index}].finding`, 4096), + finding: boundedText(source.finding, `${label}[${index}].finding`, 4096), limitation: source.limitation === null || source.limitation === undefined ? null - : boundedText(source.limitation, `receipt.sources[${index}].limitation`, 2048), + : boundedText(source.limitation, `${label}[${index}].limitation`, 2048), publication_date: source.publication_date === null || source.publication_date === undefined ? null : boundedText( source.publication_date, - `receipt.sources[${index}].publication_date`, + `${label}[${index}].publication_date`, 64, ), - accessed_at: boundedText(source.accessed_at, `receipt.sources[${index}].accessed_at`, 64), + accessed_at: boundedText(source.accessed_at, `${label}[${index}].accessed_at`, 64), content_digest: contentDigest, }; } @@ -369,7 +373,7 @@ function normalizeExecutionReceipt( const status = requireStringLiteral(receipt.status, RECEIPT_STATUSES, "receipt.status"); requireThat(Array.isArray(receipt.sources), "receipt.sources must be an array"); requireThat(receipt.sources.length <= 64, "receipt.sources has too many items"); - const sources = receipt.sources.map(sourceRecord); + const sources = receipt.sources.map((source, index) => sourceRecord(source, index)); requireThat( new Set(sources.map((source) => source.source_ref)).size === sources.length, "receipt source refs must be unique", @@ -395,17 +399,17 @@ function normalizeExecutionReceipt( }; } -export function recordExternalEvidenceExecution(params: JsonObject): JsonObject { +export function recordExternalEvidenceReceiptObservation(params: JsonObject): JsonObject { const { plan, receipt } = normalizeExecutionReceipt(params.plan, params.receipt); - const executionIdentity = { + const observationIdentity = { plan_id: plan.plan_id, request_id: receipt.request_id, provider_id: receipt.provider_id, receipt_digest: digest(receipt), }; return { - schema_version: EXTERNAL_EVIDENCE_EXECUTION_SCHEMA_VERSION, - execution_id: digest(executionIdentity), + schema_version: EXTERNAL_EVIDENCE_RECEIPT_OBSERVATION_SCHEMA_VERSION, + receipt_observation_id: digest(observationIdentity), plan_id: plan.plan_id, request_id: receipt.request_id, provider_id: receipt.provider_id, @@ -413,8 +417,9 @@ export function recordExternalEvidenceExecution(params: JsonObject): JsonObject status: receipt.status, receipt, truth_contract: { - provider_execution_observed: true, - evidence_produced: receipt.status === "succeeded", + provider_receipt_observed: true, + provider_execution_attested: false, + evidence_produced_reported: receipt.status === "succeeded", evidence_coverage_observed: false, automatic_admission: false, automatic_promotion: false, @@ -457,17 +462,21 @@ export function evaluateExternalEvidenceAdmission(params: JsonObject): JsonObjec "reject cannot carry admitted sources", ); const admitted = sources.filter((source) => admittedRefs.includes(source.source_ref as string)); - const admissionIdentity = { + const downstreamProjection = { + schema_version: "loopx_external_evidence_projection_v0", plan_id: plan.plan_id, request_id: request.request_id, - provider_id: selected.provider_id, - receipt_digest: receiptDigest, + objective: request.objective, + decision: request.decision, disposition, - admitted_source_refs: admittedRefs, + sources: admitted, + summary: boundedText(receipt.summary, "receipt.summary", 4096), + limitations: receipt.limitations === undefined + ? [] + : boundedStrings(receipt.limitations, "receipt.limitations", 16, 1024), }; - return { + const admission = { schema_version: EXTERNAL_EVIDENCE_ADMISSION_SCHEMA_VERSION, - admission_id: digest(admissionIdentity), plan_id: plan.plan_id, request_id: request.request_id, provider_id: selected.provider_id, @@ -478,34 +487,130 @@ export function evaluateExternalEvidenceAdmission(params: JsonObject): JsonObjec disposition, reason, admitted_source_refs: admittedRefs, - downstream_projection: { - schema_version: "loopx_external_evidence_projection_v0", - plan_id: plan.plan_id, - request_id: request.request_id, - objective: request.objective, - decision: request.decision, - disposition, - sources: admitted, - summary: boundedText(receipt.summary, "receipt.summary", 4096), - limitations: receipt.limitations === undefined - ? [] - : boundedStrings(receipt.limitations, "receipt.limitations", 16, 1024), - }, + downstream_projection: downstreamProjection, + }; + return { + ...admission, + admission_id: digest(admission), }; } -export function projectExternalEvidenceRetirement(params: JsonObject): JsonObject { - const admission = requireJsonObject(params.admission, "external evidence admission"); +function normalizeAdmission(value: unknown): JsonObject { + const admission = requireJsonObject(value, "external evidence admission"); requireThat( admission.schema_version === EXTERNAL_EVIDENCE_ADMISSION_SCHEMA_VERSION, "external evidence admission schema is invalid", ); + const planId = boundedText(admission.plan_id, "admission.plan_id", 71); + const requestId = boundedText(admission.request_id, "admission.request_id", 71); + const receiptDigest = boundedText( + admission.receipt_digest, + "admission.receipt_digest", + 71, + ); + requireThat(SHA256_RE.test(planId), "admission.plan_id is invalid"); + requireThat(SHA256_RE.test(requestId), "admission.request_id is invalid"); + requireThat(SHA256_RE.test(receiptDigest), "admission.receipt_digest is invalid"); + const providerId = boundedText(admission.provider_id, "admission.provider_id", 96); + requireThat(PROVIDER_ID_RE.test(providerId), "admission.provider_id is invalid"); + const providerKind = requireStringLiteral( + admission.provider_kind, + PROVIDER_KINDS, + "admission.provider_kind", + ); + const receiptStatus = requireStringLiteral( + admission.receipt_status, + RECEIPT_STATUSES, + "admission.receipt_status", + ); + const disposition = requireStringLiteral( + admission.disposition, + ADMISSION_DECISIONS, + "admission.disposition", + ); const admittedRefs = boundedStrings( admission.admitted_source_refs, "admission.admitted_source_refs", 64, 2048, ); + requireThat( + new Set(admittedRefs).size === admittedRefs.length, + "admission admitted source refs must be unique", + ); + requireThat( + disposition !== "admit" || (receiptStatus === "succeeded" && admittedRefs.length > 0), + "admission admit disposition requires succeeded evidence", + ); + requireThat( + disposition !== "reject" || admittedRefs.length === 0, + "admission reject disposition cannot carry admitted sources", + ); + const projection = requireJsonObject( + admission.downstream_projection, + "external evidence downstream projection", + ); + requireThat( + projection.schema_version === "loopx_external_evidence_projection_v0" && + projection.plan_id === planId && + projection.request_id === requestId && + projection.disposition === disposition, + "external evidence downstream projection does not match the admission", + ); + requireThat(Array.isArray(projection.sources), "downstream projection sources must be an array"); + requireThat(projection.sources.length <= 64, "downstream projection has too many sources"); + const sources = projection.sources.map((source, index) => + sourceRecord(source, index, "downstream_projection.sources") + ); + const projectedRefs = sources.map((source) => source.source_ref as string); + requireThat( + projectedRefs.length === admittedRefs.length && + projectedRefs.every((sourceRef) => admittedRefs.includes(sourceRef)), + "downstream projection sources do not match admitted source refs", + ); + const normalizedAdmission = { + schema_version: EXTERNAL_EVIDENCE_ADMISSION_SCHEMA_VERSION, + plan_id: planId, + request_id: requestId, + provider_id: providerId, + provider_kind: providerKind, + receipt_status: receiptStatus, + receipt_digest: receiptDigest, + completed_at: boundedText(admission.completed_at, "admission.completed_at", 64), + disposition, + reason: boundedText(admission.reason, "admission.reason", 2048), + admitted_source_refs: admittedRefs, + downstream_projection: { + schema_version: "loopx_external_evidence_projection_v0", + plan_id: planId, + request_id: requestId, + objective: boundedText(projection.objective, "downstream_projection.objective"), + decision: boundedText(projection.decision, "downstream_projection.decision"), + disposition, + sources, + summary: boundedText(projection.summary, "downstream_projection.summary", 4096), + limitations: projection.limitations === undefined + ? [] + : boundedStrings( + projection.limitations, + "downstream_projection.limitations", + 16, + 1024, + ), + }, + }; + const admissionId = boundedText(admission.admission_id, "admission.admission_id", 71); + requireThat(SHA256_RE.test(admissionId), "admission.admission_id is invalid"); + requireThat( + admissionId === digest(normalizedAdmission), + "admission_id does not match the normalized admission", + ); + return { ...normalizedAdmission, admission_id: admissionId }; +} + +export function projectExternalEvidenceRetirement(params: JsonObject): JsonObject { + const admission = normalizeAdmission(params.admission); + const admittedRefs = admission.admitted_source_refs as string[]; const downstreamRefs = params.downstream_source_refs === undefined ? [] : boundedStrings(params.downstream_source_refs, "downstream_source_refs", 64, 2048); diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index b0104f5b14..531ad1a3a4 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -203,7 +203,7 @@ import { planExternalEvidenceRequest, projectExternalEvidenceDiscovery, projectExternalEvidenceRetirement, - recordExternalEvidenceExecution, + recordExternalEvidenceReceiptObservation, } from "./capabilities/external_evidence.ts"; type EffectRuntimeHandler = (params: JsonObject) => unknown | Promise; @@ -668,7 +668,7 @@ export function createEffectRuntimeHandlers( ], ["external_evidence.discover", projectExternalEvidenceDiscovery], ["external_evidence.plan", planExternalEvidenceRequest], - ["external_evidence.receipt", recordExternalEvidenceExecution], + ["external_evidence.receipt", recordExternalEvidenceReceiptObservation], ["external_evidence.admit", evaluateExternalEvidenceAdmission], ["external_evidence.retire", projectExternalEvidenceRetirement], [ diff --git a/tests/capabilities/test_external_evidence_cli.py b/tests/capabilities/test_external_evidence_cli.py index d742675bc9..581c323b6a 100644 --- a/tests/capabilities/test_external_evidence_cli.py +++ b/tests/capabilities/test_external_evidence_cli.py @@ -222,7 +222,7 @@ def fake_runtime(method, params): captured["method"] = method captured["params"] = params return { - "schema_version": "loopx_external_evidence_execution_v0", + "schema_version": "loopx_external_evidence_receipt_observation_v0", "status": "succeeded", } diff --git a/tests/control_plane_ts/external_evidence_research.test.ts b/tests/control_plane_ts/external_evidence_research.test.ts index 0692107516..0b84cac7a3 100644 --- a/tests/control_plane_ts/external_evidence_research.test.ts +++ b/tests/control_plane_ts/external_evidence_research.test.ts @@ -6,7 +6,7 @@ import { planExternalEvidenceRequest, projectExternalEvidenceDiscovery, projectExternalEvidenceRetirement, - recordExternalEvidenceExecution, + recordExternalEvidenceReceiptObservation, } from "../../loopx/control_plane/capabilities/external_evidence.ts"; const request = { @@ -144,18 +144,19 @@ test("rejects a provider that claims ready without lifecycle readiness", () => { ); }); -test("records provider execution without claiming coverage or admission", () => { +test("records a provider receipt without attesting execution or claiming coverage", () => { const currentPlan = plan(); - const result = recordExternalEvidenceExecution({ + const result = recordExternalEvidenceReceiptObservation({ plan: currentPlan, receipt: receipt(currentPlan), }); assert.equal(result.status, "succeeded"); assert.equal(result.plan_id, currentPlan.plan_id); - assert.match(String(result.execution_id), /^sha256:[0-9a-f]{64}$/); + assert.match(String(result.receipt_observation_id), /^sha256:[0-9a-f]{64}$/); assert.deepEqual(result.truth_contract, { - provider_execution_observed: true, - evidence_produced: true, + provider_receipt_observed: true, + provider_execution_attested: false, + evidence_produced_reported: true, evidence_coverage_observed: false, automatic_admission: false, automatic_promotion: false, @@ -166,7 +167,7 @@ test("records provider execution without claiming coverage or admission", () => test("execution receipt fails closed on stale provider identity", () => { const currentPlan = plan(); assert.throws( - () => recordExternalEvidenceExecution({ + () => recordExternalEvidenceReceiptObservation({ plan: currentPlan, receipt: { ...receipt(currentPlan), provider_id: "connector:stale" }, }), @@ -177,7 +178,7 @@ test("execution receipt fails closed on stale provider identity", () => { test("execution receipt fails closed on stale plan identity", () => { const currentPlan = plan(); assert.throws( - () => recordExternalEvidenceExecution({ + () => recordExternalEvidenceReceiptObservation({ plan: currentPlan, receipt: { ...receipt(currentPlan), plan_id: `sha256:${"b".repeat(64)}` }, }), @@ -214,7 +215,7 @@ test("canonical ready-plan verification rejects semantic mutations", () => { const mutatedPlan = structuredClone(currentPlan) as Record; mutate(mutatedPlan); assert.throws( - () => recordExternalEvidenceExecution({ + () => recordExternalEvidenceReceiptObservation({ plan: mutatedPlan, receipt: receipt(currentPlan), }), @@ -292,6 +293,10 @@ test("retirement waits for downstream use of every admitted source", () => { projectExternalEvidenceRetirement({ admission, downstream_source_refs: [] }).status, "retained", ); + assert.equal( + projectExternalEvidenceRetirement({ admission, downstream_source_refs: [] }).plan_id, + currentPlan.plan_id, + ); assert.equal( projectExternalEvidenceRetirement({ admission, @@ -300,3 +305,41 @@ test("retirement waits for downstream use of every admitted source", () => { "retire_ready", ); }); + +test("retirement fails closed on mutated admission semantics", () => { + const currentPlan = plan(); + const admission = evaluateExternalEvidenceAdmission({ + plan: currentPlan, + receipt: receipt(currentPlan), + decision: { + disposition: "admit", + reason: "direct evidence", + admitted_source_refs: ["https://example.com/original"], + }, + }); + const forgedReject = structuredClone(admission) as Record; + forgedReject.disposition = "reject"; + forgedReject.admitted_source_refs = []; + const forgedProjection = forgedReject.downstream_projection as Record; + forgedProjection.disposition = "reject"; + forgedProjection.sources = []; + assert.throws( + () => projectExternalEvidenceRetirement({ + admission: forgedReject, + downstream_source_refs: [], + }), + /admission_id does not match/, + ); + + const mutatedFinding = structuredClone(admission) as Record; + const mutatedProjection = mutatedFinding.downstream_projection as Record; + const mutatedSources = mutatedProjection.sources as Array>; + mutatedSources[0].finding = "A different finding"; + assert.throws( + () => projectExternalEvidenceRetirement({ + admission: mutatedFinding, + downstream_source_refs: ["https://example.com/original"], + }), + /admission_id does not match/, + ); +}); From 5aaf27b96d913f569b888df9e10bf5f58b286a96 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:48:04 +0800 Subject: [PATCH 10/14] docs(capability): narrow external evidence receipt claims Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- ...xternal-evidence-research-capability-v0.md | 20 ++++++++++++------- ...l-evidence-research-capability-v0.zh-CN.md | 16 +++++++++------ .../capabilities/external_research/README.md | 18 +++++++++-------- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/docs/architecture/rfcs/external-evidence-research-capability-v0.md b/docs/architecture/rfcs/external-evidence-research-capability-v0.md index 97a0be3605..bc3027d235 100644 --- a/docs/architecture/rfcs/external-evidence-research-capability-v0.md +++ b/docs/architecture/rfcs/external-evidence-research-capability-v0.md @@ -39,15 +39,17 @@ evidence coverage. Provider execution stays with the existing method or connector owner. Core's `receipt` boundary validates the returned identity and provenance against the -exact ready plan, records that execution was observed, and still does not claim -evidence completeness, admission, or automatic promotion. Failure and empty -evidence remain fail-open to the caller's original-source path. +exact ready plan and records that a caller-presented receipt was observed. It +does not attest that provider execution occurred, or claim evidence +completeness, admission, or automatic promotion. Failure and empty evidence +remain fail-open to the caller's original-source path. The plan carries a content-addressed `plan_id` over its normalized request, provider candidates, selected ready provider, and execution envelope. The provider receipt must echo that `plan_id`; Core reconstructs the canonical plan -and verifies the digest before it can report observed execution or admission. -The digest detects semantic plan mutation but grants no provider authority. +and verifies the digest before it can report the receipt observation or +admission. The digest detects semantic plan mutation but grants no provider +authority and is not a provider attestation. The receipt binds that exact plan, completion time, and a digest over the complete receipt. Each admitted source has a direct non-file @@ -57,7 +59,9 @@ content digest. Raw provider content is never part of the Core projection. The parent agent explicitly admits or rejects evidence. Rejection can retire; admission remains retained until downstream readback covers every admitted -source reference. +source reference. The admission id content-addresses the complete normalized +admission and downstream projection; retirement reconstructs and verifies that +identity before evaluating coverage. ## Ownership and TypeScript migration @@ -86,12 +90,14 @@ inventory-only row for the same provider id. claiming execution or evidence coverage; - method and connector providers use one protocol and receipt contract; - an observed provider receipt is bound to the exact plan without implying - evidence coverage, admission, or promotion; + authenticated execution, evidence coverage, admission, or promotion; - mutation of the request objective, decision, constraints, provider readiness, or execution envelope fails canonical `plan_id` verification; - stale request/provider identity, file provenance, and unsupported evidence basis fail closed; - admitted source refs are a subset of receipt sources; +- retirement rejects mutated disposition, source, or downstream projection + fields whose complete admission identity no longer matches; - retirement waits for downstream coverage of every admitted source; - CLI and effect-runtime TypeScript tests pass from the source checkout. diff --git a/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md b/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md index a744a4e93f..e925ccff09 100644 --- a/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md +++ b/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md @@ -31,20 +31,22 @@ ready provider id,并携带显式真值合同:registry presence 与 `support readiness,discovery 本身既不证明 provider 已执行,也不证明已有证据覆盖。 Provider 执行仍归既有 method 或 connector owner。Core 的 `receipt` 边界把返回身份和 -provenance 与精确 ready plan 绑定,记录“执行已观察”,但仍不宣称证据完整、已被采纳 -或可自动晋升。执行失败或空证据时,对 caller 的原始来源路径保持 fail-open。 +provenance 与精确 ready plan 绑定,只记录“已观察到 caller 提交的 receipt”,并不证明 +provider 真实执行,也不宣称证据完整、已被采纳或可自动晋升。执行失败或空证据时, +对 caller 的原始来源路径保持 fail-open。 plan 通过内容寻址的 `plan_id` 绑定规范化请求、provider candidates、已选 ready provider 与 execution envelope。provider 回执必须回传该 `plan_id`;Core 只有重建 -canonical plan 并验证 digest 后,才能报告“执行已观察”或进入准入。该 digest 能检测 -plan 语义被改写,但不授予任何 provider 权限。 +canonical plan 并验证 digest 后,才能报告 receipt observation 或进入准入。该 digest +能检测 plan 语义被改写,但不授予任何 provider 权限,也不构成 provider attestation。 回执绑定该精确 plan、完成时间与完整回执 digest。每条被采纳来源都包含直接且非文件型引用、来源 家族、证据基础(`stated`、`observed`、`tested` 或 `inferred`)、发现、局限、相关 日期和内容摘要。Core 投影永不携带 provider 原始内容。 父 Agent 必须显式采纳或拒绝。拒绝后可以退休;采纳后必须等下游读回覆盖全部被采纳 -source ref,才能退休。 +source ref,才能退休。admission id 对完整规范化 admission 与下游投影做内容寻址; +retirement 必须重建并验证该身份后,才可判断覆盖。 ## 所有权与 TypeScript 迁移 @@ -67,11 +69,13 @@ Connector registry 继续只拥有库存与遥测。`supported` 绝不映射为 - inventory-only connector 不可被选择; - discovery 能区分 empty、inventory-only 与 ready 库存,且不冒充执行或证据覆盖; - method 与 connector provider 使用同一协议与回执; -- 已观察 provider 回执必须绑定精确 plan,且不得冒充证据覆盖、采纳或晋升; +- 已观察 provider 回执必须绑定精确 plan,且不得冒充经认证的执行、证据覆盖、采纳或晋升; - 请求目标、决策、约束、provider readiness 或 execution envelope 被改写时, canonical `plan_id` 验证必须 fail closed; - 过期请求/provider 身份、文件 provenance、未知证据基础均 fail closed; - 被采纳 source ref 必须是回执来源的子集; +- disposition、来源或下游投影被改写而与完整 admission identity 不一致时,retirement + 必须 fail closed; - 全部被采纳来源完成下游覆盖前不得退休; - CLI 与 effect-runtime TypeScript 测试在源码 checkout 中通过。 diff --git a/loopx/capabilities/external_research/README.md b/loopx/capabilities/external_research/README.md index 509d68718f..ef3874156e 100644 --- a/loopx/capabilities/external_research/README.md +++ b/loopx/capabilities/external_research/README.md @@ -26,24 +26,26 @@ The lifecycle is: selection, and execution envelope as `plan_id`; 3. provider execution: the selected host method or connector reads external sources under its own adapter and permission boundary; -4. `receipt`: require the provider to echo `plan_id`, reconstruct and verify the - canonical plan, then validate the observed execution without claiming - evidence coverage, admission, or automatic promotion; +4. `receipt`: require the caller-presented provider receipt to echo `plan_id`, + reconstruct and verify the canonical plan, then record the receipt + observation without treating it as provider execution attestation or + claiming evidence coverage, admission, or automatic promotion; 5. `admit`: validate the exact request/provider identity and source-level provenance, bind the complete receipt digest, then record the parent agent's admit/reject decision; 6. downstream projection: pass only compact findings, limitations, direct references, evidence basis, dates, and content digests; -7. `retire`: retire rejected evidence immediately, or admitted evidence only - after every admitted source reference appears in downstream readback. +7. `retire`: revalidate the complete content-addressed admission, then retire + rejected evidence immediately, or admitted evidence only after every + admitted source reference appears in downstream readback. 生命周期为:`discover` 只读投影 method/connector 库存与当前 readiness,并明确区分 registry presence、真实执行和证据覆盖;`plan` 绑定“对象 + 用户活动 + 决策”并选择当前 真实 ready 的 provider,并用 `plan_id` 对规范化请求、候选库存、选择和 execution envelope -做内容寻址;provider 在自己的权限边界内执行;`receipt` 回传并校验 `plan_id`,把执行观察绑定精确 plan, -但不冒充证据覆盖、采纳或自动晋升;`admit` 校验请求、provider、完成时间、 +做内容寻址;provider 在自己的权限边界内执行;`receipt` 回传并校验 `plan_id`,只记录 +caller 提交的回执,不把它冒充 provider 执行证明、证据覆盖、采纳或自动晋升;`admit` 校验请求、provider、完成时间、 完整 receipt digest 与逐来源 provenance,并记录父 Agent 的采纳/拒绝;下游只投影紧凑证据;被采纳的来源全部完成 -下游读回后才可 `retire`。 +下游读回后才可 `retire`,且 retirement 会先重验完整 admission identity。 The connector registry is only inventory and telemetry. A connector row marked `supported` is projected as `ready=false` until a current provider lifecycle From 6cf0cd4abc313dc11be2f2b6a85a0c436173c7b2 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:56:25 +0800 Subject: [PATCH 11/14] fix(capability): keep receipt projection identity strict Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/capabilities/external_research/cli.py | 2 +- loopx/control_plane/capabilities/external_evidence.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/loopx/capabilities/external_research/cli.py b/loopx/capabilities/external_research/cli.py index 1b3d2d1363..fd3c4da9db 100644 --- a/loopx/capabilities/external_research/cli.py +++ b/loopx/capabilities/external_research/cli.py @@ -144,7 +144,7 @@ def register_external_evidence_commands( receipt = actions.add_parser( "receipt", - help="Validate and bind an observed provider execution receipt to its exact plan.", + help="Validate and bind a caller-presented provider receipt to its exact plan.", ) receipt.add_argument("--plan-json", required=True) receipt.add_argument("--receipt-json", required=True) diff --git a/loopx/control_plane/capabilities/external_evidence.ts b/loopx/control_plane/capabilities/external_evidence.ts index 363ff896db..13c24f1c7e 100644 --- a/loopx/control_plane/capabilities/external_evidence.ts +++ b/loopx/control_plane/capabilities/external_evidence.ts @@ -564,8 +564,9 @@ function normalizeAdmission(value: unknown): JsonObject { ); const projectedRefs = sources.map((source) => source.source_ref as string); requireThat( + new Set(projectedRefs).size === projectedRefs.length && projectedRefs.length === admittedRefs.length && - projectedRefs.every((sourceRef) => admittedRefs.includes(sourceRef)), + admittedRefs.every((sourceRef) => projectedRefs.includes(sourceRef)), "downstream projection sources do not match admitted source refs", ); const normalizedAdmission = { From 6ca2b48a240469716cf0af97f7c7d2937f0f2a87 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:33:12 +0800 Subject: [PATCH 12/14] fix(packaging): ship external evidence runtime owner Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/control_plane/capabilities/__init__.py | 1 + pyproject.toml | 1 + 2 files changed, 2 insertions(+) create mode 100644 loopx/control_plane/capabilities/__init__.py diff --git a/loopx/control_plane/capabilities/__init__.py b/loopx/control_plane/capabilities/__init__.py new file mode 100644 index 0000000000..593cde1c4e --- /dev/null +++ b/loopx/control_plane/capabilities/__init__.py @@ -0,0 +1 @@ +"""Typed control-plane capability owners.""" diff --git a/pyproject.toml b/pyproject.toml index 218d87e464..c241409912 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ include = ["loopx*"] "loopx.control_plane" = ["*.json", "*.ts", "presentation/*.ts"] "loopx.semantics" = ["*.json"] "loopx.control_plane.agents" = ["*.ts"] +"loopx.control_plane.capabilities" = ["*.ts"] "loopx.control_plane.collaboration" = ["*.ts"] "loopx.control_plane.coordination" = ["*.ts", "*.json"] "loopx.control_plane.goals" = ["*.ts"] From 83ab0c0ebd7bb8b1b2be3a8a3a2d0fe879b10bbd Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:32:24 +0800 Subject: [PATCH 13/14] ci: preserve minimum Node conformance coverage Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .github/workflows/python-tests.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 2ed266160f..1067478405 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -213,7 +213,10 @@ jobs: needs: changes if: needs.changes.outputs.core_tests == 'true' runs-on: ubuntu-latest - timeout-minutes: 10 + # The full public-minimum conformance suite normally takes about 9-10 + # minutes on hosted runners. Keep enough headroom for setup and cleanup + # without dropping compatibility cases or weakening their deadlines. + timeout-minutes: 15 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 From f0862fb51b604d33b15cd76b5418d7553cc6b1ed Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:49:56 +0800 Subject: [PATCH 14/14] fix(capability): register external evidence catalog contract Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../external_research/catalog_entry.py | 12 +++++++--- .../test_capability_extension_registry.py | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/loopx/capabilities/external_research/catalog_entry.py b/loopx/capabilities/external_research/catalog_entry.py index 4784216ff1..047db8bc36 100644 --- a/loopx/capabilities/external_research/catalog_entry.py +++ b/loopx/capabilities/external_research/catalog_entry.py @@ -43,8 +43,8 @@ { "command": "loopx external-evidence receipt --plan-json plan.json --receipt-json receipt.json", "purpose": ( - "Bind observed provider execution to the exact plan without claiming " - "coverage, admission, or promotion." + "Bind a caller-presented provider receipt to the exact plan without claiming " + "provider execution, coverage, admission, or promotion." ), "write_boundary": "read-only typed reduction; provider owner performs execution", }, @@ -59,7 +59,13 @@ "write_boundary": "read-only", }, ], - "implemented_protocols": ["external_evidence_research_v0"], + "implemented_protocols": [ + { + "schema_version": "external_evidence_research_v0", + "module": "loopx.control_plane.capabilities.external_evidence", + "doc": "loopx/capabilities/external_research/README.md", + } + ], "smokes": [ "node --no-warnings --experimental-strip-types --test tests/control_plane_ts/external_evidence_research.test.ts", "python -m pytest tests/capabilities/test_external_evidence_cli.py -q", diff --git a/tests/capabilities/test_capability_extension_registry.py b/tests/capabilities/test_capability_extension_registry.py index 83f299ba27..ce31f6e31f 100644 --- a/tests/capabilities/test_capability_extension_registry.py +++ b/tests/capabilities/test_capability_extension_registry.py @@ -42,6 +42,7 @@ "deep-research", "public-safe-outbound", "connector-registry", + "external-evidence-research", "reliability-diagnostics", ] @@ -169,6 +170,27 @@ def test_builtin_catalog_preserves_order_and_marks_provider() -> None: ] +def test_external_evidence_catalog_preserves_receipt_observation_boundary() -> None: + capability = build_capability_detail_packet("external-evidence-research")[ + "capability" + ] + + assert capability["implemented_protocols"] == [ + { + "schema_version": "external_evidence_research_v0", + "module": "loopx.control_plane.capabilities.external_evidence", + "doc": "loopx/capabilities/external_research/README.md", + } + ] + receipt_command = next( + command + for command in capability["commands"] + if command["command"].startswith("loopx external-evidence receipt ") + ) + assert "caller-presented provider receipt" in receipt_command["purpose"] + assert "without claiming provider execution" in receipt_command["purpose"] + + def test_material_lifecycle_catalog_exposes_managed_project_skill() -> None: capability = build_capability_detail_packet("material-lifecycle")["capability"]