diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 2ed266160..106747840 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 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 000000000..bc3027d23 --- /dev/null +++ b/docs/architecture/rfcs/external-evidence-research-capability-v0.md @@ -0,0 +1,111 @@ +# 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. + +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. + +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 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 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 +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. 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 + +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 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. + +## 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; +- an observed provider receipt is bound to the exact plan without implying + 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. + +## 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 000000000..e925ccff0 --- /dev/null +++ b/docs/architecture/rfcs/external-evidence-research-capability-v0.zh-CN.md @@ -0,0 +1,89 @@ +# 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。 + +Discovery 是只读类型化投影。它报告 method/connector 数量、ready/unavailable 数量和 +ready provider id,并携带显式真值合同:registry presence 与 `supported` 都不等于 +readiness,discovery 本身既不证明 provider 已执行,也不证明已有证据覆盖。 + +Provider 执行仍归既有 method 或 connector owner。Core 的 `receipt` 边界把返回身份和 +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 后,才能报告 receipt observation 或进入准入。该 digest +能检测 plan 语义被改写,但不授予任何 provider 权限,也不构成 provider attestation。 + +回执绑定该精确 plan、完成时间与完整回执 digest。每条被采纳来源都包含直接且非文件型引用、来源 +家族、证据基础(`stated`、`observed`、`tested` 或 `inferred`)、发现、局限、相关 +日期和内容摘要。Core 投影永不携带 provider 原始内容。 + +父 Agent 必须显式采纳或拒绝。拒绝后可以退休;采纳后必须等下游读回覆盖全部被采纳 +source ref,才能退休。admission id 对完整规范化 admission 与下游投影做内容寻址; +retirement 必须重建并验证该身份后,才可判断覆盖。 + +## 所有权与 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 discover|plan|receipt|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 回执必须绑定精确 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 中通过。 + +## 非目标 + +- 通用浏览器或搜索引擎; +- provider 凭据存储; +- 原始页面/逐字稿持久化; +- 自动采纳证据; +- 交易、发布或其他下游 effect 权限; +- 把注册或使用计数当作证据质量证明。 diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 023aff177..72ead1eb2 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 6c4653337..65149539e 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/catalog.py b/loopx/capabilities/catalog.py index 0a71aeba1..606b763d0 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/README.md b/loopx/capabilities/external_research/README.md new file mode 100644 index 000000000..ef3874156 --- /dev/null +++ b/loopx/capabilities/external_research/README.md @@ -0,0 +1,115 @@ +# 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. `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, 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`: 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`: 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`,只记录 +caller 提交的回执,不把它冒充 provider 执行证明、证据覆盖、采纳或自动晋升;`admit` 校验请求、provider、完成时间、 +完整 receipt digest 与逐来源 provenance,并记录父 Agent 的采纳/拒绝;下游只投影紧凑证据;被采纳的来源全部完成 +下游读回后才可 `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 +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 discover \ + --connector-registry \ + --format json + +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 receipt \ + --plan-json plan.json \ + --receipt-json receipt.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.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 + evidence state machine. + +TypeScript 是 discovery 真值边界、请求身份、provider 准入、provenance 校验、父 Agent 采纳、紧凑投影与 +退休条件的唯一语义 owner。Python 仅适配 CLI 与 effect-runtime transport。Managed +Turn 复用同一方法;frontend/Lark 后续只渲染同源投影,不新建 registry 或状态机。 diff --git a/loopx/capabilities/external_research/__init__.py b/loopx/capabilities/external_research/__init__.py new file mode 100644 index 000000000..2af54eda5 --- /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 000000000..047db8bc3 --- /dev/null +++ b/loopx/capabilities/external_research/catalog_entry.py @@ -0,0 +1,73 @@ +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": ( + "Discover method and connector inventory, select only a currently ready provider, " + "bind a caller-presented provider 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 " + "provider inventory to plan and admit the returned provider receipt" + ), + "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.", + "write_boundary": "read-only", + }, + { + "command": "loopx external-evidence receipt --plan-json plan.json --receipt-json receipt.json", + "purpose": ( + "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", + }, + { + "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": [ + { + "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/loopx/capabilities/external_research/cli.py b/loopx/capabilities/external_research/cli.py new file mode 100644 index 000000000..fd3c4da9d --- /dev/null +++ b/loopx/capabilities/external_research/cli.py @@ -0,0 +1,273 @@ +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 path_text is None: + return [] + registry_path = None if path_text == "" else Path(path_text).expanduser() + state = load_connector_registry(registry_path) + 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", + "plan_id", + "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')}`") + 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" + + +def register_external_evidence_commands( + subparsers: argparse._SubParsersAction, + add_subcommand_format: AddFormat, +) -> None: + parser = subparsers.add_parser( + "external-evidence", + help="Discover, plan, receipt, admit, and retire auditable external evidence.", + ) + 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) + 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", nargs="?", const="") + plan.add_argument("--preferred-provider-id") + add_subcommand_format(plan) + + receipt = actions.add_parser( + "receipt", + 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) + 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) + 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 == "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", + ) + 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 == "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" + ), + "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 discover, plan, receipt, 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/cli.py b/loopx/cli.py index 1d4b09358..d0e08d4a4 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/capabilities/__init__.py b/loopx/control_plane/capabilities/__init__.py new file mode 100644 index 000000000..593cde1c4 --- /dev/null +++ b/loopx/control_plane/capabilities/__init__.py @@ -0,0 +1 @@ +"""Typed control-plane capability owners.""" diff --git a/loopx/control_plane/capabilities/external_evidence.ts b/loopx/control_plane/capabilities/external_evidence.ts new file mode 100644 index 000000000..13c24f1c7 --- /dev/null +++ b/loopx/control_plane/capabilities/external_evidence.ts @@ -0,0 +1,635 @@ +import { createHash } from "node:crypto"; + +import type { JsonObject } from "../effect_program.ts"; +import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; +import { + requireJsonObject, + requireNonEmptyString, + requireStringLiteral, +} from "../runtime_decode.ts"; + +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_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 = + "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, + }; +} + +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 + : 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"; + 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: 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, + 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://"), + `${label}[${index}].source_ref must be a non-file provenance URI`, + ); + const contentDigest = boundedText( + source.content_digest, + `${label}[${index}].content_digest`, + 71, + ); + requireThat(SHA256_RE.test(contentDigest), `${label}[${index}].content_digest is invalid`); + return { + source_ref: sourceRef, + source_family: boundedText( + source.source_family, + `${label}[${index}].source_family`, + 128, + ), + basis: requireStringLiteral( + source.basis, + EVIDENCE_BASES, + `${label}[${index}].basis`, + ), + finding: boundedText(source.finding, `${label}[${index}].finding`, 4096), + limitation: source.limitation === null || source.limitation === undefined + ? null + : boundedText(source.limitation, `${label}[${index}].limitation`, 2048), + publication_date: source.publication_date === null || source.publication_date === undefined + ? null + : boundedText( + source.publication_date, + `${label}[${index}].publication_date`, + 64, + ), + accessed_at: boundedText(source.accessed_at, `${label}[${index}].accessed_at`, 64), + content_digest: contentDigest, + }; +} + +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"); + requireThat( + 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"); + 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((source, index) => sourceRecord(source, index)); + 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"); + return { + 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 recordExternalEvidenceReceiptObservation(params: JsonObject): JsonObject { + const { plan, receipt } = normalizeExecutionReceipt(params.plan, params.receipt); + 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_RECEIPT_OBSERVATION_SCHEMA_VERSION, + receipt_observation_id: digest(observationIdentity), + plan_id: plan.plan_id, + request_id: receipt.request_id, + provider_id: receipt.provider_id, + provider_kind: receipt.provider_kind, + status: receipt.status, + receipt, + truth_contract: { + provider_receipt_observed: true, + provider_execution_attested: false, + evidence_produced_reported: 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 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 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( + 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)); + const downstreamProjection = { + 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), + }; + const admission = { + schema_version: EXTERNAL_EVIDENCE_ADMISSION_SCHEMA_VERSION, + plan_id: plan.plan_id, + 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, + downstream_projection: downstreamProjection, + }; + return { + ...admission, + admission_id: digest(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( + new Set(projectedRefs).size === projectedRefs.length && + projectedRefs.length === admittedRefs.length && + admittedRefs.every((sourceRef) => projectedRefs.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); + 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, + admission_id: admission.admission_id, + plan_id: admission.plan_id, + 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/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 8f9d036dd..531ad1a3a 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -198,6 +198,13 @@ import { } from "./collaboration/return_delivery.ts"; import { normalizeCollaborationRequest } from "./collaboration/semantic_request.ts"; +import { + evaluateExternalEvidenceAdmission, + planExternalEvidenceRequest, + projectExternalEvidenceDiscovery, + projectExternalEvidenceRetirement, + recordExternalEvidenceReceiptObservation, +} from "./capabilities/external_evidence.ts"; type EffectRuntimeHandler = (params: JsonObject) => unknown | Promise; @@ -659,6 +666,11 @@ export function createEffectRuntimeHandlers( "collaboration.request.normalize", (params) => normalizeCollaborationRequest(params.request), ], + ["external_evidence.discover", projectExternalEvidenceDiscovery], + ["external_evidence.plan", planExternalEvidenceRequest], + ["external_evidence.receipt", recordExternalEvidenceReceiptObservation], + ["external_evidence.admit", evaluateExternalEvidenceAdmission], + ["external_evidence.retire", projectExternalEvidenceRetirement], [ "manager.return_delivery.normalize_attempt", (params) => normalizeManagerReturnDeliveryAttempt(params.attempt), diff --git a/pyproject.toml b/pyproject.toml index 218d87e46..c24140991 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"] diff --git a/tests/capabilities/test_capability_extension_registry.py b/tests/capabilities/test_capability_extension_registry.py index 83f299ba2..ce31f6e31 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"] diff --git a/tests/capabilities/test_external_evidence_cli.py b/tests/capabilities/test_external_evidence_cli.py new file mode 100644 index 000000000..581c323b6 --- /dev/null +++ b/tests/capabilities/test_external_evidence_cli.py @@ -0,0 +1,351 @@ +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_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( + 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_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_receipt_observation_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( + 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" + + +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 new file mode 100644 index 000000000..0b84cac7a --- /dev/null +++ b/tests/control_plane_ts/external_evidence_research.test.ts @@ -0,0 +1,345 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + evaluateExternalEvidenceAdmission, + planExternalEvidenceRequest, + projectExternalEvidenceDiscovery, + projectExternalEvidenceRetirement, + recordExternalEvidenceReceiptObservation, +} from "../../loopx/control_plane/capabilities/external_evidence.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(currentPlan: Record) { + const currentRequest = currentPlan.request as Record; + return { + schema_version: "loopx_external_evidence_receipt_v0", + plan_id: currentPlan.plan_id, + request_id: currentRequest.request_id, + 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."], + completed_at: "2026-09-20T10:01:00Z", + }; +} + +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"); + 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}$/, + ); + 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({ + 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("records a provider receipt without attesting execution or claiming coverage", () => { + const currentPlan = plan(); + 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.receipt_observation_id), /^sha256:[0-9a-f]{64}$/); + assert.deepEqual(result.truth_contract, { + provider_receipt_observed: true, + provider_execution_attested: false, + evidence_produced_reported: 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(); + assert.throws( + () => recordExternalEvidenceReceiptObservation({ + plan: currentPlan, + 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( + () => recordExternalEvidenceReceiptObservation({ + 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( + () => recordExternalEvidenceReceiptObservation({ + plan: mutatedPlan, + receipt: receipt(currentPlan), + }), + undefined, + label, + ); + } +}); + +test("admits exact source refs and exposes only compact provenance", () => { + const currentPlan = plan(); + const result = evaluateExternalEvidenceAdmission({ + plan: currentPlan, + receipt: receipt(currentPlan), + decision: { + disposition: "admit", + reason: "The source directly answers the interaction question.", + admitted_source_refs: ["https://example.com/original"], + }, + }); + 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; + 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(); + assert.throws( + () => evaluateExternalEvidenceAdmission({ + plan: currentPlan, + receipt: { + ...receipt(currentPlan), + 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(currentPlan); + 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 admission = evaluateExternalEvidenceAdmission({ + plan: currentPlan, + receipt: receipt(currentPlan), + 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: [] }).plan_id, + currentPlan.plan_id, + ); + assert.equal( + projectExternalEvidenceRetirement({ + admission, + downstream_source_refs: ["https://example.com/original"], + }).status, + "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/, + ); +});