소스 검색

高维治理:统一任务契约与检索恢复

SamLee 16 시간 전
부모
커밋
d74efad081
43개의 변경된 파일2013개의 추가작업 그리고 403개의 파일을 삭제
  1. 5 1
      agent/agent/orchestration/coordinator.py
  2. 1 0
      script_build_host/migrations/CHECKSUMS.sha256
  3. 64 0
      script_build_host/migrations/versions/0004_candidate_source_identity.py
  4. 50 17
      script_build_host/src/script_build_host/adapters/legacy_retrieval.py
  5. 146 10
      script_build_host/src/script_build_host/adapters/retrieval.py
  6. 81 2
      script_build_host/src/script_build_host/agents/model_manifest.py
  7. 35 37
      script_build_host/src/script_build_host/agents/presets.py
  8. 8 2
      script_build_host/src/script_build_host/agents/prompts/script_planner.md
  9. 6 8
      script_build_host/src/script_build_host/agents/validation.py
  10. 7 63
      script_build_host/src/script_build_host/api/routes.py
  11. 26 2
      script_build_host/src/script_build_host/application/input_snapshot_service.py
  12. 20 23
      script_build_host/src/script_build_host/application/mission_factory.py
  13. 189 16
      script_build_host/src/script_build_host/application/mission_workbench.py
  14. 47 12
      script_build_host/src/script_build_host/application/phase_two_candidates.py
  15. 27 104
      script_build_host/src/script_build_host/application/phase_two_planning.py
  16. 24 0
      script_build_host/src/script_build_host/domain/errors.py
  17. 24 0
      script_build_host/src/script_build_host/domain/model_contracts.py
  18. 66 0
      script_build_host/src/script_build_host/domain/phase_protocol.py
  19. 6 0
      script_build_host/src/script_build_host/domain/phase_two_ports.py
  20. 333 0
      script_build_host/src/script_build_host/domain/task_capabilities.py
  21. 13 3
      script_build_host/src/script_build_host/domain/task_contracts.py
  22. 3 1
      script_build_host/src/script_build_host/domain/task_policy.py
  23. 1 0
      script_build_host/src/script_build_host/domain/workbench.py
  24. 29 0
      script_build_host/src/script_build_host/infrastructure/tables.py
  25. 153 2
      script_build_host/src/script_build_host/repositories/workspace.py
  26. 7 0
      script_build_host/src/script_build_host/tools/failures.py
  27. 69 32
      script_build_host/src/script_build_host/tools/gateway.py
  28. 16 14
      script_build_host/src/script_build_host/tools/registry.py
  29. 10 0
      script_build_host/tests/test_context_broker_integration.py
  30. 5 4
      script_build_host/tests/test_input_snapshot_service.py
  31. 10 2
      script_build_host/tests/test_internal_runtime.py
  32. 3 2
      script_build_host/tests/test_migrations.py
  33. 4 1
      script_build_host/tests/test_mission_factory.py
  34. 61 2
      script_build_host/tests/test_mission_workbench.py
  35. 16 7
      script_build_host/tests/test_phase_one_e2e.py
  36. 34 3
      script_build_host/tests/test_phase_two_agent_tools.py
  37. 172 5
      script_build_host/tests/test_phase_two_candidates.py
  38. 28 12
      script_build_host/tests/test_phase_two_contracts.py
  39. 16 7
      script_build_host/tests/test_phase_two_real_runner_e2e.py
  40. 16 7
      script_build_host/tests/test_phase_two_sql_flow.py
  41. 103 1
      script_build_host/tests/test_retrieval_adapters.py
  42. 42 0
      script_build_host/tests/test_task_capabilities.py
  43. 37 1
      script_build_host/tests/test_task_policy.py

+ 5 - 1
agent/agent/orchestration/coordinator.py

@@ -376,7 +376,7 @@ class TaskCoordinator:
             if item.failure is not None
             if item.failure is not None
         ]
         ]
         last_failure = failures[-1].fingerprint() if failures else None
         last_failure = failures[-1].fingerprint() if failures else None
-        return {
+        snapshot = {
             "root": {
             "root": {
                 "spec": spec_value(root),
                 "spec": spec_value(root),
                 "status": root.status.value,
                 "status": root.status.value,
@@ -394,6 +394,10 @@ class TaskCoordinator:
             ),
             ),
             "last_failure": last_failure,
             "last_failure": last_failure,
         }
         }
+        observer = getattr(self.task_context_provider, "progress_snapshot", None)
+        if callable(observer):
+            snapshot["observation_progress"] = await observer(root_trace_id)
+        return snapshot
 
 
     async def _mutate(
     async def _mutate(
         self,
         self,

+ 1 - 0
script_build_host/migrations/CHECKSUMS.sha256

@@ -1,3 +1,4 @@
 0d524c7647d567ec3fba3c6d747ec09c2c96c9805506857adf14996a2539bff7  migrations/versions/0001_phase_one_business_tables.py
 0d524c7647d567ec3fba3c6d747ec09c2c96c9805506857adf14996a2539bff7  migrations/versions/0001_phase_one_business_tables.py
 c570caabd328ca4ff0c7aaa6212f44af3bae916bcdb5f2b028e40a1f16da2ff7  migrations/versions/0002_phase_two_artifact_types.py
 c570caabd328ca4ff0c7aaa6212f44af3bae916bcdb5f2b028e40a1f16da2ff7  migrations/versions/0002_phase_two_artifact_types.py
 1e4f94fd826ca8dfe8b0b0c1dd33470f052d371221d9d47c7d4f5638cc741f80  migrations/versions/0003_phase_three_publication_fencing.py
 1e4f94fd826ca8dfe8b0b0c1dd33470f052d371221d9d47c7d4f5638cc741f80  migrations/versions/0003_phase_three_publication_fencing.py
+ee3b6c2af035616b17fe8e0c2430f50be0a6de31fed23b076837120d07ddaa0e  migrations/versions/0004_candidate_source_identity.py

+ 64 - 0
script_build_host/migrations/versions/0004_candidate_source_identity.py

@@ -0,0 +1,64 @@
+"""Persist source-qualified logical identities for candidate workspace seeds.
+
+Revision ID: 0004_candidate_source_identity
+Revises: 0003_phase_three_publication
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import mysql
+
+revision: str = "0004_candidate_source_identity"
+down_revision: str | None = "0003_phase_three_publication"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    timestamp = sa.DateTime(timezone=True).with_variant(mysql.DATETIME(fsp=6), "mysql")
+    op.create_table(
+        "script_build_candidate_source_identity",
+        sa.Column("id", sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
+        sa.Column("workspace_artifact_version_id", sa.BigInteger(), nullable=False),
+        sa.Column("entity_kind", sa.String(32), nullable=False),
+        sa.Column("target_local_id", sa.BigInteger(), nullable=False),
+        sa.Column("source_artifact_version_id", sa.BigInteger(), nullable=False),
+        sa.Column("source_artifact_digest", sa.String(64), nullable=False),
+        sa.Column("source_local_id", sa.BigInteger(), nullable=False),
+        sa.Column("created_at", timestamp, nullable=False),
+        sa.UniqueConstraint(
+            "workspace_artifact_version_id",
+            "entity_kind",
+            "target_local_id",
+            name="uq_candidate_source_target",
+        ),
+        sa.UniqueConstraint(
+            "workspace_artifact_version_id",
+            "source_artifact_version_id",
+            "entity_kind",
+            "source_local_id",
+            name="uq_candidate_source_origin",
+        ),
+        sa.CheckConstraint(
+            "entity_kind IN ('paragraph', 'element')", name="ck_candidate_source_kind"
+        ),
+        sa.CheckConstraint(
+            "length(source_artifact_digest) = 64", name="ck_candidate_source_digest"
+        ),
+    )
+    op.create_index(
+        "ix_candidate_source_workspace",
+        "script_build_candidate_source_identity",
+        ["workspace_artifact_version_id"],
+    )
+
+
+def downgrade() -> None:
+    op.drop_index(
+        "ix_candidate_source_workspace", table_name="script_build_candidate_source_identity"
+    )
+    op.drop_table("script_build_candidate_source_identity")

+ 50 - 17
script_build_host/src/script_build_host/adapters/legacy_retrieval.py

@@ -357,6 +357,7 @@ class LegacyExternalRetrievalAdapter:
         http: SafeHttpClient,
         http: SafeHttpClient,
         openrouter: LegacyOpenRouterClient | None = None,
         openrouter: LegacyOpenRouterClient | None = None,
         image_model: str = "google/gemini-3-flash-preview",
         image_model: str = "google/gemini-3-flash-preview",
+        detail_concurrency: int = 5,
     ) -> None:
     ) -> None:
         self.xhs_search_endpoint = xhs_search_endpoint
         self.xhs_search_endpoint = xhs_search_endpoint
         self.xhs_detail_endpoint = xhs_detail_endpoint
         self.xhs_detail_endpoint = xhs_detail_endpoint
@@ -364,17 +365,18 @@ class LegacyExternalRetrievalAdapter:
         self.http = http
         self.http = http
         self.openrouter = openrouter
         self.openrouter = openrouter
         self.image_model = image_model
         self.image_model = image_model
+        self._detail_semaphore = asyncio.Semaphore(max(1, detail_concurrency))
 
 
     async def retrieve(self, *, query: Mapping[str, Any], snapshot: Any) -> RetrievalResult:
     async def retrieve(self, *, query: Mapping[str, Any], snapshot: Any) -> RetrievalResult:
         del snapshot
         del snapshot
         keyword = str(query.get("keyword") or "")
         keyword = str(query.get("keyword") or "")
         max_count = int(query.get("max_count", 5))
         max_count = int(query.get("max_count", 5))
         platform = query.get("platform_channel", "xhs")
         platform = query.get("platform_channel", "xhs")
-        rows = (
-            await self._search_xhs(keyword, max_count)
-            if platform == "xhs"
-            else await self._search_zhihu(keyword, max_count)
-        )
+        if platform == "xhs":
+            rows, detail_failures = await self._search_xhs(keyword, max_count)
+        else:
+            rows = await self._search_zhihu(keyword, max_count)
+            detail_failures = 0
         await asyncio.gather(*(self._add_image_understanding(row) for row in rows))
         await asyncio.gather(*(self._add_image_understanding(row) for row in rows))
         safe_rows = redact(rows)
         safe_rows = redact(rows)
         if not isinstance(safe_rows, list):
         if not isinstance(safe_rows, list):
@@ -388,13 +390,32 @@ class LegacyExternalRetrievalAdapter:
             summary=json.dumps(safe_rows, ensure_ascii=False, sort_keys=True),
             summary=json.dumps(safe_rows, ensure_ascii=False, sort_keys=True),
             supports=refs,
             supports=refs,
             confidence="upstream",
             confidence="upstream",
-            limitations=("image understanding unavailable",) if self.openrouter is None else (),
-            metadata={"response_sha256": canonical_sha256(rows).wire},
+            limitations=tuple(
+                item
+                for item in (
+                    "image understanding unavailable" if self.openrouter is None else None,
+                    (
+                        f"{detail_failures} upstream detail request(s) failed; "
+                        "search cards retained"
+                        if detail_failures
+                        else None
+                    ),
+                )
+                if item is not None
+            ),
+            metadata={
+                "response_sha256": canonical_sha256(rows).wire,
+                "detail_failures": detail_failures,
+                "partial_result": detail_failures > 0,
+            },
         )
         )
 
 
-    async def _search_xhs(self, keyword: str, max_count: int) -> list[dict[str, Any]]:
+    async def _search_xhs(
+        self, keyword: str, max_count: int
+    ) -> tuple[list[dict[str, Any]], int]:
         cursor: str | int = ""
         cursor: str | int = ""
         output: list[dict[str, Any]] = []
         output: list[dict[str, Any]] = []
+        detail_failures = 0
         for _ in range(2):
         for _ in range(2):
             body = await self.http.json(
             body = await self.http.json(
                 "POST",
                 "POST",
@@ -406,41 +427,53 @@ class LegacyExternalRetrievalAdapter:
                     "publish_time": "",
                     "publish_time": "",
                     "cursor": cursor,
                     "cursor": cursor,
                 },
                 },
+                retryable=True,
+                provider="xhs",
+                operation="search",
             )
             )
             if not isinstance(body, dict) or body.get("code") not in (0, "0"):
             if not isinstance(body, dict) or body.get("code") not in (0, "0"):
                 raise ProtocolViolation("XHS search returned a non-zero code")
                 raise ProtocolViolation("XHS search returned a non-zero code")
             block = body.get("data") or {}
             block = body.get("data") or {}
             raw_items = block.get("data") if isinstance(block, dict) else []
             raw_items = block.get("data") if isinstance(block, dict) else []
             items = [item for item in raw_items or [] if isinstance(item, dict)]
             items = [item for item in raw_items or [] if isinstance(item, dict)]
+            remaining = max_count - len(output)
+            items = items[:remaining]
             ids = [str(item["id"]).strip() for item in items if str(item.get("id") or "").strip()]
             ids = [str(item["id"]).strip() for item in items if str(item.get("id") or "").strip()]
             detail_values = await asyncio.gather(
             detail_values = await asyncio.gather(
-                *(self._xhs_detail(identifier) for identifier in ids)
+                *(self._xhs_detail(identifier) for identifier in ids),
+                return_exceptions=True,
             )
             )
             detail_map = {
             detail_map = {
                 identifier: value
                 identifier: value
                 for identifier, value in zip(ids, detail_values, strict=True)
                 for identifier, value in zip(ids, detail_values, strict=True)
-                if value is not None
+                if isinstance(value, dict)
             }
             }
+            detail_failures += sum(
+                isinstance(value, BaseException) or value is None for value in detail_values
+            )
             for item in items:
             for item in items:
                 identifier = str(item.get("id") or "").strip()
                 identifier = str(item.get("id") or "").strip()
                 if identifier and identifier in detail_map:
                 if identifier and identifier in detail_map:
                     output.append(detail_map[identifier])
                     output.append(detail_map[identifier])
-                elif not identifier:
+                else:
                     output.append(_xhs_card(item))
                     output.append(_xhs_card(item))
                 if len(output) >= max_count:
                 if len(output) >= max_count:
-                    return output[:max_count]
+                    return output[:max_count], detail_failures
             if not block.get("has_more") or block.get("next_cursor") in (None, ""):
             if not block.get("has_more") or block.get("next_cursor") in (None, ""):
                 break
                 break
             cursor = block["next_cursor"]
             cursor = block["next_cursor"]
-        return output[:max_count]
+        return output[:max_count], detail_failures
 
 
     async def _xhs_detail(self, identifier: str) -> dict[str, Any] | None:
     async def _xhs_detail(self, identifier: str) -> dict[str, Any] | None:
-        try:
+        async with self._detail_semaphore:
             body = await self.http.json(
             body = await self.http.json(
-                "POST", self.xhs_detail_endpoint, json_body={"content_id": identifier}
+                "POST",
+                self.xhs_detail_endpoint,
+                json_body={"content_id": identifier},
+                retryable=True,
+                provider="xhs",
+                operation="detail",
             )
             )
-        except ProtocolViolation:
-            return None
         inner = (
         inner = (
             body.get("data") if isinstance(body, dict) and body.get("code") in (0, "0") else None
             body.get("data") if isinstance(body, dict) and body.get("code") in (0, "0") else None
         )
         )

+ 146 - 10
script_build_host/src/script_build_host/adapters/retrieval.py

@@ -15,7 +15,7 @@ from urllib.parse import urljoin
 
 
 import httpx
 import httpx
 
 
-from script_build_host.domain.errors import ProtocolViolation
+from script_build_host.domain.errors import ProtocolViolation, UpstreamFailure
 from script_build_host.infrastructure.canonical_json import canonical_json_bytes, canonical_sha256
 from script_build_host.infrastructure.canonical_json import canonical_json_bytes, canonical_sha256
 from script_build_host.infrastructure.outbound import OutboundPolicy
 from script_build_host.infrastructure.outbound import OutboundPolicy
 from script_build_host.infrastructure.redaction import redact, redact_text
 from script_build_host.infrastructure.redaction import redact, redact_text
@@ -33,12 +33,16 @@ class SafeHttpClient:
         timeout_seconds: float = 30.0,
         timeout_seconds: float = 30.0,
         max_response_bytes: int = 5_000_000,
         max_response_bytes: int = 5_000_000,
         max_redirects: int = 3,
         max_redirects: int = 3,
+        max_attempts: int = 3,
+        retry_backoff_seconds: float = 0.05,
     ) -> None:
     ) -> None:
         self.client = client
         self.client = client
         self.policy = policy
         self.policy = policy
         self.timeout_seconds = timeout_seconds
         self.timeout_seconds = timeout_seconds
         self.max_response_bytes = max_response_bytes
         self.max_response_bytes = max_response_bytes
         self.max_redirects = max_redirects
         self.max_redirects = max_redirects
+        self.max_attempts = max_attempts
+        self.retry_backoff_seconds = retry_backoff_seconds
 
 
     async def request(
     async def request(
         self,
         self,
@@ -47,6 +51,42 @@ class SafeHttpClient:
         *,
         *,
         json_body: Mapping[str, Any] | None = None,
         json_body: Mapping[str, Any] | None = None,
         headers: Mapping[str, str] | None = None,
         headers: Mapping[str, str] | None = None,
+        retryable: bool = False,
+        provider: str = "http",
+        operation: str = "request",
+    ) -> httpx.Response:
+        attempts = self.max_attempts if retryable else 1
+        for attempt in range(attempts):
+            try:
+                return await self._request_attempt(
+                    method,
+                    url,
+                    json_body=json_body,
+                    headers=headers,
+                    provider=provider,
+                    operation=operation,
+                )
+            except UpstreamFailure as exc:
+                if not exc.retryable or attempt + 1 >= attempts:
+                    raise
+                delay = (
+                    exc.retry_after_seconds
+                    if exc.retry_after_seconds is not None
+                    else self.retry_backoff_seconds * (2**attempt)
+                )
+                if delay > 0:
+                    await asyncio.sleep(min(delay, 2.0))
+        raise AssertionError("unreachable retry loop")
+
+    async def _request_attempt(
+        self,
+        method: str,
+        url: str,
+        *,
+        json_body: Mapping[str, Any] | None,
+        headers: Mapping[str, str] | None,
+        provider: str,
+        operation: str,
     ) -> httpx.Response:
     ) -> httpx.Response:
         current = await self.policy.validate_url(url)
         current = await self.policy.validate_url(url)
         request_headers = {"Accept-Encoding": "identity", **dict(headers or {})}
         request_headers = {"Accept-Encoding": "identity", **dict(headers or {})}
@@ -58,7 +98,24 @@ class SafeHttpClient:
                 headers=request_headers,
                 headers=request_headers,
                 timeout=self.timeout_seconds,
                 timeout=self.timeout_seconds,
             )
             )
-            response = await self.client.send(request, stream=True, follow_redirects=False)
+            try:
+                response = await self.client.send(request, stream=True, follow_redirects=False)
+            except httpx.TimeoutException as exc:
+                raise UpstreamFailure(
+                    "TRANSIENT_TIMEOUT",
+                    "upstream request timed out",
+                    provider=provider,
+                    operation=operation,
+                    retryable=True,
+                ) from exc
+            except httpx.TransportError as exc:
+                raise UpstreamFailure(
+                    "TRANSIENT_CONNECTION_LOST",
+                    "upstream connection was interrupted",
+                    provider=provider,
+                    operation=operation,
+                    retryable=True,
+                ) from exc
             if 300 <= response.status_code < 400:
             if 300 <= response.status_code < 400:
                 location = response.headers.get("location")
                 location = response.headers.get("location")
                 await response.aclose()
                 await response.aclose()
@@ -69,14 +126,67 @@ class SafeHttpClient:
                 json_body = None if method == "GET" else json_body
                 json_body = None if method == "GET" else json_body
                 continue
                 continue
             content = bytearray()
             content = bytearray()
-            async for chunk in response.aiter_bytes():
-                content.extend(chunk)
-                if len(content) > self.max_response_bytes:
-                    await response.aclose()
-                    raise ProtocolViolation("upstream response exceeds the configured byte limit")
+            try:
+                async for chunk in response.aiter_bytes():
+                    content.extend(chunk)
+                    if len(content) > self.max_response_bytes:
+                        await response.aclose()
+                        raise ProtocolViolation(
+                            "upstream response exceeds the configured byte limit"
+                        )
+            except httpx.TimeoutException as exc:
+                await response.aclose()
+                raise UpstreamFailure(
+                    "TRANSIENT_TIMEOUT",
+                    "upstream response timed out",
+                    provider=provider,
+                    operation=operation,
+                    retryable=True,
+                ) from exc
+            except httpx.TransportError as exc:
+                await response.aclose()
+                raise UpstreamFailure(
+                    "TRANSIENT_CONNECTION_LOST",
+                    "upstream response was interrupted",
+                    provider=provider,
+                    operation=operation,
+                    retryable=True,
+                ) from exc
             await response.aclose()
             await response.aclose()
             if response.is_error:
             if response.is_error:
-                raise ProtocolViolation(f"upstream returned HTTP {response.status_code}")
+                retry_after = _retry_after_seconds(response.headers.get("retry-after"))
+                if response.status_code in {401, 403}:
+                    raise UpstreamFailure(
+                        "UPSTREAM_AUTH_REJECTED",
+                        f"upstream rejected authentication with HTTP {response.status_code}",
+                        provider=provider,
+                        operation=operation,
+                        retryable=False,
+                    )
+                if response.status_code == 429:
+                    raise UpstreamFailure(
+                        "RATE_LIMITED",
+                        "upstream rate limit was reached",
+                        provider=provider,
+                        operation=operation,
+                        retryable=True,
+                        retry_after_seconds=retry_after,
+                    )
+                if response.status_code >= 500:
+                    raise UpstreamFailure(
+                        "UPSTREAM_UNAVAILABLE",
+                        f"upstream returned HTTP {response.status_code}",
+                        provider=provider,
+                        operation=operation,
+                        retryable=True,
+                    )
+                raise UpstreamFailure(
+                    "UPSTREAM_CONTRACT_MISMATCH",
+                    f"upstream returned HTTP {response.status_code}",
+                    provider=provider,
+                    operation=operation,
+                    retryable=False,
+                )
             return httpx.Response(
             return httpx.Response(
                 status_code=response.status_code,
                 status_code=response.status_code,
                 headers=response.headers,
                 headers=response.headers,
@@ -92,12 +202,38 @@ class SafeHttpClient:
         *,
         *,
         json_body: Mapping[str, Any] | None = None,
         json_body: Mapping[str, Any] | None = None,
         headers: Mapping[str, str] | None = None,
         headers: Mapping[str, str] | None = None,
+        retryable: bool = False,
+        provider: str = "http",
+        operation: str = "json",
     ) -> Any:
     ) -> Any:
-        response = await self.request(method, url, json_body=json_body, headers=headers)
+        response = await self.request(
+            method,
+            url,
+            json_body=json_body,
+            headers=headers,
+            retryable=retryable,
+            provider=provider,
+            operation=operation,
+        )
         try:
         try:
             return response.json()
             return response.json()
         except ValueError as exc:
         except ValueError as exc:
-            raise ProtocolViolation("upstream response is not valid JSON") from exc
+            raise UpstreamFailure(
+                "UPSTREAM_CONTRACT_MISMATCH",
+                "upstream response is not valid JSON",
+                provider=provider,
+                operation=operation,
+                retryable=False,
+            ) from exc
+
+
+def _retry_after_seconds(value: str | None) -> float | None:
+    if value is None:
+        return None
+    try:
+        return max(0.0, min(float(value), 60.0))
+    except ValueError:
+        return None
 
 
 
 
 class PatternRetrievalAdapter:
 class PatternRetrievalAdapter:

+ 81 - 2
script_build_host/src/script_build_host/agents/model_manifest.py

@@ -9,6 +9,7 @@ from script_build_host.agents.prompts import (
     phase_three_prompt_manifest,
     phase_three_prompt_manifest,
     script_build_prompt_manifest,
     script_build_prompt_manifest,
 )
 )
+from script_build_host.domain.model_contracts import validate_model_prompt_closure
 
 
 _VALIDATOR_CAPS = {
 _VALIDATOR_CAPS = {
     "script_retrieval_validator": 20,
     "script_retrieval_validator": 20,
@@ -16,6 +17,18 @@ _VALIDATOR_CAPS = {
     "script_root_validator": 25,
     "script_root_validator": 25,
 }
 }
 
 
+_LEGACY_MODEL_KEYS = {
+    "script_pattern_retrieval_worker": "model_name_means_pattern_relation",
+    "script_decode_retrieval_worker": "model_name_means_case",
+    "script_external_retrieval_worker": "model_name_means_data",
+    "script_knowledge_retrieval_worker": "model_name_means_knowledge",
+    "script_retrieval_validator": "model_name_script_evaluator",
+    "script_candidate_validator": "model_name_script_evaluator",
+    "script_candidate_compare_worker": "model_name_script_evaluator",
+    "script_candidate_portfolio_worker": "model_name_script_evaluator",
+    "script_root_validator": "model_name_script_evaluator",
+}
+
 
 
 def resolve_script_role_model_manifest(
 def resolve_script_role_model_manifest(
     configured: Mapping[str, object],
     configured: Mapping[str, object],
@@ -23,7 +36,7 @@ def resolve_script_role_model_manifest(
     model: str | None,
     model: str | None,
     max_iterations: int,
     max_iterations: int,
 ) -> dict[str, object]:
 ) -> dict[str, object]:
-    """Merge explicit presets over complete defaults for all sixteen roles."""
+    """Merge explicit presets over complete defaults for every frozen role."""
 
 
     output = dict(configured)
     output = dict(configured)
     raw_presets = output.get("presets")
     raw_presets = output.get("presets")
@@ -60,4 +73,70 @@ def resolve_script_role_model_manifest(
     return output
     return output
 
 
 
 
-__all__ = ["resolve_script_role_model_manifest"]
+def resolve_request_role_model_manifest(
+    raw_config: Mapping[str, object],
+    defaults: Mapping[str, object],
+) -> dict[str, object]:
+    """Resolve API legacy model keys through the same complete role catalog."""
+
+    default_presets = defaults.get("presets", defaults)
+    if not isinstance(default_presets, Mapping):
+        default_presets = {}
+    planner_default = default_presets.get("script_planner", {})
+    if not isinstance(planner_default, Mapping):
+        planner_default = {}
+    model = str(raw_config.get("model_name", planner_default.get("model", "gpt-4o")))
+    max_iterations = _as_int(raw_config.get("max_iterations", 120), "max_iterations")
+    temperature = _as_float(raw_config.get("temperature", 0.3), "temperature")
+    explicit: dict[str, object] = {}
+    for item in (*script_build_prompt_manifest(), *phase_three_prompt_manifest()):
+        preset = str(item["preset"])
+        role = str(item["role"])
+        preset_default = default_presets.get(preset, {})
+        if not isinstance(preset_default, Mapping):
+            preset_default = {}
+        model_key = _LEGACY_MODEL_KEYS.get(preset, "model_name")
+        explicit[preset] = {
+            "model": str(raw_config.get(model_key, preset_default.get("model", model))),
+            "temperature": _as_float(
+                raw_config.get(
+                    "temperature",
+                    preset_default.get("temperature", 0.0 if role == "validator" else temperature),
+                ),
+                "temperature",
+            ),
+            "max_iterations": _as_int(
+                raw_config.get(
+                    "max_iterations", preset_default.get("max_iterations", max_iterations)
+                ),
+                "max_iterations",
+            ),
+        }
+    return resolve_script_role_model_manifest(
+        {"presets": explicit}, model=model, max_iterations=max_iterations
+    )
+
+
+def _as_int(value: object, label: str) -> int:
+    if isinstance(value, bool) or not isinstance(value, (int, float, str)):
+        raise ValueError(f"{label} must be numeric")
+    try:
+        return int(value)
+    except ValueError as exc:
+        raise ValueError(f"{label} must be numeric") from exc
+
+
+def _as_float(value: object, label: str) -> float:
+    if isinstance(value, bool) or not isinstance(value, (int, float, str)):
+        raise ValueError(f"{label} must be numeric")
+    try:
+        return float(value)
+    except ValueError as exc:
+        raise ValueError(f"{label} must be numeric") from exc
+
+
+__all__ = [
+    "resolve_request_role_model_manifest",
+    "resolve_script_role_model_manifest",
+    "validate_model_prompt_closure",
+]

+ 35 - 37
script_build_host/src/script_build_host/agents/presets.py

@@ -5,6 +5,9 @@ from __future__ import annotations
 from agent import AgentPreset, AgentRole
 from agent import AgentPreset, AgentRole
 from agent.core.presets import register_preset
 from agent.core.presets import register_preset
 
 
+from script_build_host.domain.task_capabilities import task_capability
+from script_build_host.domain.task_contracts import ScriptTaskKind
+
 from .prompts import (
 from .prompts import (
     COMPARISON_WORKER_PROMPT,
     COMPARISON_WORKER_PROMPT,
     COMPOSE_WORKER_PROMPT,
     COMPOSE_WORKER_PROMPT,
@@ -63,6 +66,14 @@ def _worker(tools: list[str], *, prompt: str) -> AgentPreset:
     )
     )
 
 
 
 
+def _task_worker(kind: ScriptTaskKind, *, prompt: str) -> AgentPreset:
+    capability = task_capability(kind)
+    return _worker(
+        sorted(capability.allowed_tools - {"submit_attempt"}),
+        prompt=prompt,
+    )
+
+
 def register_script_presets() -> None:
 def register_script_presets() -> None:
     """Register only the new Agentic script-build roles.
     """Register only the new Agentic script-build roles.
 
 
@@ -91,98 +102,85 @@ def register_script_presets() -> None:
     )
     )
     register_preset(
     register_preset(
         "script_pattern_retrieval_worker",
         "script_pattern_retrieval_worker",
-        _worker(
-            ["search_mission_context", "read_mission_context", "query_pattern_qa"],
+        _task_worker(
+            ScriptTaskKind.PATTERN_RETRIEVAL,
             prompt=PATTERN_RETRIEVAL_WORKER_PROMPT,
             prompt=PATTERN_RETRIEVAL_WORKER_PROMPT,
         ),
         ),
     )
     )
     register_preset(
     register_preset(
         "script_decode_retrieval_worker",
         "script_decode_retrieval_worker",
-        _worker(
-            ["search_mission_context", "read_mission_context", "search_script_decode_case"],
+        _task_worker(
+            ScriptTaskKind.DECODE_RETRIEVAL,
             prompt=DECODE_RETRIEVAL_WORKER_PROMPT,
             prompt=DECODE_RETRIEVAL_WORKER_PROMPT,
         ),
         ),
     )
     )
     register_preset(
     register_preset(
         "script_external_retrieval_worker",
         "script_external_retrieval_worker",
-        _worker(
-            ["search_mission_context", "read_mission_context", "external_search_case"],
+        _task_worker(
+            ScriptTaskKind.EXTERNAL_RETRIEVAL,
             prompt=EXTERNAL_RETRIEVAL_WORKER_PROMPT,
             prompt=EXTERNAL_RETRIEVAL_WORKER_PROMPT,
         ),
         ),
     )
     )
     register_preset(
     register_preset(
         "script_knowledge_retrieval_worker",
         "script_knowledge_retrieval_worker",
-        _worker(
-            ["search_mission_context", "read_mission_context", "search_knowledge"],
+        _task_worker(
+            ScriptTaskKind.KNOWLEDGE_RETRIEVAL,
             prompt=KNOWLEDGE_RETRIEVAL_WORKER_PROMPT,
             prompt=KNOWLEDGE_RETRIEVAL_WORKER_PROMPT,
         ),
         ),
     )
     )
     register_preset(
     register_preset(
         "script_direction_worker",
         "script_direction_worker",
-        _worker(
-            ["search_mission_context", "read_mission_context", "save_direction_candidate"],
+        _task_worker(
+            ScriptTaskKind.DIRECTION,
             prompt=DIRECTION_WORKER_PROMPT,
             prompt=DIRECTION_WORKER_PROMPT,
         ),
         ),
     )
     )
-    structure_writes = ["save_script_paragraphs"]
-    paragraph_writes = ["save_script_paragraphs"]
-    element_writes = ["save_script_elements"]
-    common_candidate_reads = [
-        "search_mission_context",
-        "read_mission_context",
-        "view_frozen_images",
-    ]
     register_preset(
     register_preset(
         "script_structure_worker",
         "script_structure_worker",
-        _worker(
-            [*common_candidate_reads, *structure_writes],
+        _task_worker(
+            ScriptTaskKind.STRUCTURE,
             prompt=STRUCTURE_WORKER_PROMPT,
             prompt=STRUCTURE_WORKER_PROMPT,
         ),
         ),
     )
     )
     register_preset(
     register_preset(
         "script_paragraph_worker",
         "script_paragraph_worker",
-        _worker(
-            [*common_candidate_reads, *paragraph_writes],
+        _task_worker(
+            ScriptTaskKind.PARAGRAPH,
             prompt=PARAGRAPH_WORKER_PROMPT,
             prompt=PARAGRAPH_WORKER_PROMPT,
         ),
         ),
     )
     )
     register_preset(
     register_preset(
         "script_element_set_worker",
         "script_element_set_worker",
-        _worker(
-            [*common_candidate_reads, *element_writes],
+        _task_worker(
+            ScriptTaskKind.ELEMENT_SET,
             prompt=ELEMENT_SET_WORKER_PROMPT,
             prompt=ELEMENT_SET_WORKER_PROMPT,
         ),
         ),
     )
     )
     register_preset(
     register_preset(
         "script_candidate_compare_worker",
         "script_candidate_compare_worker",
-        _worker(
-            ["search_mission_context", "read_mission_context", "save_comparison_candidate"],
+        _task_worker(
+            ScriptTaskKind.COMPARE,
             prompt=COMPARISON_WORKER_PROMPT,
             prompt=COMPARISON_WORKER_PROMPT,
         ),
         ),
     )
     )
     register_preset(
     register_preset(
         "script_compose_worker",
         "script_compose_worker",
-        _worker(
-            [],
+        _task_worker(
+            ScriptTaskKind.COMPOSE,
             prompt=COMPOSE_WORKER_PROMPT,
             prompt=COMPOSE_WORKER_PROMPT,
         ),
         ),
     )
     )
     register_preset(
     register_preset(
         "script_candidate_portfolio_worker",
         "script_candidate_portfolio_worker",
-        _worker(
-            [],
+        _task_worker(
+            ScriptTaskKind.CANDIDATE_PORTFOLIO,
             prompt=PORTFOLIO_WORKER_PROMPT,
             prompt=PORTFOLIO_WORKER_PROMPT,
         ),
         ),
     )
     )
     register_preset(
     register_preset(
         "script_root_worker",
         "script_root_worker",
-        _worker(
-            [
-                "search_mission_context",
-                "read_mission_context",
-                "legacy_projection_dry_run",
-                "save_root_delivery_manifest",
-            ],
+        _task_worker(
+            ScriptTaskKind.ROOT_DELIVERY,
             prompt=ROOT_WORKER_PROMPT,
             prompt=ROOT_WORKER_PROMPT,
         ),
         ),
     )
     )

+ 8 - 2
script_build_host/src/script_build_host/agents/prompts/script_planner.md

@@ -9,8 +9,11 @@
   `dispatch_script_tasks`;每次决策前读取对应 Validation。
   `dispatch_script_tasks`;每次决策前读取对应 Validation。
 - PlannerTaskInput 只提交 `task_kind`、语义 `scope_selector`、`intent_class`、`objective`、
 - PlannerTaskInput 只提交 `task_kind`、语义 `scope_selector`、`intent_class`、`objective`、
   `goal_ids`、`criteria` 和 `input_decision_ids`,真实需要时才提交 `base_decision_id`、
   `goal_ids`、`criteria` 和 `input_decision_ids`,真实需要时才提交 `base_decision_id`、
-  `comparison_decision_ids`、`supersedes_decision_ids` 或 `gap_key`。`criteria` 只用
-  `client_key`、`description`、`hard` 补充业务标准,通用硬标准由 Host 注入。
+  `comparison_decision_ids`、`supersedes_decision_ids` 或 `gap_key`。`criteria` 使用
+  `client_key`、`criterion_type`、`params`、`hard`;只有非 Retrieval 的 `custom` 标准才写
+  `description`。Retrieval 只允许 `evidence_nonempty`、`source_attributable`、
+  `semantic_relevance`、`concept_coverage`、`freshness`,不能要求映射、人设分析、写作或
+  结构化合成文档。通用硬标准由 Host 注入。
 - 周期 Mission Context 中的 `active_subgraph` 是待推进前沿,`accepted_frontier`
 - 周期 Mission Context 中的 `active_subgraph` 是待推进前沿,`accepted_frontier`
   是可选 ACCEPT 索引。Decision ID 只能来自该索引。不要提交物理引用、
   是可选 ACCEPT 索引。Decision ID 只能来自该索引。不要提交物理引用、
   内容摘要值、producer/write scope、schema、budget、candidate closure 或 compose order;
   内容摘要值、producer/write scope、schema、budget、candidate closure 或 compose order;
@@ -21,6 +24,9 @@
 - Phase 1 使用空 `goal_ids`。Phase 2 只使用 active Direction Goal:子 Task 不得超出父 Task
 - Phase 1 使用空 `goal_ids`。Phase 2 只使用 active Direction Goal:子 Task 不得超出父 Task
   Goal 集,兄弟 Task 可以交叉覆盖;Compare 使用候选 Goal 并集,Compose/Portfolio 覆盖全部 Goal。
   Goal 集,兄弟 Task 可以交叉覆盖;Compare 使用候选 Goal 并集,Compose/Portfolio 覆盖全部 Goal。
 - 先创建父 Task 再创建子 Task。阶段层级、Root 所有权和 BLOCK reason 只服从受保护阶段策略。
 - 先创建父 Task 再创建子 Task。阶段层级、Root 所有权和 BLOCK reason 只服从受保护阶段策略。
+- `phase_protocol` 是当前阶段唯一的机器可读协议。只从其中的 `allowed_child_kinds`、
+  `valid_actions` 和 `selectable_decisions` 选择动作与 Decision,不从全局 accepted 列表猜测
+  当前 Compose/Portfolio 的采用闭包。
 - `scope_selector` 只写 `{anchor: mission|parent, path: [语义段...]}`;Host 负责拼接、
 - `scope_selector` 只写 `{anchor: mission|parent, path: [语义段...]}`;Host 负责拼接、
   包含关系和写范围,不得构造 URI。
   包含关系和写范围,不得构造 URI。
 - Phase 2 初始创作 Task 不要把 Phase 1 Retrieval Decision 放入 `input_decision_ids`;active
 - Phase 2 初始创作 Task 不要把 Phase 1 Retrieval Decision 放入 `input_decision_ids`;active

+ 6 - 8
script_build_host/src/script_build_host/agents/validation.py

@@ -30,6 +30,8 @@ from script_build_host.domain.ports import (
     MissionBindingRepository,
     MissionBindingRepository,
     ScriptBusinessArtifactRepository,
     ScriptBusinessArtifactRepository,
 )
 )
+from script_build_host.domain.task_capabilities import task_capability
+from script_build_host.domain.task_contracts import ScriptTaskKind
 
 
 TASK_KIND_PREFIX = "script-build://task-kinds/"
 TASK_KIND_PREFIX = "script-build://task-kinds/"
 RETRIEVAL_KINDS = frozenset(
 RETRIEVAL_KINDS = frozenset(
@@ -80,14 +82,10 @@ class ScriptBuildValidationPolicy:
 
 
     def plan(self, context: ValidationContext) -> ValidationPlan:
     def plan(self, context: ValidationContext) -> ValidationPlan:
         kind = task_kind(context.task.current_spec.context_refs)
         kind = task_kind(context.task.current_spec.context_refs)
-        if kind in RETRIEVAL_KINDS:
-            preset = "script_retrieval_validator"
-        elif kind == ROOT_DELIVERY_KIND:
-            preset = "script_root_validator"
-        elif kind == "direction" or kind in PHASE_TWO_KINDS:
-            preset = "script_candidate_validator"
-        else:
-            raise ValueError(f"Unsupported script-build task kind: {kind!r}")
+        try:
+            preset = task_capability(ScriptTaskKind(str(kind))).validator_preset
+        except ValueError as exc:
+            raise ValueError(f"Unsupported script-build task kind: {kind!r}") from exc
         preflight_rules = [
         preflight_rules = [
             "immutable-reference-present",
             "immutable-reference-present",
             "sha256-digest-shape",
             "sha256-digest-shape",

+ 7 - 63
script_build_host/src/script_build_host/api/routes.py

@@ -12,6 +12,7 @@ from uuid import uuid4
 from fastapi import APIRouter, Depends, Header, Query, Request, WebSocket, WebSocketDisconnect
 from fastapi import APIRouter, Depends, Header, Query, Request, WebSocket, WebSocketDisconnect
 from fastapi.responses import HTMLResponse
 from fastapi.responses import HTMLResponse
 
 
+from script_build_host.agents.model_manifest import resolve_request_role_model_manifest
 from script_build_host.agents.prompt_catalog import script_prompt_requests
 from script_build_host.agents.prompt_catalog import script_prompt_requests
 from script_build_host.application.mission_service import (
 from script_build_host.application.mission_service import (
     ScriptMissionService,
     ScriptMissionService,
@@ -771,9 +772,7 @@ def create_script_build_router(
         await _owned_trace(trace_store, binding.root_trace_id, trace_id)
         await _owned_trace(trace_store, binding.root_trace_id, trace_id)
         messages = await trace_store.get_trace_messages(trace_id)
         messages = await trace_store.get_trace_messages(trace_id)
         return {
         return {
-            "messages": [
-                item.to_dict() for item in messages if int(item.sequence) > after_sequence
-            ]
+            "messages": [item.to_dict() for item in messages if int(item.sequence) > after_sequence]
         }
         }
 
 
     @router.get("/api/pattern/script_builds/{script_build_id}/trace_messages")
     @router.get("/api/pattern/script_builds/{script_build_id}/trace_messages")
@@ -895,66 +894,11 @@ async def _start_command(
         raw_config.get("max_iterations"), bool
         raw_config.get("max_iterations"), bool
     ):
     ):
         raise problem(400, "INVALID_AGENT_CONFIG", "model run configuration is invalid")
         raise problem(400, "INVALID_AGENT_CONFIG", "model run configuration is invalid")
-    default_presets = (default_model_manifest or {}).get(
-        "presets",
-        default_model_manifest or {},
-    )
-    planner_default = (
-        default_presets.get("script_planner", {}) if isinstance(default_presets, Mapping) else {}
-    )
-    if not isinstance(planner_default, Mapping):
-        planner_default = {}
     try:
     try:
-        model = str(raw_config.get("model_name", planner_default.get("model", "gpt-4o")))
-        temperature = float(raw_config.get("temperature", 0.3))
-        max_iterations = int(raw_config.get("max_iterations", 120))
-        presets: dict[str, dict[str, object]] = {}
-        preset_keys = {
-            "script_planner": "model_name",
-            "script_direction_worker": "model_name",
-            "script_pattern_retrieval_worker": "model_name_means_pattern_relation",
-            "script_decode_retrieval_worker": "model_name_means_case",
-            "script_external_retrieval_worker": "model_name_means_data",
-            "script_knowledge_retrieval_worker": "model_name_means_knowledge",
-            "script_retrieval_validator": "model_name_script_evaluator",
-            "script_candidate_validator": "model_name_script_evaluator",
-            "script_structure_worker": "model_name",
-            "script_paragraph_worker": "model_name",
-            "script_element_set_worker": "model_name",
-            "script_candidate_compare_worker": "model_name_script_evaluator",
-            "script_compose_worker": "model_name",
-            "script_candidate_portfolio_worker": "model_name_script_evaluator",
-        }
-        for name, key in preset_keys.items():
-            preset_default = (
-                default_presets.get(name, {}) if isinstance(default_presets, Mapping) else {}
-            )
-            if not isinstance(preset_default, Mapping):
-                preset_default = {}
-            presets[name] = {
-                "model": str(raw_config.get(key, preset_default.get("model", model))),
-                "temperature": float(
-                    cast(
-                        Any,
-                        raw_config.get(
-                            "temperature",
-                            preset_default.get(
-                                "temperature",
-                                0.0 if "validator" in name else temperature,
-                            ),
-                        ),
-                    )
-                ),
-                "max_iterations": int(
-                    cast(
-                        Any,
-                        raw_config.get(
-                            "max_iterations",
-                            preset_default.get("max_iterations", max_iterations),
-                        ),
-                    )
-                ),
-            }
+        resolved_manifest = resolve_request_role_model_manifest(
+            raw_config, default_model_manifest or {}
+        )
+        presets = cast(dict[str, dict[str, object]], resolved_manifest["presets"])
     except (TypeError, ValueError, OverflowError) as exc:
     except (TypeError, ValueError, OverflowError) as exc:
         raise problem(400, "INVALID_AGENT_CONFIG", "model run configuration is invalid") from exc
         raise problem(400, "INVALID_AGENT_CONFIG", "model run configuration is invalid") from exc
     model_pattern = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$")
     model_pattern = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$")
@@ -977,7 +921,7 @@ async def _start_command(
         strategies_on_demand=tuple(body.strategies_on_demand or ()),
         strategies_on_demand=tuple(body.strategies_on_demand or ()),
         prompt_requests=script_prompt_requests(),
         prompt_requests=script_prompt_requests(),
         datasource_manifest=datasource_manifest,
         datasource_manifest=datasource_manifest,
-        model_manifest={"presets": presets},
+        model_manifest=resolved_manifest,
     )
     )
 
 
 
 

+ 26 - 2
script_build_host/src/script_build_host/application/input_snapshot_service.py

@@ -6,6 +6,7 @@ from typing import Any
 from script_build_host.domain.canonical_json import canonical_sha256
 from script_build_host.domain.canonical_json import canonical_sha256
 from script_build_host.domain.errors import InputRelationMismatch
 from script_build_host.domain.errors import InputRelationMismatch
 from script_build_host.domain.input_snapshot import ScriptBuildInput, ScriptBuildInputSnapshotV1
 from script_build_host.domain.input_snapshot import ScriptBuildInput, ScriptBuildInputSnapshotV1
+from script_build_host.domain.model_contracts import validate_model_prompt_closure
 from script_build_host.domain.ports import (
 from script_build_host.domain.ports import (
     InputSnapshotRepository,
     InputSnapshotRepository,
     LegacyInputReader,
     LegacyInputReader,
@@ -90,6 +91,13 @@ class ScriptInputSnapshotService:
         )
         )
         sanitized_strategies = _redacted_rows(strategies, "strategies")
         sanitized_strategies = _redacted_rows(strategies, "strategies")
         sanitized_prompts = _redacted_rows(prompts, "prompt manifest")
         sanitized_prompts = _redacted_rows(prompts, "prompt manifest")
+        sanitized_models = redact(request.model_manifest)
+        if not isinstance(sanitized_models, dict):
+            raise InputRelationMismatch()
+        try:
+            validate_model_prompt_closure(sanitized_prompts, sanitized_models)
+        except ValueError as exc:
+            raise InputRelationMismatch() from exc
         persona_digest = canonical_sha256(
         persona_digest = canonical_sha256(
             {
             {
                 "persona_points": sanitized_persona_points,
                 "persona_points": sanitized_persona_points,
@@ -115,7 +123,7 @@ class ScriptInputSnapshotService:
             strategies=sanitized_strategies,
             strategies=sanitized_strategies,
             prompt_manifest=sanitized_prompts,
             prompt_manifest=sanitized_prompts,
             datasource_manifest=redact(request.datasource_manifest),
             datasource_manifest=redact(request.datasource_manifest),
-            model_manifest=redact(request.model_manifest),
+            model_manifest=sanitized_models,
         )
         )
 
 
     async def freeze(self, assembled: ScriptBuildInput) -> ScriptBuildInputSnapshotV1:
     async def freeze(self, assembled: ScriptBuildInput) -> ScriptBuildInputSnapshotV1:
@@ -162,11 +170,17 @@ class ScriptInputSnapshotService:
             strategies=snapshot.strategies,
             strategies=snapshot.strategies,
             prompt_manifest=tuple(merged[key] for key in sorted(merged)),
             prompt_manifest=tuple(merged[key] for key in sorted(merged)),
             datasource_manifest=snapshot.datasource_manifest,
             datasource_manifest=snapshot.datasource_manifest,
-            model_manifest=redact(model_manifest or snapshot.model_manifest),
+            model_manifest=redact(
+                _merge_model_manifests(snapshot.model_manifest, model_manifest or {})
+            ),
             parent_snapshot_id=snapshot.snapshot_id,
             parent_snapshot_id=snapshot.snapshot_id,
             parent_snapshot_sha256=snapshot.canonical_sha256,
             parent_snapshot_sha256=snapshot.canonical_sha256,
             business_input_sha256=business_digest,
             business_input_sha256=business_digest,
         )
         )
+        try:
+            validate_model_prompt_closure(extended.prompt_manifest, extended.model_manifest)
+        except ValueError as exc:
+            raise InputRelationMismatch() from exc
         return await self._snapshots.freeze(extended, version=version)
         return await self._snapshots.freeze(extended, version=version)
 
 
 
 
@@ -178,3 +192,13 @@ def _redacted_rows(
     if not isinstance(sanitized, list) or any(not isinstance(item, dict) for item in sanitized):
     if not isinstance(sanitized, list) or any(not isinstance(item, dict) for item in sanitized):
         raise InputRelationMismatch()
         raise InputRelationMismatch()
     return tuple(sanitized)
     return tuple(sanitized)
+
+
+def _merge_model_manifests(
+    current: dict[str, Any], extension: dict[str, Any]
+) -> dict[str, Any]:
+    current_presets = current.get("presets", current)
+    extension_presets = extension.get("presets", extension)
+    if not isinstance(current_presets, dict) or not isinstance(extension_presets, dict):
+        raise InputRelationMismatch()
+    return {"presets": {**current_presets, **extension_presets}}

+ 20 - 23
script_build_host/src/script_build_host/application/mission_factory.py

@@ -13,6 +13,8 @@ from agent.tools.builtin.knowledge import KnowledgeConfig
 from script_build_host.domain.input_snapshot import ScriptBuildInputSnapshotV1
 from script_build_host.domain.input_snapshot import ScriptBuildInputSnapshotV1
 from script_build_host.domain.records import MissionBinding
 from script_build_host.domain.records import MissionBinding
 
 
+_PLANNER_STALL_LIMIT = 3
+
 
 
 def normalize_topic_summary(value: object, *, max_length: int = 160) -> str:
 def normalize_topic_summary(value: object, *, max_length: int = 160) -> str:
     """Extract a bounded topic summary without inventing a title field."""
     """Extract a bounded topic summary without inventing a title field."""
@@ -93,10 +95,8 @@ class ScriptMissionFactory:
         return json.dumps(
         return json.dumps(
             {
             {
                 "mission": "plan_toward_root",
                 "mission": "plan_toward_root",
-                "script_build_id": binding.script_build_id,
-                "input_snapshot_ref": f"script-build://inputs/{snapshot.snapshot_id}",
-                "input_sha256": snapshot.canonical_sha256,
                 "phase": 1,
                 "phase": 1,
+                "context_entrypoint": "get_current_context",
                 "phase_boundary": "PHASE_ONE_CAPABILITY_BOUNDARY",
                 "phase_boundary": "PHASE_ONE_CAPABILITY_BOUNDARY",
                 "instruction": (
                 "instruction": (
                     "Create Direction under Root without dispatching it; create Retrieval "
                     "Create Direction under Root without dispatching it; create Retrieval "
@@ -122,7 +122,10 @@ class ScriptMissionFactory:
             max_iterations=planner_config["max_iterations"],
             max_iterations=planner_config["max_iterations"],
             system_prompt=_frozen_prompt(snapshot, "script_planner"),
             system_prompt=_frozen_prompt(snapshot, "script_planner"),
             completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
             completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
-            no_progress=NoProgressPolicy(progress_epoch="script-build-phase-one"),
+            no_progress=NoProgressPolicy(
+                planner_stall_limit=_PLANNER_STALL_LIMIT,
+                progress_epoch="script-build-phase-one",
+            ),
             new_trace_id=binding.root_trace_id,
             new_trace_id=binding.root_trace_id,
             root_task_spec=self.build_root_task_spec(snapshot),
             root_task_spec=self.build_root_task_spec(snapshot),
             tool_groups=None,
             tool_groups=None,
@@ -148,8 +151,7 @@ class ScriptMissionFactory:
             {
             {
                 "policy": "script-build-phase-two/v1",
                 "policy": "script-build-phase-two/v1",
                 "phase": 2,
                 "phase": 2,
-                "input_snapshot_ref": f"script-build://inputs/{snapshot.snapshot_id}",
-                "input_sha256": snapshot.canonical_sha256,
+                "context_entrypoint": "get_current_context",
                 "instruction": (
                 "instruction": (
                     "Create exactly one CandidatePortfolio as Root's Phase2 child. Plan any "
                     "Create exactly one CandidatePortfolio as Root's Phase2 child. Plan any "
                     "number and order of Structure, Paragraph, ElementSet, Retrieval and Compare "
                     "number and order of Structure, Paragraph, ElementSet, Retrieval and Compare "
@@ -193,8 +195,8 @@ class ScriptMissionFactory:
         return json.dumps(
         return json.dumps(
             {
             {
                 "mission": "recover_planner_from_durable_ledger",
                 "mission": "recover_planner_from_durable_ledger",
-                "script_build_id": script_build_id,
                 "phase": phase,
                 "phase": phase,
+                "context_entrypoint": "get_current_context",
                 "instruction": (
                 "instruction": (
                     "Inspect the durable ledger, preserve completed Attempts and ACCEPT "
                     "Inspect the durable ledger, preserve completed Attempts and ACCEPT "
                     "decisions, and create new work only for needs_replan Tasks. Never replay "
                     "decisions, and create new work only for needs_replan Tasks. Never replay "
@@ -216,13 +218,8 @@ class ScriptMissionFactory:
         return json.dumps(
         return json.dumps(
             {
             {
                 "mission": "continue_toward_root",
                 "mission": "continue_toward_root",
-                "script_build_id": binding.script_build_id,
                 "phase": 2,
                 "phase": 2,
-                "input_snapshot_ref": f"script-build://inputs/{snapshot.snapshot_id}",
-                "input_sha256": snapshot.canonical_sha256,
-                "accepted_direction_ref": (
-                    f"script-build://artifact-versions/{direction_artifact_version_id}"
-                ),
+                "context_entrypoint": "get_current_context",
                 "active_direction_goal_ids": list(direction_goal_ids),
                 "active_direction_goal_ids": list(direction_goal_ids),
                 "phase_boundary": "PHASE_TWO_CANDIDATE_PORTFOLIO_READY",
                 "phase_boundary": "PHASE_TWO_CANDIDATE_PORTFOLIO_READY",
             },
             },
@@ -242,7 +239,10 @@ class ScriptMissionFactory:
             temperature=planner_config["temperature"],
             temperature=planner_config["temperature"],
             max_iterations=planner_config["max_iterations"],
             max_iterations=planner_config["max_iterations"],
             completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
             completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
-            no_progress=NoProgressPolicy(progress_epoch="script-build-phase-two"),
+            no_progress=NoProgressPolicy(
+                planner_stall_limit=_PLANNER_STALL_LIMIT,
+                progress_epoch="script-build-phase-two",
+            ),
             trace_id=binding.root_trace_id,
             trace_id=binding.root_trace_id,
             root_task_spec=None,
             root_task_spec=None,
             tool_groups=None,
             tool_groups=None,
@@ -268,8 +268,7 @@ class ScriptMissionFactory:
             {
             {
                 "policy": "script-build-phase-three/v1",
                 "policy": "script-build-phase-three/v1",
                 "phase": 3,
                 "phase": 3,
-                "input_snapshot_ref": f"script-build://inputs/{snapshot.snapshot_id}",
-                "input_sha256": snapshot.canonical_sha256,
+                "context_entrypoint": "get_current_context",
                 "instruction": (
                 "instruction": (
                     "Deliver only the accepted Direction and CandidatePortfolio closure. "
                     "Deliver only the accepted Direction and CandidatePortfolio closure. "
                     "Dispatch the Host-frozen root-delivery contract, require independent "
                     "Dispatch the Host-frozen root-delivery contract, require independent "
@@ -293,13 +292,8 @@ class ScriptMissionFactory:
         return json.dumps(
         return json.dumps(
             {
             {
                 "mission": "close_root_delivery",
                 "mission": "close_root_delivery",
-                "script_build_id": binding.script_build_id,
                 "phase": 3,
                 "phase": 3,
-                "input_snapshot_ref": f"script-build://inputs/{snapshot.snapshot_id}",
-                "input_sha256": snapshot.canonical_sha256,
-                "accepted_direction_ref": direction_ref,
-                "accepted_candidate_portfolio_ref": portfolio_ref,
-                "adopted_structured_script_ref": structured_script_ref,
+                "context_entrypoint": "get_current_context",
             },
             },
             ensure_ascii=False,
             ensure_ascii=False,
             sort_keys=True,
             sort_keys=True,
@@ -317,7 +311,10 @@ class ScriptMissionFactory:
             temperature=planner_config["temperature"],
             temperature=planner_config["temperature"],
             max_iterations=planner_config["max_iterations"],
             max_iterations=planner_config["max_iterations"],
             completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
             completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
-            no_progress=NoProgressPolicy(progress_epoch="script-build-phase-three"),
+            no_progress=NoProgressPolicy(
+                planner_stall_limit=_PLANNER_STALL_LIMIT,
+                progress_epoch="script-build-phase-three",
+            ),
             trace_id=binding.root_trace_id,
             trace_id=binding.root_trace_id,
             root_task_spec=None,
             root_task_spec=None,
             tool_groups=None,
             tool_groups=None,

+ 189 - 16
script_build_host/src/script_build_host/application/mission_workbench.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 
 
 import asyncio
 import asyncio
 import json
 import json
+import re
 import secrets
 import secrets
 from collections import Counter, defaultdict
 from collections import Counter, defaultdict
 from collections.abc import Mapping, Sequence
 from collections.abc import Mapping, Sequence
@@ -29,6 +30,13 @@ from script_build_host.domain.context_broker import (
     paginate_context_cards,
     paginate_context_cards,
     rank_context_cards,
     rank_context_cards,
 )
 )
+from script_build_host.domain.errors import ScriptBuildError
+from script_build_host.domain.phase_protocol import (
+    PhaseBoundaryProtocol,
+    PhaseProtocolSnapshot,
+    PhaseTaskProtocol,
+    allowed_child_kinds,
+)
 from script_build_host.domain.phase_two_ports import ScriptTaskContractStore
 from script_build_host.domain.phase_two_ports import ScriptTaskContractStore
 from script_build_host.domain.ports import (
 from script_build_host.domain.ports import (
     InputSnapshotRepository,
     InputSnapshotRepository,
@@ -62,12 +70,12 @@ _ACTIVE = {
     TaskStatus.WAITING_CHILDREN,
     TaskStatus.WAITING_CHILDREN,
     TaskStatus.BLOCKED,
     TaskStatus.BLOCKED,
 }
 }
+_SEMANTIC_HANDLE = re.compile(r"^[a-z][a-z0-9_]*_[0-9a-f]{20}$")
 
 
 
 
-class WorkbenchError(RuntimeError):
+class WorkbenchError(ScriptBuildError):
     def __init__(self, code: str, message: str) -> None:
     def __init__(self, code: str, message: str) -> None:
-        super().__init__(message)
-        self.code = code
+        super().__init__(code, message)
         self.message = message
         self.message = message
 
 
 
 
@@ -145,6 +153,9 @@ class ContextBroker:
         if known_revision == revision:
         if known_revision == revision:
             return NotModified(revision)
             return NotModified(revision)
         binding = await self._bindings.get_by_root(root_trace_id)
         binding = await self._bindings.get_by_root(root_trace_id)
+        snapshot = await self._snapshots.get(
+            str(binding.input_snapshot_id), script_build_id=binding.script_build_id
+        )
         counts = Counter(task.status.value for task in ledger.tasks.values())
         counts = Counter(task.status.value for task in ledger.tasks.values())
         included = self._active_subgraph_ids(ledger)
         included = self._active_subgraph_ids(ledger)
         active_tasks: list[dict[str, Any]] = []
         active_tasks: list[dict[str, Any]] = []
@@ -205,20 +216,42 @@ class ContextBroker:
             accepted_cards,
             accepted_cards,
             " ".join((root.current_spec.objective, *(item for item in all_goals))),
             " ".join((root.current_spec.objective, *(item for item in all_goals))),
         )
         )
-        selected_accepted, used_tokens, omitted = fit_context_cards(ranked_accepted, 3_000)
+        selected_accepted, _accepted_tokens, omitted = fit_context_cards(ranked_accepted, 3_000)
+        input_cards = self._input_cards(snapshot)
+        topic_card = next(
+            (card for card in input_cards if card.source_type == "topic"),
+            input_cards[0] if input_cards else None,
+        )
+        selected_inputs = (
+            ()
+            if topic_card is None
+            else (
+                replace(
+                    topic_card,
+                    summary=topic_card.summary[:160],
+                    excerpt="",
+                    metadata={**dict(topic_card.metadata), "detail_required": True},
+                ),
+            )
+        )
+        input_tokens = sum(estimate_context_tokens(card.to_payload()) for card in selected_inputs)
+        input_omitted = len(input_cards) - len(selected_inputs)
+        input_source_types = tuple(dict.fromkeys(card.source_type for card in input_cards))
         bundle = ContextBundle(
         bundle = ContextBundle(
             revision=revision,
             revision=revision,
-            cards=selected_accepted,
+            cards=selected_inputs,
             required_handles=(),
             required_handles=(),
             sufficiency=assess_context_sufficiency(
             sufficiency=assess_context_sufficiency(
-                required_source_types=(), cards=selected_accepted, has_more=bool(omitted)
+                required_source_types=input_source_types,
+                cards=selected_inputs,
+                has_more=bool(input_omitted),
             ),
             ),
             receipt=ContextReceipt(
             receipt=ContextReceipt(
                 revision=revision,
                 revision=revision,
-                candidate_count=len(accepted_cards),
-                selected_count=len(selected_accepted),
-                estimated_tokens=used_tokens,
-                omitted_count=omitted,
+                candidate_count=len(input_cards),
+                selected_count=len(selected_inputs),
+                estimated_tokens=input_tokens,
+                omitted_count=input_omitted,
             ),
             ),
         )
         )
         await self._emit_receipt(
         await self._emit_receipt(
@@ -226,12 +259,13 @@ class ContextBroker:
             role="planner",
             role="planner",
             task_kind="root",
             task_kind="root",
             receipt=bundle.receipt,
             receipt=bundle.receipt,
-            has_more=bool(omitted),
+            has_more=bool(input_omitted),
         )
         )
         selected_active = tuple(active_tasks[:12])
         selected_active = tuple(active_tasks[:12])
+        phase = _phase(ledger, root, binding.active_direction_artifact_version_id)
         state = PlannerState(
         state = PlannerState(
             state_revision=revision,
             state_revision=revision,
-            phase=_phase(ledger, root, binding.active_direction_artifact_version_id),
+            phase=phase,
             root={
             root={
                 "task_id": root.task_id,
                 "task_id": root.task_id,
                 "status": root.status.value,
                 "status": root.status.value,
@@ -257,13 +291,93 @@ class ContextBroker:
             active_has_more=len(active_tasks) > len(selected_active),
             active_has_more=len(active_tasks) > len(selected_active),
             accepted_total=len(accepted_cards),
             accepted_total=len(accepted_cards),
             accepted_has_more=bool(omitted),
             accepted_has_more=bool(omitted),
-            context_bundle=_bundle_metadata(bundle),
+            context_bundle={
+                **_bundle_metadata(bundle),
+                "cards": [_compact_card(card) for card in bundle.cards],
+            },
+            phase_protocol=(await self._phase_protocol(ledger, phase)).to_payload(),
         )
         )
         _bounded_context(
         _bounded_context(
             state.to_payload(), char_limit=24_000, token_limit=6_000, label="PlannerState"
             state.to_payload(), char_limit=24_000, token_limit=6_000, label="PlannerState"
         )
         )
         return state
         return state
 
 
+    async def _phase_protocol(self, ledger: Any, phase: int) -> PhaseProtocolSnapshot:
+        root = ledger.tasks[ledger.root_task_id]
+        current = ledger.tasks.get(ledger.focused_task_id or "") or root
+        current_is_root = current.task_id == ledger.root_task_id
+        current_kind_value = (
+            None
+            if current_is_root
+            else task_kind_from_context_refs(current.current_spec.context_refs)
+        )
+        current_kind = ScriptTaskKind(current_kind_value) if current_kind_value else None
+        selectable: dict[str, list[str]] = defaultdict(list)
+        for child_id in current.child_task_ids:
+            child = ledger.tasks.get(child_id)
+            if child is None:
+                continue
+            decision = self._accepted_decision(ledger, child)
+            child_kind = task_kind_from_context_refs(child.current_spec.context_refs)
+            if decision is not None and child_kind:
+                selectable[child_kind].append(decision.decision_id)
+        actions: tuple[str, ...]
+        if current_is_root:
+            actions = ("plan", "submit_boundary")
+        elif current.status is TaskStatus.AWAITING_DECISION:
+            actions = ("validate", "accept", "repair", "retry", "revise", "split", "block")
+        elif current.status in {TaskStatus.NEEDS_REPLAN, TaskStatus.BLOCKED}:
+            actions = ("retry", "revise", "split", "block", "cancel")
+        else:
+            actions = ("plan", "dispatch", "inspect")
+        if phase == 1:
+            boundary_reason = "PHASE_ONE_CAPABILITY_BOUNDARY"
+            ready = any(
+                self._accepted_decision(ledger, item) is not None
+                and task_kind_from_context_refs(item.current_spec.context_refs)
+                == ScriptTaskKind.DIRECTION.value
+                for item in ledger.tasks.values()
+            )
+            missing = () if ready else ("accepted_direction",)
+        elif phase == 2:
+            boundary_reason = "PHASE_TWO_CANDIDATE_PORTFOLIO_READY"
+            ready = any(
+                self._accepted_decision(ledger, item) is not None
+                and task_kind_from_context_refs(item.current_spec.context_refs)
+                == ScriptTaskKind.CANDIDATE_PORTFOLIO.value
+                for item in ledger.tasks.values()
+            )
+            missing = () if ready else ("accepted_candidate_portfolio",)
+        else:
+            boundary_reason = "PHASE_THREE_ROOT_DELIVERY_READY"
+            ready = any(
+                self._accepted_decision(ledger, item) is not None
+                and task_kind_from_context_refs(item.current_spec.context_refs)
+                == ScriptTaskKind.ROOT_DELIVERY.value
+                for item in ledger.tasks.values()
+            )
+            missing = () if ready else ("accepted_root_delivery",)
+        return PhaseProtocolSnapshot(
+            phase=phase,
+            phase_epoch=f"script-build-phase-{phase}",
+            topology_version="v1",
+            current_task=PhaseTaskProtocol(
+                task_id=current.task_id,
+                task_kind=current_kind.value if current_kind is not None else "root",
+                allowed_child_kinds=tuple(
+                    sorted(
+                        item.value
+                        for item in allowed_child_kinds(phase=phase, parent_kind=current_kind)
+                    )
+                ),
+                valid_actions=actions,
+                selectable_decisions={
+                    kind: tuple(sorted(values)) for kind, values in sorted(selectable.items())
+                },
+            ),
+            boundary=PhaseBoundaryProtocol(boundary_reason, ready, missing),
+        )
+
     async def worker_state(self, request: RoleContextRequest) -> WorkerState:
     async def worker_state(self, request: RoleContextRequest) -> WorkerState:
         ledger = await self._task_store.load(request.root_trace_id)
         ledger = await self._task_store.load(request.root_trace_id)
         task = ledger.tasks[request.task_id]
         task = ledger.tasks[request.task_id]
@@ -857,6 +971,14 @@ class ContextBroker:
                 omitted_count=max(0, len(filtered) - page.offset - len(page.items)),
                 omitted_count=max(0, len(filtered) - page.offset - len(page.items)),
             ),
             ),
             has_more=page.has_more,
             has_more=page.has_more,
+            progress_key=_digest(
+                {
+                    "operation": "search",
+                    "filter": request.filter_digest(),
+                    "cursor": cursor or "start",
+                    "revision": revision,
+                }
+            ),
         )
         )
         _bounded_json(payload, 8_000, "Context search page")
         _bounded_json(payload, 8_000, "Context search page")
         _bounded_tokens(payload, request.token_budget, "Context search page")
         _bounded_tokens(payload, request.token_budget, "Context search page")
@@ -875,6 +997,12 @@ class ContextBroker:
     ) -> Mapping[str, Any]:
     ) -> Mapping[str, Any]:
         """Read one authorized immutable source through a bounded page."""
         """Read one authorized immutable source through a bounded page."""
 
 
+        if not _SEMANTIC_HANDLE.fullmatch(handle):
+            raise WorkbenchError(
+                "CONTEXT_HANDLE_FORMAT_INVALID",
+                "use a semantic handle returned by get_current_context or search_mission_context",
+            )
+
         ledger = await self._task_store.load(root_trace_id)
         ledger = await self._task_store.load(root_trace_id)
         binding = await self._bindings.get_by_root(root_trace_id)
         binding = await self._bindings.get_by_root(root_trace_id)
         snapshot = await self._snapshots.get(
         snapshot = await self._snapshots.get(
@@ -894,7 +1022,7 @@ class ContextBroker:
             )
             )
             if handle not in authorized_handles:
             if handle not in authorized_handles:
                 raise WorkbenchError(
                 raise WorkbenchError(
-                    "CONTEXT_HANDLE_INVALID", "context handle is stale or unauthorized"
+                    "CONTEXT_HANDLE_UNAUTHORIZED", "context handle is outside this role and Task"
                 )
                 )
             authorized_ref = self._authorized_artifact_refs.get(
             authorized_ref = self._authorized_artifact_refs.get(
                 (root_trace_id, task_id, attempt_id or ""), {}
                 (root_trace_id, task_id, attempt_id or ""), {}
@@ -1018,6 +1146,9 @@ class ContextBroker:
             detail_page_key=_digest(
             detail_page_key=_digest(
                 {"handle": handle, "section": section, "start": start, "revision": revision}
                 {"handle": handle, "section": section, "start": start, "revision": revision}
             ),
             ),
+            progress_key=_digest(
+                {"operation": "read", "handle": handle, "section": section, "start": start}
+            ),
         )
         )
         _bounded_json(payload, 8_000, "Context detail page")
         _bounded_json(payload, 8_000, "Context detail page")
         return payload
         return payload
@@ -1316,7 +1447,7 @@ class ContextBroker:
         ):
         ):
             if card.handle == handle:
             if card.handle == handle:
                 return value, revision
                 return value, revision
-        raise WorkbenchError("CONTEXT_HANDLE_INVALID", "context handle is stale or unauthorized")
+        raise WorkbenchError("CONTEXT_HANDLE_UNKNOWN", "context handle is stale or unknown")
 
 
     async def _authorize_cards(
     async def _authorize_cards(
         self,
         self,
@@ -1507,6 +1638,7 @@ class ContextBroker:
         receipt: ContextReceipt,
         receipt: ContextReceipt,
         has_more: bool,
         has_more: bool,
         detail_page_key: str | None = None,
         detail_page_key: str | None = None,
+        progress_key: str | None = None,
     ) -> None:
     ) -> None:
         append = getattr(self._receipt_store, "append_event", None)
         append = getattr(self._receipt_store, "append_event", None)
         if not callable(append):
         if not callable(append):
@@ -1528,9 +1660,39 @@ class ContextBroker:
                 "omitted_count": receipt.omitted_count,
                 "omitted_count": receipt.omitted_count,
                 "detail_reads": receipt.detail_reads,
                 "detail_reads": receipt.detail_reads,
                 "detail_page_key": detail_page_key,
                 "detail_page_key": detail_page_key,
+                "progress_key": progress_key or detail_page_key,
             },
             },
         )
         )
 
 
+    async def progress_snapshot(self, root_trace_id: str) -> Mapping[str, Any]:
+        """Return durable, duplicate-insensitive Context Broker progress."""
+
+        get_events = getattr(self._receipt_store, "get_events", None)
+        if not callable(get_events):
+            return {}
+        events = await get_events(root_trace_id)
+        progress_keys = {
+            str(item.get("progress_key"))
+            for item in events
+            if item.get("event") == "context_broker_receipt" and item.get("progress_key")
+        }
+        required = {
+            str(key)
+            for item in events
+            if item.get("event") == "context_broker_required_context"
+            for key in item.get("required_source_keys") or ()
+        }
+        completed = {
+            str(item.get("source_key"))
+            for item in events
+            if item.get("event") == "context_broker_required_source_read"
+            and item.get("source_key")
+        }
+        return {
+            "unique_observations": sorted(progress_keys),
+            "required_remaining": sorted(required - completed),
+        }
+
     async def _record_required_context(
     async def _record_required_context(
         self,
         self,
         root_trace_id: str,
         root_trace_id: str,
@@ -1707,6 +1869,9 @@ class MissionWorkbench:
     async def planner_state(self, root_trace_id: str, **kwargs: Any) -> PlannerState | NotModified:
     async def planner_state(self, root_trace_id: str, **kwargs: Any) -> PlannerState | NotModified:
         return await self.broker.planner_state(root_trace_id, **kwargs)
         return await self.broker.planner_state(root_trace_id, **kwargs)
 
 
+    async def progress_snapshot(self, root_trace_id: str) -> Mapping[str, Any]:
+        return await self.broker.progress_snapshot(root_trace_id)
+
     async def worker_state(self, request: RoleContextRequest) -> WorkerState:
     async def worker_state(self, request: RoleContextRequest) -> WorkerState:
         return await self.broker.worker_state(request)
         return await self.broker.worker_state(request)
 
 
@@ -2055,7 +2220,15 @@ def _phase(ledger: Any, root: Any, active_direction_id: int | None) -> int:
         for item in ledger.tasks.values()
         for item in ledger.tasks.values()
     ):
     ):
         return 3
         return 3
-    return 2 if active_direction_id is not None else 1
+    if any(
+        task_kind_from_context_refs(item.current_spec.context_refs)
+        == ScriptTaskKind.CANDIDATE_PORTFOLIO.value
+        for item in ledger.tasks.values()
+    ):
+        return 2
+    if active_direction_id is not None and root.status is not TaskStatus.WAITING_CHILDREN:
+        return 2
+    return 1
 
 
 
 
 def _scope_selector(scope_ref: str) -> dict[str, Any]:
 def _scope_selector(scope_ref: str) -> dict[str, Any]:

+ 47 - 12
script_build_host/src/script_build_host/application/phase_two_candidates.py

@@ -118,6 +118,12 @@ class CandidateWorkspaceOperations(Protocol):
 
 
     async def snapshot(self, context: CandidateWriteContext) -> CandidateWorkspaceSnapshot: ...
     async def snapshot(self, context: CandidateWriteContext) -> CandidateWorkspaceSnapshot: ...
 
 
+    async def seed_from_accepted_artifacts(
+        self,
+        context: CandidateWriteContext,
+        sources: Sequence[ArtifactVersion],
+    ) -> CandidateWorkspaceSnapshot: ...
+
     async def create_paragraph(self, context: CandidateWriteContext, **values: Any) -> int: ...
     async def create_paragraph(self, context: CandidateWriteContext, **values: Any) -> int: ...
 
 
     async def create_paragraphs(
     async def create_paragraphs(
@@ -1304,12 +1310,9 @@ class PhaseTwoCandidateService:
                     ]
                     ]
                 )
                 )
                 if paragraph_versions:
                 if paragraph_versions:
-                    await self._materialize(
-                        scope,
+                    await self._workspace_repo().seed_from_accepted_artifacts(
+                        scope.write_context,
                         paragraph_versions,
                         paragraph_versions,
-                        (),
-                        require_structure_coverage=False,
-                        require_complete=False,
                     )
                     )
         return scope
         return scope
 
 
@@ -1501,13 +1504,36 @@ class PhaseTwoCandidateService:
         while pending:
         while pending:
             version = pending.pop()
             version = pending.pop()
             lineage = getattr(version.artifact, "lineage", None)
             lineage = getattr(version.artifact, "lineage", None)
+            source_refs = {
+                str(item.get("source_artifact_ref"))
+                for item in getattr(lineage, "source_lineage", ())
+                if item.get("source_artifact_ref")
+            }
             base_ref = getattr(lineage, "base_artifact_ref", None)
             base_ref = getattr(lineage, "base_artifact_ref", None)
-            if not base_ref or base_ref in version_by_ref:
-                continue
-            base_id = int(str(base_ref).rsplit("/", 1)[-1])
-            base = await self._artifacts.get_by_id(base_id, script_build_id=scope.script_build_id)
-            version_by_ref[base_ref] = base
-            pending.append(base)
+            if base_ref:
+                source_refs.add(str(base_ref))
+            for source_ref in source_refs:
+                if source_ref in version_by_ref:
+                    continue
+                source_id = int(source_ref.rsplit("/", 1)[-1])
+                source = await self._artifacts.get_by_id(
+                    source_id, script_build_id=scope.script_build_id
+                )
+                expected_digest = next(
+                    (
+                        str(item.get("source_artifact_digest"))
+                        for item in getattr(lineage, "source_lineage", ())
+                        if item.get("source_artifact_ref") == source_ref
+                        and item.get("source_artifact_digest")
+                    ),
+                    None,
+                )
+                if expected_digest and source.canonical_sha256 != expected_digest:
+                    raise PhaseTwoCandidateError(
+                        "STALE_BASE_REVISION", "candidate source identity digest changed"
+                    )
+                version_by_ref[source_ref] = source
+                pending.append(source)
 
 
         entity_key_cache: dict[tuple[str, str, int], tuple[str, str, int]] = {}
         entity_key_cache: dict[tuple[str, str, int], tuple[str, str, int]] = {}
 
 
@@ -1537,7 +1563,16 @@ class PhaseTwoCandidateService:
                     "LEGACY_REFERENCE_INVALID", "candidate source identity is ambiguous"
                     "LEGACY_REFERENCE_INVALID", "candidate source identity is ambiguous"
                 )
                 )
             base_ref = getattr(lineage, "base_artifact_ref", None)
             base_ref = getattr(lineage, "base_artifact_ref", None)
-            if matches and base_ref:
+            qualified_ref = str(matches[0].get("source_artifact_ref") or "") if matches else ""
+            if matches and qualified_ref:
+                source_id = int(matches[0]["source_local_id"])
+                source = version_by_ref.get(qualified_ref)
+                resolved = (
+                    entity_key(source, entity, source_id, trail | {cache_key})
+                    if source is not None
+                    else (entity, qualified_ref, source_id)
+                )
+            elif matches and base_ref:
                 source_id = int(matches[0]["source_local_id"])
                 source_id = int(matches[0]["source_local_id"])
                 base = version_by_ref.get(base_ref)
                 base = version_by_ref.get(base_ref)
                 resolved = (
                 resolved = (

+ 27 - 104
script_build_host/src/script_build_host/application/phase_two_planning.py

@@ -31,11 +31,13 @@ from script_build_host.domain.input_compatibility import (
     AcceptedInputSource,
     AcceptedInputSource,
     validate_accepted_input,
     validate_accepted_input,
 )
 )
+from script_build_host.domain.phase_protocol import allowed_child_kinds
 from script_build_host.domain.phase_two_ports import ScriptTaskContractStore
 from script_build_host.domain.phase_two_ports import ScriptTaskContractStore
 from script_build_host.domain.ports import (
 from script_build_host.domain.ports import (
     MissionBindingRepository,
     MissionBindingRepository,
     ScriptBusinessArtifactRepository,
     ScriptBusinessArtifactRepository,
 )
 )
+from script_build_host.domain.task_capabilities import task_capability
 from script_build_host.domain.task_contracts import (
 from script_build_host.domain.task_contracts import (
     AcceptedDecisionRef,
     AcceptedDecisionRef,
     FrozenTaskContract,
     FrozenTaskContract,
@@ -57,18 +59,7 @@ PHASE_TWO_CANDIDATE_PORTFOLIO_READY = "PHASE_TWO_CANDIDATE_PORTFOLIO_READY"
 PHASE_ONE_CAPABILITY_BOUNDARY = "PHASE_ONE_CAPABILITY_BOUNDARY"
 PHASE_ONE_CAPABILITY_BOUNDARY = "PHASE_ONE_CAPABILITY_BOUNDARY"
 
 
 TASK_PRESET_BY_KIND: dict[ScriptTaskKind, str] = {
 TASK_PRESET_BY_KIND: dict[ScriptTaskKind, str] = {
-    ScriptTaskKind.DIRECTION: "script_direction_worker",
-    ScriptTaskKind.PATTERN_RETRIEVAL: "script_pattern_retrieval_worker",
-    ScriptTaskKind.DECODE_RETRIEVAL: "script_decode_retrieval_worker",
-    ScriptTaskKind.EXTERNAL_RETRIEVAL: "script_external_retrieval_worker",
-    ScriptTaskKind.KNOWLEDGE_RETRIEVAL: "script_knowledge_retrieval_worker",
-    ScriptTaskKind.STRUCTURE: "script_structure_worker",
-    ScriptTaskKind.PARAGRAPH: "script_paragraph_worker",
-    ScriptTaskKind.ELEMENT_SET: "script_element_set_worker",
-    ScriptTaskKind.COMPARE: "script_candidate_compare_worker",
-    ScriptTaskKind.COMPOSE: "script_compose_worker",
-    ScriptTaskKind.CANDIDATE_PORTFOLIO: "script_candidate_portfolio_worker",
-    ScriptTaskKind.ROOT_DELIVERY: "script_root_worker",
+    kind: task_capability(kind).worker_preset for kind in ScriptTaskKind
 }
 }
 
 
 _PHASE_TWO_KINDS = frozenset(
 _PHASE_TWO_KINDS = frozenset(
@@ -181,29 +172,17 @@ class PhasePolicyGuard:
         if phase is None:
         if phase is None:
             return
             return
         parent_is_root = parent.task_id == ledger.root_task_id
         parent_is_root = parent.task_id == ledger.root_task_id
-        parent_kind = task_kind_from_context_refs(parent.current_spec.context_refs)
+        parent_kind_value = task_kind_from_context_refs(parent.current_spec.context_refs)
+        if not parent_is_root and parent_kind_value is None:
+            raise TaskContractError("TASK_CONTRACT_INVALID", "parent Task kind is missing")
+        parent_kind = None if parent_is_root else ScriptTaskKind(cast(str, parent_kind_value))
         kinds = {item.task_kind for item in contracts}
         kinds = {item.task_kind for item in contracts}
-        if phase == 1:
-            allowed = (
-                {ScriptTaskKind.DIRECTION}
-                if parent_is_root
-                else (
-                    set(ScriptTaskKind(item) for item in RETRIEVAL_KINDS)
-                    if parent_kind == ScriptTaskKind.DIRECTION.value
-                    else set()
-                )
-            )
-        elif phase == 2:
-            allowed = (
-                {ScriptTaskKind.CANDIDATE_PORTFOLIO} if parent_is_root else set(_PHASE_TWO_KINDS)
-            )
-        else:
-            allowed = set()
+        allowed = set(allowed_child_kinds(phase=phase, parent_kind=parent_kind))
         if not kinds <= allowed:
         if not kinds <= allowed:
             self._raise_planning_violation(
             self._raise_planning_violation(
                 phase=phase,
                 phase=phase,
                 parent_is_root=parent_is_root,
                 parent_is_root=parent_is_root,
-                parent_kind=parent_kind,
+                parent_kind=parent_kind_value,
                 attempted=kinds,
                 attempted=kinds,
                 allowed=allowed,
                 allowed=allowed,
             )
             )
@@ -228,29 +207,17 @@ class PhasePolicyGuard:
         if phase is None:
         if phase is None:
             return
             return
         parent_is_root = parent.task_id == ledger.root_task_id
         parent_is_root = parent.task_id == ledger.root_task_id
-        parent_kind = task_kind_from_context_refs(parent.current_spec.context_refs)
+        parent_kind_value = task_kind_from_context_refs(parent.current_spec.context_refs)
+        if not parent_is_root and parent_kind_value is None:
+            raise TaskContractError("TASK_CONTRACT_INVALID", "parent Task kind is missing")
+        parent_kind = None if parent_is_root else ScriptTaskKind(cast(str, parent_kind_value))
         kinds = {item.task_kind for item in inputs}
         kinds = {item.task_kind for item in inputs}
-        if phase == 1:
-            allowed = (
-                {ScriptTaskKind.DIRECTION}
-                if parent_is_root
-                else (
-                    set(ScriptTaskKind(item) for item in RETRIEVAL_KINDS)
-                    if parent_kind == ScriptTaskKind.DIRECTION.value
-                    else set()
-                )
-            )
-        elif phase == 2:
-            allowed = (
-                {ScriptTaskKind.CANDIDATE_PORTFOLIO} if parent_is_root else set(_PHASE_TWO_KINDS)
-            )
-        else:
-            allowed = set()
+        allowed = set(allowed_child_kinds(phase=phase, parent_kind=parent_kind))
         if not kinds <= allowed:
         if not kinds <= allowed:
             self._raise_planning_violation(
             self._raise_planning_violation(
                 phase=phase,
                 phase=phase,
                 parent_is_root=parent_is_root,
                 parent_is_root=parent_is_root,
-                parent_kind=parent_kind,
+                parent_kind=parent_kind_value,
                 attempted=kinds,
                 attempted=kinds,
                 allowed=allowed,
                 allowed=allowed,
             )
             )
@@ -1946,7 +1913,11 @@ class PhaseTwoPlanningService:
         )
         )
         child_kinds = {item.task_kind.value for item in contracts}
         child_kinds = {item.task_kind.value for item in contracts}
         if parent_kind is None:
         if parent_kind is None:
-            allowed = {ScriptTaskKind.DIRECTION.value, ScriptTaskKind.CANDIDATE_PORTFOLIO.value}
+            allowed = {
+                item.value
+                for phase in (1, 2)
+                for item in allowed_child_kinds(phase=phase, parent_kind=None)
+            }
             if not child_kinds <= allowed:
             if not child_kinds <= allowed:
                 raise TaskContractError(
                 raise TaskContractError(
                     "TASK_CONTRACT_INVALID", "Root accepts only Direction or CandidatePortfolio"
                     "TASK_CONTRACT_INVALID", "Root accepts only Direction or CandidatePortfolio"
@@ -2000,53 +1971,13 @@ class PhaseTwoPlanningService:
                 "WRITE_SCOPE_VIOLATION",
                 "WRITE_SCOPE_VIOLATION",
                 "child Task write scope must stay within its parent write scope",
                 "child Task write scope must stay within its parent write scope",
             )
             )
-        if parent_kind == ScriptTaskKind.DIRECTION.value:
-            if not child_kinds <= RETRIEVAL_KINDS:
-                raise TaskContractError(
-                    "TASK_CONTRACT_INVALID", "Direction children must be Retrieval Tasks"
-                )
-            return
-        if parent_kind == ScriptTaskKind.CANDIDATE_PORTFOLIO.value:
-            if not child_kinds <= {ScriptTaskKind.COMPOSE.value}:
-                raise TaskContractError(
-                    "TASK_CONTRACT_INVALID", "Portfolio children must be Compose candidates"
-                )
-            return
-        if parent_kind == ScriptTaskKind.COMPOSE.value:
-            allowed = {
-                ScriptTaskKind.STRUCTURE.value,
-                ScriptTaskKind.PARAGRAPH.value,
-                ScriptTaskKind.ELEMENT_SET.value,
-                ScriptTaskKind.COMPARE.value,
-                *RETRIEVAL_KINDS,
-            }
-            if not child_kinds <= allowed:
-                raise TaskContractError(
-                    "TASK_CONTRACT_INVALID", "Compose contains only local creative increments"
-                )
-            return
-        if parent_kind in {
-            ScriptTaskKind.STRUCTURE.value,
-            ScriptTaskKind.PARAGRAPH.value,
-            ScriptTaskKind.ELEMENT_SET.value,
-            ScriptTaskKind.COMPARE.value,
-        }:
-            allowed = {
-                ScriptTaskKind.STRUCTURE.value,
-                ScriptTaskKind.PARAGRAPH.value,
-                ScriptTaskKind.ELEMENT_SET.value,
-                ScriptTaskKind.COMPARE.value,
-                *RETRIEVAL_KINDS,
-            }
-            if not child_kinds <= allowed:
-                raise TaskContractError(
-                    "TASK_CONTRACT_INVALID",
-                    "a local creative Task may split only bounded local or Retrieval increments",
-                )
-            return
-        if child_kinds:
+        allowed = {
+            item.value for item in task_capability(ScriptTaskKind(parent_kind)).child_kinds
+        }
+        if not child_kinds <= allowed:
             raise TaskContractError(
             raise TaskContractError(
-                "TASK_CONTRACT_INVALID", "this Task kind cannot own additional children"
+                "TASK_CONTRACT_INVALID",
+                f"{parent_kind} cannot own child kinds {sorted(child_kinds - allowed)}",
             )
             )
 
 
     @staticmethod
     @staticmethod
@@ -2175,15 +2106,7 @@ def _scope_contains(parent: str, child: str) -> bool:
 
 
 
 
 def _write_scope_for(kind: ScriptTaskKind) -> tuple[str, ...]:
 def _write_scope_for(kind: ScriptTaskKind) -> tuple[str, ...]:
-    if kind in {ScriptTaskKind.STRUCTURE, ScriptTaskKind.PARAGRAPH}:
-        return ("script-build://writes/paragraphs",)
-    if kind is ScriptTaskKind.ELEMENT_SET:
-        # ElementSet owns Element rows and their placement links. It may read or
-        # materialize Paragraph endpoints, but it does not own Paragraph prose.
-        return ("script-build://writes/elements",)
-    if kind in {ScriptTaskKind.COMPOSE, ScriptTaskKind.CANDIDATE_PORTFOLIO}:
-        return ("script-build://writes",)
-    return ()
+    return task_capability(kind).write_scope
 
 
 
 
 def _contract_signature(contract: ScriptTaskContractV1) -> str:
 def _contract_signature(contract: ScriptTaskContractV1) -> str:

+ 24 - 0
script_build_host/src/script_build_host/domain/errors.py

@@ -119,6 +119,30 @@ class ProtocolViolation(ScriptBuildError):
         super().__init__("PROTOCOL_VIOLATION", summary)
         super().__init__("PROTOCOL_VIOLATION", summary)
 
 
 
 
+class UpstreamFailure(ScriptBuildError):
+    """Typed external dependency failure safe for retry and operator policy."""
+
+    def __init__(
+        self,
+        code: str,
+        summary: str,
+        *,
+        provider: str,
+        operation: str,
+        retryable: bool,
+        retry_after_seconds: float | None = None,
+        succeeded: int = 0,
+        failed: int = 0,
+    ) -> None:
+        super().__init__(code, summary)
+        self.provider = provider
+        self.operation = operation
+        self.retryable = retryable
+        self.retry_after_seconds = retry_after_seconds
+        self.succeeded = succeeded
+        self.failed = failed
+
+
 class LegacyCanonicalIdentityConflict(ScriptBuildError):
 class LegacyCanonicalIdentityConflict(ScriptBuildError):
     def __init__(self, summary: str = "legacy projection contains conflicting logical identities"):
     def __init__(self, summary: str = "legacy projection contains conflicting logical identities"):
         super().__init__("LEGACY_CANONICAL_IDENTITY_CONFLICT", summary)
         super().__init__("LEGACY_CANONICAL_IDENTITY_CONFLICT", summary)

+ 24 - 0
script_build_host/src/script_build_host/domain/model_contracts.py

@@ -0,0 +1,24 @@
+"""Domain checks that bind frozen prompts to executable model presets."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+
+
+def validate_model_prompt_closure(
+    prompt_manifest: tuple[Mapping[str, object], ...],
+    model_manifest: Mapping[str, object],
+) -> None:
+    """Fail before freeze when a frozen Prompt has no model configuration."""
+
+    presets = model_manifest.get("presets")
+    if not isinstance(presets, Mapping):
+        return
+    required = {str(item.get("preset") or "") for item in prompt_manifest}
+    required.discard("")
+    missing = sorted(required - set(map(str, presets)))
+    if missing:
+        raise ValueError(f"model manifest is missing frozen prompt presets: {missing}")
+
+
+__all__ = ["validate_model_prompt_closure"]

+ 66 - 0
script_build_host/src/script_build_host/domain/phase_protocol.py

@@ -0,0 +1,66 @@
+"""Machine-readable phase protocol shared by guards and Planner projections."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import asdict, dataclass, field
+from typing import Any
+
+from .task_capabilities import task_capability
+from .task_contracts import ScriptTaskKind
+
+
+@dataclass(frozen=True, slots=True)
+class PhaseTaskProtocol:
+    task_id: str
+    task_kind: str
+    allowed_child_kinds: tuple[str, ...]
+    valid_actions: tuple[str, ...]
+    selectable_decisions: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
+
+
+@dataclass(frozen=True, slots=True)
+class PhaseBoundaryProtocol:
+    reason: str
+    ready: bool
+    missing: tuple[str, ...] = ()
+
+
+@dataclass(frozen=True, slots=True)
+class PhaseProtocolSnapshot:
+    phase: int
+    phase_epoch: str
+    topology_version: str
+    current_task: PhaseTaskProtocol
+    boundary: PhaseBoundaryProtocol
+
+    def to_payload(self) -> dict[str, Any]:
+        return asdict(self)
+
+
+def allowed_child_kinds(
+    *, phase: int, parent_kind: ScriptTaskKind | None
+) -> frozenset[ScriptTaskKind]:
+    if phase == 1:
+        if parent_kind is None:
+            return frozenset({ScriptTaskKind.DIRECTION})
+        if parent_kind is ScriptTaskKind.DIRECTION:
+            return task_capability(parent_kind).child_kinds
+        return frozenset()
+    if phase == 2:
+        return (
+            frozenset({ScriptTaskKind.CANDIDATE_PORTFOLIO})
+            if parent_kind is None
+            else task_capability(parent_kind).child_kinds
+        )
+    if phase == 3:
+        return frozenset({ScriptTaskKind.ROOT_DELIVERY}) if parent_kind is None else frozenset()
+    return frozenset()
+
+
+__all__ = [
+    "PhaseBoundaryProtocol",
+    "PhaseProtocolSnapshot",
+    "PhaseTaskProtocol",
+    "allowed_child_kinds",
+]

+ 6 - 0
script_build_host/src/script_build_host/domain/phase_two_ports.py

@@ -68,6 +68,12 @@ class CandidateWorkspaceRepository(Protocol):
         links: Sequence[Mapping[str, Any]],
         links: Sequence[Mapping[str, Any]],
     ) -> tuple[Mapping[str, int], int]: ...
     ) -> tuple[Mapping[str, int], int]: ...
 
 
+    async def seed_from_accepted_artifacts(
+        self,
+        context: CandidateWriteContext,
+        sources: Sequence[ArtifactVersion],
+    ) -> CandidateWorkspaceSnapshot: ...
+
     async def freeze(
     async def freeze(
         self,
         self,
         context: CandidateWriteContext,
         context: CandidateWriteContext,

+ 333 - 0
script_build_host/src/script_build_host/domain/task_capabilities.py

@@ -0,0 +1,333 @@
+"""Single source of truth for Script Task execution capabilities.
+
+The Planner owns business intent.  The Host owns the executable envelope:
+worker, output schema, tools, criterion vocabulary, write ownership and child
+topology.  Keeping those facts together makes impossible contracts fail before
+an Agent or an external dependency is started.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from enum import StrEnum
+from types import MappingProxyType
+from typing import Any
+
+from .task_contracts import PlannerCriterionInput, ScriptTaskKind, TaskContractError
+
+
+class CriterionType(StrEnum):
+    CUSTOM = "custom"
+    EVIDENCE_NONEMPTY = "evidence_nonempty"
+    SOURCE_ATTRIBUTABLE = "source_attributable"
+    SEMANTIC_RELEVANCE = "semantic_relevance"
+    CONCEPT_COVERAGE = "concept_coverage"
+    FRESHNESS = "freshness"
+
+
+class CompletionMode(StrEnum):
+    WORKER_SUBMITS = "worker_submits"
+    RETRIEVAL_AUTO_SUBMIT = "retrieval_auto_submit"
+    HOST_ASSEMBLES = "host_assembles"
+
+
+@dataclass(frozen=True, slots=True)
+class TaskCapability:
+    task_kind: ScriptTaskKind
+    output_schemas: frozenset[str]
+    worker_preset: str
+    validator_preset: str
+    allowed_tools: frozenset[str]
+    criterion_types: frozenset[CriterionType]
+    write_scope: tuple[str, ...]
+    child_kinds: frozenset[ScriptTaskKind]
+    completion_mode: CompletionMode = CompletionMode.WORKER_SUBMITS
+
+
+_RETRIEVAL_CRITERIA = frozenset(
+    {
+        CriterionType.EVIDENCE_NONEMPTY,
+        CriterionType.SOURCE_ATTRIBUTABLE,
+        CriterionType.SEMANTIC_RELEVANCE,
+        CriterionType.CONCEPT_COVERAGE,
+        CriterionType.FRESHNESS,
+    }
+)
+_CUSTOM = frozenset({CriterionType.CUSTOM})
+_RETRIEVAL_KINDS = frozenset(
+    {
+        ScriptTaskKind.PATTERN_RETRIEVAL,
+        ScriptTaskKind.DECODE_RETRIEVAL,
+        ScriptTaskKind.EXTERNAL_RETRIEVAL,
+        ScriptTaskKind.KNOWLEDGE_RETRIEVAL,
+    }
+)
+_LOCAL_CHILDREN = frozenset(
+    {
+        ScriptTaskKind.STRUCTURE,
+        ScriptTaskKind.PARAGRAPH,
+        ScriptTaskKind.ELEMENT_SET,
+        ScriptTaskKind.COMPARE,
+        *_RETRIEVAL_KINDS,
+    }
+)
+_CONTEXT_READS = frozenset({"search_mission_context", "read_mission_context"})
+
+
+def _retrieval(
+    kind: ScriptTaskKind,
+    preset: str,
+    tool: str,
+) -> TaskCapability:
+    return TaskCapability(
+        task_kind=kind,
+        output_schemas=frozenset({"evidence-record/v1"}),
+        worker_preset=preset,
+        validator_preset="script_retrieval_validator",
+        allowed_tools=frozenset({*_CONTEXT_READS, tool, "submit_attempt"}),
+        criterion_types=_RETRIEVAL_CRITERIA,
+        write_scope=(),
+        child_kinds=frozenset(),
+        completion_mode=CompletionMode.RETRIEVAL_AUTO_SUBMIT,
+    )
+
+
+_CAPABILITIES: Mapping[ScriptTaskKind, TaskCapability] = MappingProxyType(
+    {
+        ScriptTaskKind.DIRECTION: TaskCapability(
+            ScriptTaskKind.DIRECTION,
+            frozenset({"script-direction/v1"}),
+            "script_direction_worker",
+            "script_candidate_validator",
+            frozenset({*_CONTEXT_READS, "save_direction_candidate", "submit_attempt"}),
+            _CUSTOM,
+            (),
+            _RETRIEVAL_KINDS,
+        ),
+        ScriptTaskKind.PATTERN_RETRIEVAL: _retrieval(
+            ScriptTaskKind.PATTERN_RETRIEVAL,
+            "script_pattern_retrieval_worker",
+            "query_pattern_qa",
+        ),
+        ScriptTaskKind.DECODE_RETRIEVAL: _retrieval(
+            ScriptTaskKind.DECODE_RETRIEVAL,
+            "script_decode_retrieval_worker",
+            "search_script_decode_case",
+        ),
+        ScriptTaskKind.EXTERNAL_RETRIEVAL: _retrieval(
+            ScriptTaskKind.EXTERNAL_RETRIEVAL,
+            "script_external_retrieval_worker",
+            "external_search_case",
+        ),
+        ScriptTaskKind.KNOWLEDGE_RETRIEVAL: _retrieval(
+            ScriptTaskKind.KNOWLEDGE_RETRIEVAL,
+            "script_knowledge_retrieval_worker",
+            "search_knowledge",
+        ),
+        ScriptTaskKind.STRUCTURE: TaskCapability(
+            ScriptTaskKind.STRUCTURE,
+            frozenset({"structure-artifact/v1"}),
+            "script_structure_worker",
+            "script_candidate_validator",
+            frozenset(
+                {*_CONTEXT_READS, "view_frozen_images", "save_script_paragraphs", "submit_attempt"}
+            ),
+            _CUSTOM,
+            ("script-build://writes/paragraphs",),
+            _LOCAL_CHILDREN,
+        ),
+        ScriptTaskKind.PARAGRAPH: TaskCapability(
+            ScriptTaskKind.PARAGRAPH,
+            frozenset({"paragraph-artifact/v1", "paragraph-patch/v1"}),
+            "script_paragraph_worker",
+            "script_candidate_validator",
+            frozenset(
+                {*_CONTEXT_READS, "view_frozen_images", "save_script_paragraphs", "submit_attempt"}
+            ),
+            _CUSTOM,
+            ("script-build://writes/paragraphs",),
+            _LOCAL_CHILDREN,
+        ),
+        ScriptTaskKind.ELEMENT_SET: TaskCapability(
+            ScriptTaskKind.ELEMENT_SET,
+            frozenset({"element-set-artifact/v1", "element-set-patch/v1"}),
+            "script_element_set_worker",
+            "script_candidate_validator",
+            frozenset(
+                {*_CONTEXT_READS, "view_frozen_images", "save_script_elements", "submit_attempt"}
+            ),
+            _CUSTOM,
+            ("script-build://writes/elements",),
+            _LOCAL_CHILDREN,
+        ),
+        ScriptTaskKind.COMPARE: TaskCapability(
+            ScriptTaskKind.COMPARE,
+            frozenset({"comparison-artifact/v1"}),
+            "script_candidate_compare_worker",
+            "script_candidate_validator",
+            frozenset({*_CONTEXT_READS, "save_comparison_candidate", "submit_attempt"}),
+            _CUSTOM,
+            (),
+            _LOCAL_CHILDREN,
+        ),
+        ScriptTaskKind.COMPOSE: TaskCapability(
+            ScriptTaskKind.COMPOSE,
+            frozenset({"structured-script/v1"}),
+            "script_compose_worker",
+            "script_candidate_validator",
+            frozenset({"submit_attempt"}),
+            _CUSTOM,
+            ("script-build://writes",),
+            _LOCAL_CHILDREN,
+            CompletionMode.HOST_ASSEMBLES,
+        ),
+        ScriptTaskKind.CANDIDATE_PORTFOLIO: TaskCapability(
+            ScriptTaskKind.CANDIDATE_PORTFOLIO,
+            frozenset({"candidate-portfolio/v1"}),
+            "script_candidate_portfolio_worker",
+            "script_candidate_validator",
+            frozenset({"submit_attempt"}),
+            _CUSTOM,
+            ("script-build://writes",),
+            frozenset({ScriptTaskKind.COMPOSE}),
+            CompletionMode.HOST_ASSEMBLES,
+        ),
+        ScriptTaskKind.ROOT_DELIVERY: TaskCapability(
+            ScriptTaskKind.ROOT_DELIVERY,
+            frozenset({"root-delivery-manifest/v1"}),
+            "script_root_worker",
+            "script_root_validator",
+            frozenset(
+                {
+                    *_CONTEXT_READS,
+                    "legacy_projection_dry_run",
+                    "save_root_delivery_manifest",
+                    "submit_attempt",
+                }
+            ),
+            frozenset(),
+            (),
+            frozenset(),
+        ),
+    }
+)
+
+
+def task_capability(kind: ScriptTaskKind) -> TaskCapability:
+    return _CAPABILITIES[kind]
+
+
+def task_capabilities() -> tuple[TaskCapability, ...]:
+    return tuple(_CAPABILITIES[kind] for kind in ScriptTaskKind)
+
+
+def validate_business_criteria(
+    kind: ScriptTaskKind,
+    criteria: tuple[PlannerCriterionInput, ...],
+) -> None:
+    capability = task_capability(kind)
+    invalid: list[str] = []
+    for item in criteria:
+        try:
+            criterion_type = CriterionType(item.criterion_type)
+        except ValueError:
+            invalid.append(str(item.criterion_type))
+            continue
+        if criterion_type not in capability.criterion_types:
+            invalid.append(criterion_type.value)
+    invalid = sorted(set(invalid))
+    if invalid:
+        raise TaskContractError(
+            "TASK_CAPABILITY_MISMATCH",
+            f"{kind.value} cannot produce or validate criteria types {invalid}",
+            details={
+                "task_kind": kind.value,
+                "output_schemas": sorted(capability.output_schemas),
+                "completion_mode": capability.completion_mode.value,
+                "allowed_criterion_types": sorted(
+                    item.value for item in capability.criterion_types
+                ),
+                "invalid_criterion_types": invalid,
+            },
+        )
+
+
+def render_criterion(value: PlannerCriterionInput) -> str:
+    kind = CriterionType(value.criterion_type)
+    params = dict(value.params)
+    if kind is CriterionType.CUSTOM:
+        return value.description
+    if value.description:
+        raise TaskContractError(
+            "TASK_CAPABILITY_MISMATCH",
+            "typed criteria are rendered by the Host and cannot carry free-text descriptions",
+        )
+    if kind is CriterionType.EVIDENCE_NONEMPTY:
+        minimum = _bounded_int(params, "minimum_count", default=1, minimum=1, maximum=20)
+        _only(params, {"minimum_count"})
+        return f"Evidence contains at least {minimum} usable item(s)"
+    if kind is CriterionType.SOURCE_ATTRIBUTABLE:
+        _only(params, set())
+        return "Every accepted evidence item retains an attributable source"
+    if kind in {CriterionType.SEMANTIC_RELEVANCE, CriterionType.CONCEPT_COVERAGE}:
+        concepts = params.get("concepts")
+        if (
+            not isinstance(concepts, list)
+            or not 1 <= len(concepts) <= 12
+            or any(not isinstance(item, str) or not item.strip() for item in concepts)
+        ):
+            raise TaskContractError(
+                "TASK_CAPABILITY_MISMATCH", "typed evidence criterion requires 1-12 concepts"
+            )
+        minimum = _bounded_int(
+            params,
+            "minimum_matches",
+            default=1,
+            minimum=1,
+            maximum=len(concepts),
+        )
+        _only(params, {"concepts", "minimum_matches"})
+        label = "semantically relates to" if kind is CriterionType.SEMANTIC_RELEVANCE else "covers"
+        concept_list = ", ".join(item.strip() for item in concepts)
+        return f"Evidence {label} at least {minimum} of: {concept_list}"
+    if kind is CriterionType.FRESHNESS:
+        days = _bounded_int(params, "max_age_days", default=365, minimum=1, maximum=3650)
+        _only(params, {"max_age_days"})
+        return f"Evidence is no older than {days} day(s) when publication time is available"
+    raise AssertionError(f"unhandled criterion type: {kind}")
+
+
+def _bounded_int(
+    values: Mapping[str, Any],
+    key: str,
+    *,
+    default: int,
+    minimum: int,
+    maximum: int,
+) -> int:
+    raw = values.get(key, default)
+    if isinstance(raw, bool) or not isinstance(raw, int) or not minimum <= raw <= maximum:
+        raise TaskContractError(
+            "TASK_CAPABILITY_MISMATCH", f"{key} must be between {minimum} and {maximum}"
+        )
+    return raw
+
+
+def _only(values: Mapping[str, Any], allowed: set[str]) -> None:
+    unknown = sorted(set(values) - allowed)
+    if unknown:
+        raise TaskContractError(
+            "TASK_CAPABILITY_MISMATCH", f"criterion params contain unknown fields: {unknown}"
+        )
+
+
+__all__ = [
+    "CompletionMode",
+    "CriterionType",
+    "TaskCapability",
+    "render_criterion",
+    "task_capabilities",
+    "task_capability",
+    "validate_business_criteria",
+]

+ 13 - 3
script_build_host/src/script_build_host/domain/task_contracts.py

@@ -116,12 +116,20 @@ class ScopeSelector:
 @dataclass(frozen=True, slots=True)
 @dataclass(frozen=True, slots=True)
 class PlannerCriterionInput:
 class PlannerCriterionInput:
     client_key: str
     client_key: str
-    description: str
+    description: str = ""
     hard: bool = True
     hard: bool = True
+    criterion_type: str = "custom"
+    params: Mapping[str, Any] = field(default_factory=dict)
 
 
     def __post_init__(self) -> None:
     def __post_init__(self) -> None:
         _require_identifier(self.client_key, "criterion client_key")
         _require_identifier(self.client_key, "criterion client_key")
-        _require_bounded_text(self.description, "criterion description", 500)
+        if self.criterion_type == "custom":
+            _require_bounded_text(self.description, "criterion description", 500)
+        elif self.description:
+            _require_bounded_text(self.description, "criterion description", 500)
+        _require_identifier(self.criterion_type, "criterion_type")
+        if not isinstance(self.params, Mapping):
+            raise TaskContractError("TASK_CONTRACT_INVALID", "criterion params must be an object")
         if not isinstance(self.hard, bool):
         if not isinstance(self.hard, bool):
             raise TaskContractError("TASK_CONTRACT_INVALID", "criterion hard must be boolean")
             raise TaskContractError("TASK_CONTRACT_INVALID", "criterion hard must be boolean")
 
 
@@ -129,7 +137,7 @@ class PlannerCriterionInput:
     def from_payload(cls, value: object) -> PlannerCriterionInput:
     def from_payload(cls, value: object) -> PlannerCriterionInput:
         if not isinstance(value, Mapping):
         if not isinstance(value, Mapping):
             raise TaskContractError("TASK_CONTRACT_INVALID", "criteria must contain objects")
             raise TaskContractError("TASK_CONTRACT_INVALID", "criteria must contain objects")
-        if set(value) - {"client_key", "description", "hard"}:
+        if set(value) - {"client_key", "description", "hard", "criterion_type", "params"}:
             raise TaskContractError(
             raise TaskContractError(
                 "TASK_CONTRACT_INVALID", "Planner criteria cannot provide criterion_id"
                 "TASK_CONTRACT_INVALID", "Planner criteria cannot provide criterion_id"
             )
             )
@@ -137,6 +145,8 @@ class PlannerCriterionInput:
             client_key=str(value.get("client_key", "")),
             client_key=str(value.get("client_key", "")),
             description=str(value.get("description", "")),
             description=str(value.get("description", "")),
             hard=value.get("hard", True),
             hard=value.get("hard", True),
+            criterion_type=str(value.get("criterion_type", "custom")),
+            params=dict(value.get("params") or {}),
         )
         )
 
 
 
 

+ 3 - 1
script_build_host/src/script_build_host/domain/task_policy.py

@@ -6,6 +6,7 @@ from collections.abc import Sequence
 from hashlib import sha256
 from hashlib import sha256
 from typing import Any
 from typing import Any
 
 
+from .task_capabilities import render_criterion, validate_business_criteria
 from .task_contracts import (
 from .task_contracts import (
     PlannerCriterionInput,
     PlannerCriterionInput,
     ScriptCriterion,
     ScriptCriterion,
@@ -88,6 +89,7 @@ class TaskPolicyCatalog:
         business: tuple[PlannerCriterionInput, ...],
         business: tuple[PlannerCriterionInput, ...],
         direction_constraints: Sequence[Any] = (),
         direction_constraints: Sequence[Any] = (),
     ) -> tuple[ScriptCriterion, ...]:
     ) -> tuple[ScriptCriterion, ...]:
+        validate_business_criteria(task_kind, business)
         fixed = _FIXED[task_kind]
         fixed = _FIXED[task_kind]
         fixed_ids = {item.criterion_id for item in fixed}
         fixed_ids = {item.criterion_id for item in fixed}
         constraints = tuple(
         constraints = tuple(
@@ -101,7 +103,7 @@ class TaskPolicyCatalog:
         extra = tuple(
         extra = tuple(
             ScriptCriterion(
             ScriptCriterion(
                 stable_semantic_id(task_id, "criterion", item.client_key),
                 stable_semantic_id(task_id, "criterion", item.client_key),
-                item.description,
+                render_criterion(item),
                 item.hard,
                 item.hard,
             )
             )
             for item in business
             for item in business

+ 1 - 0
script_build_host/src/script_build_host/domain/workbench.py

@@ -36,6 +36,7 @@ class PlannerState:
     accepted_total: int = 0
     accepted_total: int = 0
     accepted_has_more: bool = False
     accepted_has_more: bool = False
     context_bundle: Mapping[str, Any] = field(default_factory=dict)
     context_bundle: Mapping[str, Any] = field(default_factory=dict)
+    phase_protocol: Mapping[str, Any] = field(default_factory=dict)
 
 
     def to_payload(self) -> dict[str, Any]:
     def to_payload(self) -> dict[str, Any]:
         return asdict(self)
         return asdict(self)

+ 29 - 0
script_build_host/src/script_build_host/infrastructure/tables.py

@@ -100,6 +100,35 @@ artifact_version_table = Table(
     Index("ix_artifact_task", "script_build_id", "task_id"),
     Index("ix_artifact_task", "script_build_id", "task_id"),
 )
 )
 
 
+candidate_source_identity_table = Table(
+    "script_build_candidate_source_identity",
+    metadata,
+    Column("id", _primary_key_type, primary_key=True, autoincrement=True),
+    Column("workspace_artifact_version_id", BigInteger, nullable=False),
+    Column("entity_kind", String(32), nullable=False),
+    Column("target_local_id", BigInteger, nullable=False),
+    Column("source_artifact_version_id", BigInteger, nullable=False),
+    Column("source_artifact_digest", String(64), nullable=False),
+    Column("source_local_id", BigInteger, nullable=False),
+    Column("created_at", _timestamp_type, nullable=False),
+    UniqueConstraint(
+        "workspace_artifact_version_id",
+        "entity_kind",
+        "target_local_id",
+        name="uq_candidate_source_target",
+    ),
+    UniqueConstraint(
+        "workspace_artifact_version_id",
+        "source_artifact_version_id",
+        "entity_kind",
+        "source_local_id",
+        name="uq_candidate_source_origin",
+    ),
+    CheckConstraint("entity_kind IN ('paragraph', 'element')", name="ck_candidate_source_kind"),
+    CheckConstraint("length(source_artifact_digest) = 64", name="ck_candidate_source_digest"),
+    Index("ix_candidate_source_workspace", "workspace_artifact_version_id"),
+)
+
 publication_table = Table(
 publication_table = Table(
     "script_build_publication",
     "script_build_publication",
     metadata,
     metadata,

+ 153 - 2
script_build_host/src/script_build_host/repositories/workspace.py

@@ -45,7 +45,10 @@ from script_build_host.infrastructure.legacy_tables import (
     script_build_paragraph_element,
     script_build_paragraph_element,
     script_build_task_plan_step,
     script_build_task_plan_step,
 )
 )
-from script_build_host.infrastructure.tables import artifact_version_table
+from script_build_host.infrastructure.tables import (
+    artifact_version_table,
+    candidate_source_identity_table,
+)
 from script_build_host.repositories.sqlalchemy import (
 from script_build_host.repositories.sqlalchemy import (
     SqlAlchemyScriptBusinessArtifactRepository,
     SqlAlchemyScriptBusinessArtifactRepository,
 )
 )
@@ -180,6 +183,123 @@ class SqlAlchemyCandidateWorkspaceRepository:
             workspace = await self._locked_workspace(session, context, require_draft=False)
             workspace = await self._locked_workspace(session, context, require_draft=False)
             return await self._snapshot_in_session(session, workspace)
             return await self._snapshot_in_session(session, workspace)
 
 
+    async def seed_from_accepted_artifacts(
+        self,
+        context: CandidateWriteContext,
+        sources: Sequence[ArtifactVersion],
+    ) -> CandidateWorkspaceSnapshot:
+        """Atomically seed read-only Paragraph endpoints into an ElementSet draft."""
+
+        if not sources:
+            raise WorkspaceError("LEGACY_REFERENCE_INVALID", "workspace seed requires a source")
+        expected: set[tuple[int, int]] = set()
+        validated_sources: list[tuple[ArtifactVersion, ParagraphArtifactV1]] = []
+        for source in sources:
+            paragraph_artifact = source.artifact
+            if not isinstance(paragraph_artifact, ParagraphArtifactV1) or source.state not in {
+                ArtifactState.FROZEN,
+                ArtifactState.PUBLISHED,
+            }:
+                raise WorkspaceError(
+                    "LEGACY_REFERENCE_INVALID", "workspace seed sources must be frozen Paragraphs"
+                )
+            source_ref = f"script-build://artifact-versions/{source.artifact_version_id}"
+            if source_ref not in set(context.input_refs):
+                raise WorkspaceError(
+                    "LEGACY_REFERENCE_INVALID", "workspace seed source is outside accepted inputs"
+                )
+            expected.update(
+                (source.artifact_version_id, item.paragraph_id)
+                for item in paragraph_artifact.paragraphs
+            )
+            validated_sources.append((source, paragraph_artifact))
+        async with self._sessions() as session, session.begin():
+            workspace = await self._locked_workspace(session, context)
+            if workspace.artifact_kind is not ArtifactKind.ELEMENT_SET:
+                raise WorkspaceError(
+                    "LEGACY_WRITE_INVALID", "only ElementSet workspaces accept Paragraph seeds"
+                )
+            existing = tuple(
+                (
+                    await session.execute(
+                        select(candidate_source_identity_table).where(
+                            candidate_source_identity_table.c.workspace_artifact_version_id
+                            == workspace.artifact_version_id,
+                            candidate_source_identity_table.c.entity_kind == "paragraph",
+                        )
+                    )
+                )
+                .mappings()
+                .all()
+            )
+            if existing:
+                actual = {
+                    (int(item["source_artifact_version_id"]), int(item["source_local_id"]))
+                    for item in existing
+                }
+                if actual != expected:
+                    raise WorkspaceError(
+                        "STALE_BASE_REVISION", "workspace was seeded from a different input closure"
+                    )
+                return await self._snapshot_in_session(session, workspace)
+            current = await self._snapshot_in_session(session, workspace)
+            if current.paragraphs or current.elements or current.links:
+                raise WorkspaceError(
+                    "HOST_CONTRACT_INVARIANT_BROKEN",
+                    "unseeded ElementSet workspace is not empty",
+                )
+            now = _now()
+            for source, paragraph_artifact in sorted(
+                validated_sources, key=lambda item: item[0].artifact_version_id
+            ):
+                paragraph_map: dict[int, int] = {}
+                for item in sorted(
+                    paragraph_artifact.paragraphs,
+                    key=lambda value: (value.level, value.paragraph_index, value.paragraph_id),
+                ):
+                    parent_id = (
+                        paragraph_map.get(item.parent_id) if item.parent_id is not None else None
+                    )
+                    result = await session.execute(
+                        insert(self._paragraphs).values(
+                            script_build_id=workspace.script_build_id,
+                            branch_id=workspace.branch_id,
+                            base_ref_id=item.paragraph_id,
+                            paragraph_index=item.paragraph_index,
+                            level=item.level,
+                            parent_id=parent_id,
+                            name=item.name,
+                            content_range=item.content_range,
+                            theme=item.theme,
+                            form=item.form,
+                            function=item.function,
+                            feeling=item.feeling,
+                            theme_elements=list(item.theme_elements),
+                            form_elements=list(item.form_elements),
+                            function_elements=list(item.function_elements),
+                            feeling_elements=list(item.feeling_elements),
+                            description=item.description,
+                            full_description=item.full_description,
+                            is_active=item.is_active,
+                            created_at=now,
+                            updated_at=now,
+                        )
+                    )
+                    target_id = int(cast(Any, result).inserted_primary_key[0])
+                    paragraph_map[item.paragraph_id] = target_id
+                    await session.execute(
+                        insert(candidate_source_identity_table).values(
+                            workspace_artifact_version_id=workspace.artifact_version_id,
+                            entity_kind="paragraph",
+                            target_local_id=target_id,
+                            source_artifact_version_id=source.artifact_version_id,
+                            source_artifact_digest=source.canonical_sha256.removeprefix("sha256:"),
+                            source_local_id=item.paragraph_id,
+                            created_at=now,
+                        )
+                    )
+            return await self._snapshot_in_session(session, workspace)
+
     async def create_paragraph(
     async def create_paragraph(
         self,
         self,
         context: CandidateWriteContext,
         context: CandidateWriteContext,
@@ -1485,7 +1605,36 @@ class SqlAlchemyCandidateWorkspaceRepository:
             .mappings()
             .mappings()
             .all()
             .all()
         )
         )
+        qualified_source_rows = tuple(
+            (
+                await session.execute(
+                    select(candidate_source_identity_table).where(
+                        candidate_source_identity_table.c.workspace_artifact_version_id
+                        == workspace.artifact_version_id
+                    )
+                )
+            )
+            .mappings()
+            .all()
+        )
+        qualified_targets = {
+            (str(row["entity_kind"]), int(row["target_local_id"]))
+            for row in qualified_source_rows
+        }
         source_identity_map: list[dict[str, int | str]] = [
         source_identity_map: list[dict[str, int | str]] = [
+            {
+                "entity": str(row["entity_kind"]),
+                "source_artifact_ref": (
+                    "script-build://artifact-versions/"
+                    f"{int(row['source_artifact_version_id'])}"
+                ),
+                "source_artifact_digest": "sha256:" + str(row["source_artifact_digest"]),
+                "source_local_id": int(row["source_local_id"]),
+                "local_id": int(row["target_local_id"]),
+            }
+            for row in qualified_source_rows
+        ]
+        source_identity_map.extend(
             {
             {
                 "entity": "paragraph",
                 "entity": "paragraph",
                 "source_local_id": int(row["base_ref_id"]),
                 "source_local_id": int(row["base_ref_id"]),
@@ -1493,7 +1642,8 @@ class SqlAlchemyCandidateWorkspaceRepository:
             }
             }
             for row in paragraph_rows
             for row in paragraph_rows
             if row["base_ref_id"] is not None
             if row["base_ref_id"] is not None
-        ]
+            and ("paragraph", int(row["id"])) not in qualified_targets
+        )
         source_identity_map.extend(
         source_identity_map.extend(
             {
             {
                 "entity": "element",
                 "entity": "element",
@@ -1502,6 +1652,7 @@ class SqlAlchemyCandidateWorkspaceRepository:
             }
             }
             for row in element_rows
             for row in element_rows
             if row["base_ref_id"] is not None
             if row["base_ref_id"] is not None
+            and ("element", int(row["id"])) not in qualified_targets
         )
         )
         return CandidateWorkspaceSnapshot(
         return CandidateWorkspaceSnapshot(
             workspace=workspace,
             workspace=workspace,

+ 7 - 0
script_build_host/src/script_build_host/tools/failures.py

@@ -22,6 +22,7 @@ _REPLAN_CODES = frozenset(
         "TASK_KIND_PRESET_MISMATCH",
         "TASK_KIND_PRESET_MISMATCH",
         "TASK_STATE_CONFLICT",
         "TASK_STATE_CONFLICT",
         "ATTEMPT_WORKSPACE_FROZEN",
         "ATTEMPT_WORKSPACE_FROZEN",
+        "CONTEXT_HANDLE_UNKNOWN",
         "WRITE_SCOPE_VIOLATION",
         "WRITE_SCOPE_VIOLATION",
         "SUPERSESSION_CYCLE",
         "SUPERSESSION_CYCLE",
     }
     }
@@ -41,6 +42,12 @@ _RETRY_CODES = frozenset(
         "STALE_WORKBENCH_STATE",
         "STALE_WORKBENCH_STATE",
         "VALIDATION_EVIDENCE_INVALID",
         "VALIDATION_EVIDENCE_INVALID",
         "CONTEXT_NOT_EXHAUSTED",
         "CONTEXT_NOT_EXHAUSTED",
+        "CONTEXT_HANDLE_INVALID",
+        "CONTEXT_HANDLE_FORMAT_INVALID",
+        "TRANSIENT_TIMEOUT",
+        "TRANSIENT_CONNECTION_LOST",
+        "RATE_LIMITED",
+        "UPSTREAM_UNAVAILABLE",
     }
     }
 )
 )
 _ABORT_MARKERS = (
 _ABORT_MARKERS = (

+ 69 - 32
script_build_host/src/script_build_host/tools/gateway.py

@@ -7,6 +7,7 @@ framework's protected context and then checked against durable bindings.
 
 
 from __future__ import annotations
 from __future__ import annotations
 
 
+import asyncio
 import re
 import re
 from collections.abc import Awaitable, Callable, Mapping, Sequence
 from collections.abc import Awaitable, Callable, Mapping, Sequence
 from dataclasses import asdict, dataclass
 from dataclasses import asdict, dataclass
@@ -126,7 +127,7 @@ class LegacyScriptToolGateway:
         self.budget_guard = budget_guard
         self.budget_guard = budget_guard
         self.root_tools = root_tools
         self.root_tools = root_tools
         self.workbench = workbench
         self.workbench = workbench
-        self._active_retrieval_attempts: set[tuple[str, str, str]] = set()
+        self._retrieval_locks: dict[tuple[str, str, str], asyncio.Lock] = {}
 
 
     async def ensure_dispatch_allowed(self, context: Mapping[str, Any]) -> None:
     async def ensure_dispatch_allowed(self, context: Mapping[str, Any]) -> None:
         binding = await self.bindings.get_by_root(_required(context, "root_trace_id"))
         binding = await self.bindings.get_by_root(_required(context, "root_trace_id"))
@@ -369,24 +370,44 @@ class LegacyScriptToolGateway:
         task_id = _required(context, "task_id")
         task_id = _required(context, "task_id")
         attempt_id = _required(context, "attempt_id")
         attempt_id = _required(context, "attempt_id")
         retrieval_key = (root_trace_id, task_id, attempt_id)
         retrieval_key = (root_trace_id, task_id, attempt_id)
-        if retrieval_key in self._active_retrieval_attempts:
-            raise ProtocolViolation("one Retrieval Attempt may execute only one external query")
-        binding = await self.bindings.get_by_root(root_trace_id)
-        try:
-            await self.artifacts.get_by_attempt(
-                script_build_id=binding.script_build_id,
-                task_id=task_id,
-                attempt_id=attempt_id,
-            )
-        except ArtifactNotFound:
-            pass
-        else:
-            raise ProtocolViolation("one Retrieval Attempt may freeze only one Evidence artifact")
-        self._active_retrieval_attempts.add(retrieval_key)
-        try:
+        lock = self._retrieval_locks.setdefault(retrieval_key, asyncio.Lock())
+        async with lock:
+            binding = await self.bindings.get_by_root(root_trace_id)
+            try:
+                existing = await self.artifacts.get_by_attempt(
+                    script_build_id=binding.script_build_id,
+                    task_id=task_id,
+                    attempt_id=attempt_id,
+                )
+            except ArtifactNotFound:
+                pass
+            else:
+                artifact = existing.artifact
+                frozen_query = redact(dict(query))
+                if not isinstance(artifact, EvidenceRecordV1) or not isinstance(
+                    frozen_query, dict
+                ):
+                    raise ProtocolViolation(
+                        "Retrieval Attempt already owns a non-Evidence artifact"
+                    )
+                stored_query = dict(artifact.query)
+                stored_query.pop("_retrieval_metadata", None)
+                if (
+                    artifact.source_type != source_type
+                    or artifact.tool_name != tool_name
+                    or stored_query != frozen_query
+                ):
+                    raise ProtocolViolation(
+                        "Retrieval Attempt is already bound to a different external query"
+                    )
+                return _evidence_tool_payload(
+                    existing,
+                    root_trace_id=root_trace_id,
+                    task_id=task_id,
+                    attempt_id=attempt_id,
+                    resumed=True,
+                )
             return await self._retrieve_once(source_type, tool_name, query, context)
             return await self._retrieve_once(source_type, tool_name, query, context)
-        finally:
-            self._active_retrieval_attempts.discard(retrieval_key)
 
 
     async def _retrieve_once(
     async def _retrieve_once(
         self,
         self,
@@ -431,26 +452,20 @@ class LegacyScriptToolGateway:
             content_sha256="",
             content_sha256="",
             created_at=datetime.now(UTC),
             created_at=datetime.now(UTC),
         )
         )
-        version, ref = await self.artifacts.freeze(
+        version, _ref = await self.artifacts.freeze(
             script_build_id=binding.script_build_id,
             script_build_id=binding.script_build_id,
             task_id=_required(context, "task_id"),
             task_id=_required(context, "task_id"),
             attempt_id=_required(context, "attempt_id"),
             attempt_id=_required(context, "attempt_id"),
             spec_version=_required_int(context, "spec_version"),
             spec_version=_required_int(context, "spec_version"),
             artifact=record,
             artifact=record,
         )
         )
-        return {
-            "evidence_handle": semantic_handle(
-                "evidence",
-                _required(context, "root_trace_id"),
-                _required(context, "task_id"),
-                ref.digest or ref.version or "",
-            ),
-            "evidence": protect_model_value(
-                _json_values(asdict(version.artifact)),
-                namespace=_required(context, "attempt_id"),
-            ),
-            "source_count": len(result.source_refs),
-        }
+        return _evidence_tool_payload(
+            version,
+            root_trace_id=_required(context, "root_trace_id"),
+            task_id=_required(context, "task_id"),
+            attempt_id=_required(context, "attempt_id"),
+            resumed=False,
+        )
 
 
     async def view_frozen_images(
     async def view_frozen_images(
         self, image_handles: Sequence[str], context: Mapping[str, Any]
         self, image_handles: Sequence[str], context: Mapping[str, Any]
@@ -933,6 +948,28 @@ class LegacyScriptToolGateway:
         return self.root_tools
         return self.root_tools
 
 
 
 
+def _evidence_tool_payload(
+    version: Any,
+    *,
+    root_trace_id: str,
+    task_id: str,
+    attempt_id: str,
+    resumed: bool,
+) -> dict[str, Any]:
+    artifact = version.artifact
+    if not isinstance(artifact, EvidenceRecordV1):
+        raise ProtocolViolation("Retrieval Attempt does not own Evidence")
+    digest = str(version.canonical_sha256 or version.artifact_version_id)
+    return {
+        "evidence_handle": semantic_handle("evidence", root_trace_id, task_id, digest),
+        "evidence": protect_model_value(
+            _json_values(asdict(artifact)), namespace=attempt_id
+        ),
+        "source_count": len(artifact.source_refs),
+        "resumed_from_frozen_evidence": resumed,
+    }
+
+
 def _required(context: Mapping[str, Any], key: str) -> str:
 def _required(context: Mapping[str, Any], key: str) -> str:
     value = context.get(key)
     value = context.get(key)
     if not isinstance(value, str) or not value.strip():
     if not isinstance(value, str) or not value.strip():

+ 16 - 14
script_build_host/src/script_build_host/tools/registry.py

@@ -12,24 +12,14 @@ from agent import FailureDetail, FailureDisposition, ToolExecutionError, ToolReg
 from agent.tools.models import ToolResult
 from agent.tools.models import ToolResult
 
 
 from script_build_host.domain.errors import ScriptBuildError
 from script_build_host.domain.errors import ScriptBuildError
+from script_build_host.domain.task_capabilities import task_capabilities
 from script_build_host.domain.workbench import protect_model_value
 from script_build_host.domain.workbench import protect_model_value
 
 
 from .failures import classify_script_tool_failure
 from .failures import classify_script_tool_failure
 from .gateway import LegacyScriptToolGateway
 from .gateway import LegacyScriptToolGateway
 
 
 TASK_PRESET_BY_KIND = {
 TASK_PRESET_BY_KIND = {
-    "direction": "script_direction_worker",
-    "pattern-retrieval": "script_pattern_retrieval_worker",
-    "decode-retrieval": "script_decode_retrieval_worker",
-    "external-retrieval": "script_external_retrieval_worker",
-    "knowledge-retrieval": "script_knowledge_retrieval_worker",
-    "structure": "script_structure_worker",
-    "paragraph": "script_paragraph_worker",
-    "element-set": "script_element_set_worker",
-    "compare": "script_candidate_compare_worker",
-    "compose": "script_compose_worker",
-    "candidate-portfolio": "script_candidate_portfolio_worker",
-    "root-delivery": "script_root_worker",
+    item.task_kind.value: item.worker_preset for item in task_capabilities()
 }
 }
 
 
 _MAX_STRUCTURED_ARGUMENT_BYTES = 2 * 1024 * 1024
 _MAX_STRUCTURED_ARGUMENT_BYTES = 2 * 1024 * 1024
@@ -947,10 +937,22 @@ def _planner_task_input_schema() -> dict[str, Any]:
                     "type": "object",
                     "type": "object",
                     "properties": {
                     "properties": {
                         "client_key": {"type": "string"},
                         "client_key": {"type": "string"},
-                        "description": {"type": "string"},
+                        "criterion_type": {
+                            "type": "string",
+                            "enum": [
+                                "custom",
+                                "evidence_nonempty",
+                                "source_attributable",
+                                "semantic_relevance",
+                                "concept_coverage",
+                                "freshness",
+                            ],
+                        },
+                        "description": {"type": "string", "default": ""},
+                        "params": {"type": "object", "default": {}},
                         "hard": {"type": "boolean"},
                         "hard": {"type": "boolean"},
                     },
                     },
-                    "required": ["client_key", "description", "hard"],
+                    "required": ["client_key", "criterion_type", "hard"],
                     "additionalProperties": False,
                     "additionalProperties": False,
                 },
                 },
             },
             },

+ 10 - 0
script_build_host/tests/test_context_broker_integration.py

@@ -330,6 +330,16 @@ async def test_planner_hot_context_is_bounded_and_every_item_is_pageable() -> No
     assert "FORGED REASON" not in " ".join(str(item["summary"]) for item in state.accepted_frontier)
     assert "FORGED REASON" not in " ".join(str(item["summary"]) for item in state.accepted_frontier)
 
 
     assert any("REAL ARTIFACT CONTENT" in str(item["summary"]) for item in state.accepted_frontier)
     assert any("REAL ARTIFACT CONTENT" in str(item["summary"]) for item in state.accepted_frontier)
+    input_cards = state.context_bundle["cards"]
+    assert input_cards
+    assert input_cards[0]["source_type"] == "topic"
+    assert input_cards[0]["handle"].startswith("input_")
+    input_page = await workbench.read_mission_context(
+        root_trace_id="root",
+        role="planner",
+        handle=input_cards[0]["handle"],
+    )
+    assert input_page["content_fragment"]
 
 
     cursor = None
     cursor = None
     found: list[str] = []
     found: list[str] = []

+ 5 - 4
script_build_host/tests/test_input_snapshot_service.py

@@ -112,15 +112,16 @@ async def test_extend_prompt_lineage_is_append_only_and_preserves_business_diges
             topic_id=30,
             topic_id=30,
             topic={"topic": {"id": 30}},
             topic={"topic": {"id": 30}},
             account={"account_name": "acct"},
             account={"account_name": "acct"},
-            prompt_manifest=(
+                prompt_manifest=(
                 {
                 {
                     "preset": "script_planner",
                     "preset": "script_planner",
                     "role": "planner",
                     "role": "planner",
                     "content": "phase one",
                     "content": "phase one",
                     "content_sha256": canonical_sha256("phase one").wire,
                     "content_sha256": canonical_sha256("phase one").wire,
-                },
-            ),
-        )
+                    },
+                ),
+                model_manifest={"presets": {"script_planner": {"model": "fake"}}},
+            )
     )
     )
 
 
     class Prompts:
     class Prompts:

+ 10 - 2
script_build_host/tests/test_internal_runtime.py

@@ -7,6 +7,10 @@ import pytest
 from pydantic import SecretStr
 from pydantic import SecretStr
 
 
 from script_build_host.agents.model_manifest import resolve_script_role_model_manifest
 from script_build_host.agents.model_manifest import resolve_script_role_model_manifest
+from script_build_host.agents.prompts import (
+    phase_three_prompt_manifest,
+    script_build_prompt_manifest,
+)
 from script_build_host.domain.records import Principal
 from script_build_host.domain.records import Principal
 from script_build_host.infrastructure.config import ScriptBuildSettings
 from script_build_host.infrastructure.config import ScriptBuildSettings
 from script_build_host.internal_runtime import (
 from script_build_host.internal_runtime import (
@@ -34,7 +38,7 @@ def _settings(tmp_path: Path, **overrides: Any) -> ScriptBuildSettings:
     return ScriptBuildSettings(**values)
     return ScriptBuildSettings(**values)
 
 
 
 
-def test_role_model_manifest_covers_all_sixteen_presets_and_keeps_overrides() -> None:
+def test_role_model_manifest_covers_frozen_prompt_catalog_and_keeps_overrides() -> None:
     result = resolve_script_role_model_manifest(
     result = resolve_script_role_model_manifest(
         {
         {
             "embedding_model": "text-embedding-v4",
             "embedding_model": "text-embedding-v4",
@@ -45,7 +49,11 @@ def test_role_model_manifest_covers_all_sixteen_presets_and_keeps_overrides() ->
     )
     )
     presets = result["presets"]
     presets = result["presets"]
     assert isinstance(presets, dict)
     assert isinstance(presets, dict)
-    assert len(presets) == 17
+    required = {
+        str(item["preset"])
+        for item in (*script_build_prompt_manifest(), *phase_three_prompt_manifest())
+    }
+    assert set(presets) == required
     assert presets["script_context_broker"]["temperature"] == 0.0
     assert presets["script_context_broker"]["temperature"] == 0.0
     assert {value["model"] for value in presets.values()} == {"qwen3.7-max"}
     assert {value["model"] for value in presets.values()} == {"qwen3.7-max"}
     assert presets["script_root_validator"]["temperature"] == 0.0
     assert presets["script_root_validator"]["temperature"] == 0.0

+ 3 - 2
script_build_host/tests/test_migrations.py

@@ -59,7 +59,8 @@ def test_mysql_offline_sql_compiles_without_credentials(monkeypatch: object) ->
     config = Config(str(root / "alembic.ini"), output_buffer=output)
     config = Config(str(root / "alembic.ini"), output_buffer=output)
     command.upgrade(config, "head", sql=True)
     command.upgrade(config, "head", sql=True)
     sql = output.getvalue()
     sql = output.getvalue()
-    assert sql.count("CREATE TABLE script_build_") == 5
+    assert sql.count("CREATE TABLE script_build_") == 6
+    assert "CREATE TABLE script_build_candidate_source_identity" in sql
     assert "CREATE TABLE script_build_paragraph" not in sql
     assert "CREATE TABLE script_build_paragraph" not in sql
     assert "CREATE TABLE script_build_element" not in sql
     assert "CREATE TABLE script_build_element" not in sql
     assert "DROP CHECK ck_artifact_phase_one_type" in sql
     assert "DROP CHECK ck_artifact_phase_one_type" in sql
@@ -144,7 +145,7 @@ async def test_mysql_phase_three_schema_semantics() -> None:
                 "READ-COMMITTED"
                 "READ-COMMITTED"
             )
             )
             assert await connection.scalar(text("SELECT version_num FROM alembic_version")) == (
             assert await connection.scalar(text("SELECT version_num FROM alembic_version")) == (
-                "0003_phase_three_publication"
+                "0004_candidate_source_identity"
             )
             )
             precision = await connection.scalar(
             precision = await connection.scalar(
                 text(
                 text(

+ 4 - 1
script_build_host/tests/test_mission_factory.py

@@ -64,7 +64,9 @@ def test_factory_reads_nested_real_topic_and_does_not_double_prefix_digest() ->
     assert "title" not in str(spec)
     assert "title" not in str(spec)
     assert "workflow" not in str(spec).lower()
     assert "workflow" not in str(spec).lower()
     message = json.loads(factory.build_planner_message(_binding(), _snapshot()))
     message = json.loads(factory.build_planner_message(_binding(), _snapshot()))
-    assert message["input_sha256"] == "sha256:" + "b" * 64
+    assert message["context_entrypoint"] == "get_current_context"
+    assert "input_snapshot_ref" not in message
+    assert "input_sha256" not in message
     assert "Retrieval children under that Direction" in message["instruction"]
     assert "Retrieval children under that Direction" in message["instruction"]
     assert "exact reason PHASE_ONE_CAPABILITY_BOUNDARY" in message["instruction"]
     assert "exact reason PHASE_ONE_CAPABILITY_BOUNDARY" in message["instruction"]
     config = factory.build_run_config(_binding(), _snapshot())
     config = factory.build_run_config(_binding(), _snapshot())
@@ -72,6 +74,7 @@ def test_factory_reads_nested_real_topic_and_does_not_double_prefix_digest() ->
     assert config.model == "planner-model"
     assert config.model == "planner-model"
     assert config.temperature == 0.1
     assert config.temperature == 0.1
     assert config.max_iterations == 42
     assert config.max_iterations == 42
+    assert config.no_progress.planner_stall_limit == 3
     assert config.new_trace_id == "root"
     assert config.new_trace_id == "root"
     assert config.context["phase"] == 1
     assert config.context["phase"] == 1
     assert config.enable_memory is False
     assert config.enable_memory is False

+ 61 - 2
script_build_host/tests/test_mission_workbench.py

@@ -8,7 +8,7 @@ from typing import Any
 import pytest
 import pytest
 from agent.orchestration import AgentRole, ArtifactRef, RoleContextRequest, TaskStatus
 from agent.orchestration import AgentRole, ArtifactRef, RoleContextRequest, TaskStatus
 
 
-from script_build_host.application.mission_workbench import MissionWorkbench
+from script_build_host.application.mission_workbench import MissionWorkbench, WorkbenchError
 from script_build_host.domain.task_contracts import (
 from script_build_host.domain.task_contracts import (
     ScriptCriterion,
     ScriptCriterion,
     ScriptIntentClass,
     ScriptIntentClass,
@@ -16,7 +16,7 @@ from script_build_host.domain.task_contracts import (
     ScriptTaskContractV1,
     ScriptTaskContractV1,
     ScriptTaskKind,
     ScriptTaskKind,
 )
 )
-from script_build_host.domain.workbench import NotModified
+from script_build_host.domain.workbench import NotModified, semantic_handle
 
 
 
 
 class _TaskStore:
 class _TaskStore:
@@ -28,6 +28,17 @@ class _TaskStore:
         return self.ledger
         return self.ledger
 
 
 
 
+class _Receipts:
+    def __init__(self) -> None:
+        self.events: list[dict[str, Any]] = []
+
+    async def append_event(self, _trace_id: str, event: str, payload: dict[str, Any]) -> None:
+        self.events.append({"event": event, "event_id": len(self.events) + 1, **payload})
+
+    async def get_events(self, _trace_id: str) -> list[dict[str, Any]]:
+        return list(self.events)
+
+
 class _Bindings:
 class _Bindings:
     async def get_by_root(self, root_trace_id: str) -> Any:
     async def get_by_root(self, root_trace_id: str) -> Any:
         assert root_trace_id == "root"
         assert root_trace_id == "root"
@@ -192,18 +203,22 @@ async def test_planner_projection_is_bounded_incremental_and_does_not_copy_retir
         decisions={},
         decisions={},
         operations={},
         operations={},
     )
     )
+    receipts = _Receipts()
     workbench = MissionWorkbench(
     workbench = MissionWorkbench(
         task_store=_TaskStore(ledger),
         task_store=_TaskStore(ledger),
         bindings=_Bindings(),
         bindings=_Bindings(),
         snapshots=_Snapshots(),
         snapshots=_Snapshots(),
         artifacts=_Artifacts(),
         artifacts=_Artifacts(),
         contracts=_Contracts(),
         contracts=_Contracts(),
+        receipt_store=receipts,
     )
     )
 
 
     state = await workbench.planner_state("root")
     state = await workbench.planner_state("root")
     assert not isinstance(state, NotModified)
     assert not isinstance(state, NotModified)
     assert state.retired_task_count == 120
     assert state.retired_task_count == 120
     assert len(state.active_subgraph) == 1
     assert len(state.active_subgraph) == 1
+    assert state.phase_protocol["phase_epoch"] == "script-build-phase-1"
+    assert state.phase_protocol["current_task"]["allowed_child_kinds"] == ("direction",)
     encoded = await workbench.render("root", ledger)
     encoded = await workbench.render("root", ledger)
     assert len(encoded) < 24_000
     assert len(encoded) < 24_000
     assert "retired detail" not in encoded
     assert "retired detail" not in encoded
@@ -227,6 +242,23 @@ async def test_planner_projection_is_bounded_incremental_and_does_not_copy_retir
     assert first_page["page"]["has_more"] is True
     assert first_page["page"]["has_more"] is True
     assert first_page["page"]["next_cursor"]
     assert first_page["page"]["next_cursor"]
     assert len(json.dumps(first_page, ensure_ascii=False, separators=(",", ":"))) < 8_000
     assert len(json.dumps(first_page, ensure_ascii=False, separators=(",", ":"))) < 8_000
+    await workbench.search_mission_context(
+        root_trace_id="root",
+        role="planner",
+        collection="tasks",
+        page_size=20,
+    )
+    progress = await workbench.progress_snapshot("root")
+    assert len(progress["unique_observations"]) == 1
+    await workbench.search_mission_context(
+        root_trace_id="root",
+        role="planner",
+        collection="tasks",
+        query="different semantic scope",
+        page_size=20,
+    )
+    progress = await workbench.progress_snapshot("root")
+    assert len(progress["unique_observations"]) == 2
 
 
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio
@@ -292,3 +324,30 @@ async def test_role_bootstrap_uses_semantic_targets_and_protects_artifact_identi
     assert "paragraph_id" not in encoded_validator
     assert "paragraph_id" not in encoded_validator
     assert "script-build://artifact-versions/77" not in encoded_validator
     assert "script-build://artifact-versions/77" not in encoded_validator
     assert "canonical_sha256" not in encoded_validator
     assert "canonical_sha256" not in encoded_validator
+
+    with pytest.raises(WorkbenchError) as physical:
+        await workbench.read_mission_context(
+            root_trace_id="root",
+            role="planner",
+            handle="script-build://artifact-versions/77",
+        )
+    assert physical.value.code == "CONTEXT_HANDLE_FORMAT_INVALID"
+
+    unknown_handle = semantic_handle("unknown", "root", "not-present")
+    with pytest.raises(WorkbenchError) as unknown:
+        await workbench.read_mission_context(
+            root_trace_id="root",
+            role="planner",
+            handle=unknown_handle,
+        )
+    assert unknown.value.code == "CONTEXT_HANDLE_UNKNOWN"
+
+    with pytest.raises(WorkbenchError) as unauthorized:
+        await workbench.read_mission_context(
+            root_trace_id="root",
+            role="worker",
+            task_id="worker",
+            attempt_id="attempt",
+            handle=unknown_handle,
+        )
+    assert unauthorized.value.code == "CONTEXT_HANDLE_UNAUTHORIZED"

+ 16 - 7
script_build_host/tests/test_phase_one_e2e.py

@@ -77,6 +77,21 @@ def _contract(
     *,
     *,
     scope: str,
     scope: str,
 ) -> dict[str, object]:
 ) -> dict[str, object]:
+    criterion = (
+        {
+            "client_key": criterion_id,
+            "criterion_type": "evidence_nonempty",
+            "params": {"minimum_count": 1},
+            "hard": True,
+        }
+        if kind.endswith("-retrieval")
+        else {
+            "client_key": criterion_id,
+            "criterion_type": "custom",
+            "description": description,
+            "hard": True,
+        }
+    )
     return {
     return {
         "task_kind": kind,
         "task_kind": kind,
         "scope_selector": {
         "scope_selector": {
@@ -88,13 +103,7 @@ def _contract(
         "input_decision_ids": [],
         "input_decision_ids": [],
         "base_decision_id": None,
         "base_decision_id": None,
         "gap_key": None,
         "gap_key": None,
-        "criteria": [
-            {
-                "client_key": criterion_id,
-                "description": description,
-                "hard": True,
-            }
-        ],
+        "criteria": [criterion],
         "goal_ids": [],
         "goal_ids": [],
         "supersedes_decision_ids": [],
         "supersedes_decision_ids": [],
         "comparison_decision_ids": [],
         "comparison_decision_ids": [],

+ 34 - 3
script_build_host/tests/test_phase_two_agent_tools.py

@@ -1,5 +1,6 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
+import asyncio
 import base64
 import base64
 import hashlib
 import hashlib
 from collections.abc import Mapping
 from collections.abc import Mapping
@@ -121,6 +122,7 @@ class _BusinessArtifacts:
 class _RetrievalArtifacts:
 class _RetrievalArtifacts:
     def __init__(self) -> None:
     def __init__(self) -> None:
         self.version: Any | None = None
         self.version: Any | None = None
+        self.ref: ArtifactRef | None = None
 
 
     async def get_by_attempt(self, **_owners: Any) -> Any:
     async def get_by_attempt(self, **_owners: Any) -> Any:
         if self.version is None:
         if self.version is None:
@@ -137,10 +139,17 @@ class _RetrievalArtifacts:
         self.version = SimpleNamespace(
         self.version = SimpleNamespace(
             artifact_version_id=8,
             artifact_version_id=8,
             state=ArtifactState.FROZEN,
             state=ArtifactState.FROZEN,
+            artifact_type=SimpleNamespace(value="evidence"),
+            canonical_sha256=ref.digest,
             artifact=artifact,
             artifact=artifact,
         )
         )
+        self.ref = ref
         return self.version, ref
         return self.version, ref
 
 
+    async def read_by_ref(self, ref: ArtifactRef, **_owners: Any) -> Any:
+        assert self.ref == ref
+        return self.version
+
 
 
 class _CountingRetrievalAdapter:
 class _CountingRetrievalAdapter:
     def __init__(self) -> None:
     def __init__(self) -> None:
@@ -148,6 +157,7 @@ class _CountingRetrievalAdapter:
 
 
     async def retrieve(self, **_kwargs: Any) -> RetrievalResult:
     async def retrieve(self, **_kwargs: Any) -> RetrievalResult:
         self.calls += 1
         self.calls += 1
+        await asyncio.sleep(0)
         return RetrievalResult(
         return RetrievalResult(
             source_refs=("decode://case/1", "decode://case/1"),
             source_refs=("decode://case/1", "decode://case/1"),
             summary="one immutable result",
             summary="one immutable result",
@@ -305,7 +315,7 @@ async def test_retrieval_budget_is_checked_before_adapter_or_snapshot_access() -
 
 
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio
-async def test_one_retrieval_attempt_never_calls_adapter_twice() -> None:
+async def test_retrieval_replay_reuses_frozen_evidence_and_can_submit() -> None:
     artifacts = _RetrievalArtifacts()
     artifacts = _RetrievalArtifacts()
     adapter = _CountingRetrievalAdapter()
     adapter = _CountingRetrievalAdapter()
 
 
@@ -327,10 +337,31 @@ async def test_one_retrieval_attempt_never_calls_adapter_twice() -> None:
         "attempt_id": "attempt-a",
         "attempt_id": "attempt-a",
         "spec_version": 1,
         "spec_version": 1,
     }
     }
-    await gateway.retrieve("decode", "search_script_decode_case", {"query": "opening"}, context)
+    first, resumed = await asyncio.gather(
+        gateway.retrieve(
+            "decode", "search_script_decode_case", {"query": "opening"}, context
+        ),
+        gateway.retrieve(
+            "decode", "search_script_decode_case", {"query": "opening"}, context
+        ),
+    )
     assert artifacts.version.artifact.source_refs == ("decode://case/1",)
     assert artifacts.version.artifact.source_refs == ("decode://case/1",)
     assert artifacts.version.artifact.supports == ("opening",)
     assert artifacts.version.artifact.supports == ("opening",)
-    with pytest.raises(ProtocolViolation, match="only one Evidence"):
+    assert {first["resumed_from_frozen_evidence"], resumed["resumed_from_frozen_evidence"]} == {
+        False,
+        True,
+    }
+    assert adapter.calls == 1
+
+    assert artifacts.ref is not None
+    coordinator = _Coordinator(artifacts.ref)
+    gateway.coordinator = coordinator
+    gateway.candidate_tools = _CandidateTools(artifacts.ref)
+    await gateway.submit_current_attempt(context)
+    assert coordinator.attempt_submission is not None
+    assert coordinator.attempt_submission[1].evidence_refs == [artifacts.ref]
+
+    with pytest.raises(ProtocolViolation, match="different external query"):
         await gateway.retrieve(
         await gateway.retrieve(
             "decode", "search_script_decode_case", {"query": "different"}, context
             "decode", "search_script_decode_case", {"query": "different"}, context
         )
         )

+ 172 - 5
script_build_host/tests/test_phase_two_candidates.py

@@ -10,6 +10,7 @@ import pytest
 from agent.orchestration import ArtifactRef
 from agent.orchestration import ArtifactRef
 
 
 from script_build_host.application.phase_two_candidates import (
 from script_build_host.application.phase_two_candidates import (
+    CandidateScope,
     PhaseTwoCandidateError,
     PhaseTwoCandidateError,
     PhaseTwoCandidateService,
     PhaseTwoCandidateService,
     _candidate_state_revision,
     _candidate_state_revision,
@@ -66,7 +67,7 @@ WRITE = "script-build://writes/paragraphs/opening"
 
 
 
 
 def _complete_paragraph() -> ScriptParagraphV1:
 def _complete_paragraph() -> ScriptParagraphV1:
-    atom = ({"原子点": "内容", "维度": "开场"},)
+    atom = ({"原子点": "内容", "维度": "开场", "维度类型": "主维度"},)
     return ScriptParagraphV1(
     return ScriptParagraphV1(
         1,
         1,
         1,
         1,
@@ -644,10 +645,7 @@ async def test_element_set_materializes_all_accepted_paragraphs_before_linking(
         objective="attach concrete elements to every accepted paragraph",
         objective="attach concrete elements to every accepted paragraph",
         input_decision_refs=(accepted_ref,),
         input_decision_refs=(accepted_ref,),
         base_artifact_ref=None,
         base_artifact_ref=None,
-        write_scope=(
-            "script-build://writes/paragraphs",
-            "script-build://writes/elements",
-        ),
+        write_scope=("script-build://writes/elements",),
         gap_ref=None,
         gap_ref=None,
         output_schema="element-set-artifact/v1",
         output_schema="element-set-artifact/v1",
         criteria=(ScriptCriterion("closed", "all elements are linked"),),
         criteria=(ScriptCriterion("closed", "all elements are linked"),),
@@ -762,6 +760,175 @@ async def test_element_set_materializes_all_accepted_paragraphs_before_linking(
     assert manifest.artifact_ref.kind == ArtifactKind.ELEMENT_SET.value
     assert manifest.artifact_ref.kind == ArtifactKind.ELEMENT_SET.value
 
 
 
 
+@pytest.mark.asyncio
+async def test_element_seed_lineage_survives_full_compose_projection(database: Any) -> None:
+    _, sessions = database
+    artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
+    workspaces = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
+    paragraph = _complete_paragraph()
+
+    structure_version, structure_ref = await artifacts.freeze(
+        script_build_id=7,
+        task_id="structure-source",
+        attempt_id="structure-attempt",
+        spec_version=1,
+        artifact=StructureArtifactV1(_lineage(), (paragraph,)),
+    )
+    paragraph_lineage = replace(
+        _lineage(),
+        base_artifact_ref=structure_ref.uri,
+        base_artifact_digest=structure_ref.digest,
+        base_revision=structure_version.artifact_version_id,
+        source_lineage=(
+            {"entity": "paragraph", "local_id": 1, "source_local_id": 1},
+        ),
+    )
+    paragraph_version, paragraph_ref = await artifacts.freeze(
+        script_build_id=7,
+        task_id="paragraph-source",
+        attempt_id="paragraph-attempt",
+        spec_version=1,
+        artifact=ParagraphArtifactV1(
+            paragraph_lineage,
+            (paragraph,),
+            (),
+            (),
+            {"created": {"paragraph_ids": [1]}},
+        ),
+    )
+
+    element_context = CandidateWriteContext(
+        script_build_id=7,
+        task_id="element-task",
+        attempt_id="element-attempt",
+        spec_version=1,
+        objective="link one grounded element",
+        input_refs=(paragraph_ref.uri,),
+        write_scope=("script-build://writes/elements",),
+    )
+    await workspaces.get_or_create(element_context, artifact_kind=ArtifactKind.ELEMENT_SET)
+    seeded = await workspaces.seed_from_accepted_artifacts(
+        element_context, (paragraph_version,)
+    )
+    seeded_id = seeded.paragraphs[0].paragraph_id
+    await workspaces.create_elements(
+        element_context,
+        (
+            {
+                "client_key": "element",
+                "name": "grounded element",
+                "dimension_primary": "实质",
+                "dimension_secondary": "观察",
+            },
+        ),
+        ({"paragraph_id": seeded_id, "element_client_keys": ["element"]},),
+    )
+    element_version, element_ref = await workspaces.freeze(
+        element_context,
+        lineage=replace(
+            _lineage(),
+            write_scope=("script-build://writes/elements",),
+        ),
+    )
+    assert element_version.artifact.lineage.source_lineage[0]["source_artifact_ref"] == (
+        paragraph_ref.uri
+    )
+
+    compose_contract = replace(
+        _contract(ScriptTaskKind.COMPOSE),
+        write_scope=("script-build://writes",),
+    )
+    compose_context = CandidateWriteContext(
+        script_build_id=7,
+        task_id="compose-task",
+        attempt_id="compose-attempt",
+        spec_version=1,
+        objective=compose_contract.objective,
+        input_refs=(structure_ref.uri, paragraph_ref.uri, element_ref.uri),
+        write_scope=compose_contract.write_scope,
+    )
+    await workspaces.get_or_create(
+        compose_context, artifact_kind=ArtifactKind.STRUCTURED_SCRIPT
+    )
+    scope = CandidateScope(
+        root_trace_id="root",
+        script_build_id=7,
+        input_snapshot_id="11",
+        task=SimpleNamespace(task_id="compose-task"),
+        attempt=SimpleNamespace(attempt_id="compose-attempt", spec_version=1),
+        contract=compose_contract,
+        write_context=compose_context,
+    )
+    service = PhaseTwoCandidateService(
+        bindings=cast(Any, _Bindings()),
+        task_store=cast(Any, _TaskStore(SimpleNamespace())),
+        framework_artifact_store=_FrameworkArtifacts(),
+        artifacts=artifacts,
+        accepted_inputs=cast(Any, None),
+        active_frontier=ActiveFrontierResolver(),
+        workspaces=workspaces,
+    )
+    await service._materialize(
+        scope,
+        (structure_version, paragraph_version, element_version),
+        (),
+    )
+    composed = await workspaces.snapshot(compose_context)
+    assert len(composed.paragraphs) == 1
+    assert len(composed.elements) == 1
+    assert len(composed.links) == 1
+
+
+@pytest.mark.asyncio
+async def test_element_seed_disambiguates_same_local_id_and_replays_idempotently(
+    database: Any,
+) -> None:
+    _, sessions = database
+    artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
+    workspaces = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
+    versions: list[ArtifactVersion] = []
+    refs: list[ArtifactRef] = []
+    for index in (1, 2):
+        paragraph = replace(
+            _complete_paragraph(),
+            name=f"paragraph-{index}",
+            full_description=f"complete source paragraph {index}",
+        )
+        version, ref = await artifacts.freeze(
+            script_build_id=7,
+            task_id=f"paragraph-{index}",
+            attempt_id=f"paragraph-attempt-{index}",
+            spec_version=1,
+            artifact=ParagraphArtifactV1(
+                _lineage(),
+                (paragraph,),
+                (),
+                (),
+                {"created": {"paragraph_ids": [1]}},
+            ),
+        )
+        versions.append(version)
+        refs.append(ref)
+    context = CandidateWriteContext(
+        script_build_id=7,
+        task_id="element-multi",
+        attempt_id="element-multi-attempt",
+        spec_version=1,
+        objective="seed two accepted paragraphs",
+        input_refs=tuple(item.uri for item in refs),
+        write_scope=("script-build://writes/elements",),
+    )
+    await workspaces.get_or_create(context, artifact_kind=ArtifactKind.ELEMENT_SET)
+    first = await workspaces.seed_from_accepted_artifacts(context, tuple(versions))
+    replay = await workspaces.seed_from_accepted_artifacts(context, tuple(reversed(versions)))
+
+    assert len(first.paragraphs) == len(replay.paragraphs) == 2
+    assert {item["source_local_id"] for item in first.source_identity_map} == {1}
+    assert {item["source_artifact_ref"] for item in first.source_identity_map} == {
+        item.uri for item in refs
+    }
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_candidate_service_discards_existing_draft_workspace_on_abandoned_attempt(
 async def test_candidate_service_discards_existing_draft_workspace_on_abandoned_attempt(
     database: Any,
     database: Any,

+ 28 - 12
script_build_host/tests/test_phase_two_contracts.py

@@ -30,6 +30,7 @@ from script_build_host.application.phase_two_planning import (
     PhasePolicyGuard,
     PhasePolicyGuard,
     PhaseTwoPlanningService,
     PhaseTwoPlanningService,
     _execution_seconds,
     _execution_seconds,
+    _write_scope_for,
 )
 )
 from script_build_host.domain.artifacts import DirectionArtifact, DirectionGoal
 from script_build_host.domain.artifacts import DirectionArtifact, DirectionGoal
 from script_build_host.domain.errors import ProtocolViolation
 from script_build_host.domain.errors import ProtocolViolation
@@ -37,11 +38,16 @@ from script_build_host.domain.records import BuildStatus, MissionBinding, Princi
 from script_build_host.domain.task_contracts import (
 from script_build_host.domain.task_contracts import (
     PhaseTwoLimits,
     PhaseTwoLimits,
     ScriptTaskContractV1,
     ScriptTaskContractV1,
+    ScriptTaskKind,
     TaskContractError,
     TaskContractError,
 )
 )
 from script_build_host.infrastructure.task_contract_store import FileScriptTaskContractStore
 from script_build_host.infrastructure.task_contract_store import FileScriptTaskContractStore
 
 
 
 
+def test_element_set_owns_only_elements_even_when_host_materializes_paragraph_endpoints() -> None:
+    assert _write_scope_for(ScriptTaskKind.ELEMENT_SET) == ("script-build://writes/elements",)
+
+
 def test_execution_budget_counts_stage_intervals_not_planner_idle_time() -> None:
 def test_execution_budget_counts_stage_intervals_not_planner_idle_time() -> None:
     now = datetime.now(UTC)
     now = datetime.now(UTC)
     stages = (
     stages = (
@@ -145,14 +151,26 @@ def _planner_input(payload: dict[str, object]) -> dict[str, object]:
         "intent_class": payload["intent_class"],
         "intent_class": payload["intent_class"],
         "objective": payload["objective"],
         "objective": payload["objective"],
         "goal_ids": payload["goal_ids"],
         "goal_ids": payload["goal_ids"],
-        "criteria": [
-            {
-                "client_key": item["criterion_id"],
-                "description": item["description"],
-                "hard": item.get("hard", True),
-            }
-            for item in cast(list[dict[str, Any]], payload["criteria"])
-        ],
+        "criteria": (
+            [
+                {
+                    "client_key": "evidence-present",
+                    "criterion_type": "evidence_nonempty",
+                    "params": {"minimum_count": 1},
+                    "hard": True,
+                }
+            ]
+            if str(payload["task_kind"]).endswith("retrieval")
+            else [
+                {
+                    "client_key": item["criterion_id"],
+                    "criterion_type": "custom",
+                    "description": item["description"],
+                    "hard": item.get("hard", True),
+                }
+                for item in cast(list[dict[str, Any]], payload["criteria"])
+            ]
+        ),
         "input_decision_ids": [item["decision_id"] for item in input_refs],
         "input_decision_ids": [item["decision_id"] for item in input_refs],
         "base_decision_id": next(
         "base_decision_id": next(
             (
             (
@@ -189,10 +207,8 @@ def test_phase_policy_rejects_workspace_contract_without_capability_write_scope(
     guard = PhasePolicyGuard()
     guard = PhasePolicyGuard()
     ledger = SimpleNamespace(root_task_id="root")
     ledger = SimpleNamespace(root_task_id="root")
     parent = SimpleNamespace(
     parent = SimpleNamespace(
-        task_id="portfolio",
-        current_spec=SimpleNamespace(
-            context_refs=("script-build://task-kinds/candidate-portfolio",)
-        ),
+        task_id="compose",
+        current_spec=SimpleNamespace(context_refs=("script-build://task-kinds/compose",)),
     )
     )
     invalid = ScriptTaskContractV1.from_payload(_contract("structure"))
     invalid = ScriptTaskContractV1.from_payload(_contract("structure"))
     with pytest.raises(TaskContractError, match="WRITE_SCOPE_VIOLATION"):
     with pytest.raises(TaskContractError, match="WRITE_SCOPE_VIOLATION"):

+ 16 - 7
script_build_host/tests/test_phase_two_real_runner_e2e.py

@@ -161,6 +161,21 @@ def _contract(
             None,
             None,
         )
         )
         assert base_decision_id is not None
         assert base_decision_id is not None
+    criterion = (
+        {
+            "client_key": f"{task_kind}-closed",
+            "criterion_type": "evidence_nonempty",
+            "params": {"minimum_count": 1},
+            "hard": True,
+        }
+        if task_kind.endswith("-retrieval")
+        else {
+            "client_key": f"{task_kind}-closed",
+            "criterion_type": "custom",
+            "description": "the immutable output is concrete and causally closed",
+            "hard": True,
+        }
+    )
     return {
     return {
         "task_kind": task_kind,
         "task_kind": task_kind,
         "scope_selector": {
         "scope_selector": {
@@ -178,13 +193,7 @@ def _contract(
         "input_decision_ids": input_decision_ids,
         "input_decision_ids": input_decision_ids,
         "base_decision_id": base_decision_id,
         "base_decision_id": base_decision_id,
         "gap_key": None,
         "gap_key": None,
-        "criteria": [
-            {
-                "client_key": f"{task_kind}-closed",
-                "description": "the immutable output is concrete and causally closed",
-                "hard": True,
-            }
-        ],
+        "criteria": [criterion],
         "goal_ids": list(goal_ids or ()),
         "goal_ids": list(goal_ids or ()),
         "supersedes_decision_ids": list(supersedes_decision_ids),
         "supersedes_decision_ids": list(supersedes_decision_ids),
         "comparison_decision_ids": [str(item["decision_id"]) for item in comparison_decision_refs],
         "comparison_decision_ids": [str(item["decision_id"]) for item in comparison_decision_refs],

+ 16 - 7
script_build_host/tests/test_phase_two_sql_flow.py

@@ -93,6 +93,21 @@ def _contract(
         intent = "replace"
         intent = "replace"
     else:
     else:
         intent = "explore"
         intent = "explore"
+    criterion = (
+        {
+            "client_key": "closed",
+            "criterion_type": "evidence_nonempty",
+            "params": {"minimum_count": 1},
+            "hard": True,
+        }
+        if kind is ScriptTaskKind.DECODE_RETRIEVAL
+        else {
+            "client_key": "closed",
+            "criterion_type": "custom",
+            "description": "the frozen increment is concrete and independently verifiable",
+            "hard": True,
+        }
+    )
     payload: dict[str, Any] = {
     payload: dict[str, Any] = {
         "task_kind": kind.value,
         "task_kind": kind.value,
         "scope_selector": {
         "scope_selector": {
@@ -112,13 +127,7 @@ def _contract(
             None,
             None,
         ),
         ),
         "gap_key": None,
         "gap_key": None,
-        "criteria": [
-            {
-                "client_key": "closed",
-                "description": "the frozen increment is concrete and independently verifiable",
-                "hard": True,
-            }
-        ],
+        "criteria": [criterion],
         "goal_ids": (
         "goal_ids": (
             []
             []
             if kind in {ScriptTaskKind.DIRECTION, ScriptTaskKind.DECODE_RETRIEVAL}
             if kind in {ScriptTaskKind.DIRECTION, ScriptTaskKind.DECODE_RETRIEVAL}

+ 103 - 1
script_build_host/tests/test_retrieval_adapters.py

@@ -7,6 +7,7 @@ from types import SimpleNamespace
 import httpx
 import httpx
 import pytest
 import pytest
 
 
+from script_build_host.adapters.legacy_retrieval import LegacyExternalRetrievalAdapter
 from script_build_host.adapters.retrieval import (
 from script_build_host.adapters.retrieval import (
     ExternalRetrievalAdapter,
     ExternalRetrievalAdapter,
     FileDecodeRetrievalAdapter,
     FileDecodeRetrievalAdapter,
@@ -17,7 +18,7 @@ from script_build_host.adapters.retrieval import (
     SafeImageAdapter,
     SafeImageAdapter,
 )
 )
 from script_build_host.adapters.uploaded_topic import SqlUploadedTopicGateway
 from script_build_host.adapters.uploaded_topic import SqlUploadedTopicGateway
-from script_build_host.domain.errors import ProtocolViolation, UnsafeOutboundTarget
+from script_build_host.domain.errors import ProtocolViolation, UnsafeOutboundTarget, UpstreamFailure
 from script_build_host.infrastructure.outbound import OutboundPolicy
 from script_build_host.infrastructure.outbound import OutboundPolicy
 from script_build_host.repositories.legacy_input import LegacySqlAlchemyInputReader
 from script_build_host.repositories.legacy_input import LegacySqlAlchemyInputReader
 
 
@@ -88,6 +89,107 @@ async def test_http_retrieval_revalidates_redirect_and_freezes_response_metadata
             await safe.request("GET", "https://api.example.com/large")
             await safe.request("GET", "https://api.example.com/large")
 
 
 
 
+@pytest.mark.asyncio
+async def test_safe_http_retries_transport_and_classifies_auth_without_retry() -> None:
+    calls = 0
+
+    def flaky(_request: httpx.Request) -> httpx.Response:
+        nonlocal calls
+        calls += 1
+        if calls == 1:
+            raise httpx.RemoteProtocolError("disconnected")
+        return httpx.Response(200, json={"ok": True})
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(flaky)) as client:
+        safe = SafeHttpClient(
+            client,
+            OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
+            retry_backoff_seconds=0,
+        )
+        assert await safe.json(
+            "POST",
+            "https://api.example.com/search",
+            retryable=True,
+            provider="xhs",
+            operation="search",
+        ) == {"ok": True}
+    assert calls == 2
+
+    calls = 0
+
+    def unauthorized(_request: httpx.Request) -> httpx.Response:
+        nonlocal calls
+        calls += 1
+        return httpx.Response(401)
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(unauthorized)) as client:
+        safe = SafeHttpClient(
+            client,
+            OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
+            retry_backoff_seconds=0,
+        )
+        with pytest.raises(UpstreamFailure) as raised:
+            await safe.json(
+                "POST",
+                "https://api.example.com/search",
+                retryable=True,
+                provider="xhs",
+                operation="search",
+            )
+    assert raised.value.code == "UPSTREAM_AUTH_REJECTED"
+    assert calls == 1
+
+
+@pytest.mark.asyncio
+async def test_xhs_detail_failure_keeps_partial_search_cards() -> None:
+    def handler(request: httpx.Request) -> httpx.Response:
+        if request.url.path == "/search":
+            return httpx.Response(
+                200,
+                json={
+                    "code": 0,
+                    "data": {
+                        "data": [
+                            {"id": f"note-{index}", "title": f"card-{index}"}
+                            for index in range(5)
+                        ],
+                        "has_more": False,
+                    },
+                },
+            )
+        identifier = request.read().decode("utf-8")
+        if "note-2" in identifier:
+            raise httpx.RemoteProtocolError("detail disconnected")
+        return httpx.Response(
+            200,
+            json={
+                "code": 0,
+                "data": {"data": {"id": identifier, "title": "complete detail"}},
+            },
+        )
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
+        safe = SafeHttpClient(
+            client,
+            OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
+            retry_backoff_seconds=0,
+        )
+        result = await LegacyExternalRetrievalAdapter(
+            xhs_search_endpoint="https://api.example.com/search",
+            xhs_detail_endpoint="https://api.example.com/detail",
+            zhihu_search_endpoint="https://api.example.com/zhihu",
+            http=safe,
+        ).retrieve(
+            query={"keyword": "case", "platform_channel": "xhs", "max_count": 5},
+            snapshot=SimpleNamespace(),
+        )
+
+    assert len(json.loads(result.summary)) == 5
+    assert result.metadata["partial_result"] is True
+    assert result.metadata["detail_failures"] == 1
+    assert any("search cards retained" in item for item in result.limitations)
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_external_response_secrets_are_redacted_before_evidence_summary() -> None:
 async def test_external_response_secrets_are_redacted_before_evidence_summary() -> None:
     def handler(_request: httpx.Request) -> httpx.Response:
     def handler(_request: httpx.Request) -> httpx.Response:

+ 42 - 0
script_build_host/tests/test_task_capabilities.py

@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from agent.core.presets import AGENT_PRESETS
+
+from script_build_host.agents.presets import register_script_presets
+from script_build_host.domain.phase_protocol import allowed_child_kinds
+from script_build_host.domain.task_capabilities import task_capabilities, task_capability
+from script_build_host.domain.task_contracts import ScriptTaskKind, output_schema_for
+
+
+def test_capability_catalog_closes_worker_tool_schema_and_topology_contracts() -> None:
+    register_script_presets()
+    capabilities = task_capabilities()
+    assert {item.task_kind for item in capabilities} == set(ScriptTaskKind)
+
+    for capability in capabilities:
+        preset = AGENT_PRESETS[capability.worker_preset]
+        assert set(preset.allowed_tools) == set(capability.allowed_tools)
+        assert output_schema_for(capability.task_kind) in capability.output_schemas
+        assert allowed_child_kinds(
+            phase=2,
+            parent_kind=capability.task_kind,
+        ) == capability.child_kinds
+
+
+def test_phase_root_topology_is_machine_readable_and_fail_closed() -> None:
+    assert allowed_child_kinds(phase=1, parent_kind=None) == frozenset(
+        {ScriptTaskKind.DIRECTION}
+    )
+    assert allowed_child_kinds(phase=2, parent_kind=None) == frozenset(
+        {ScriptTaskKind.CANDIDATE_PORTFOLIO}
+    )
+    assert allowed_child_kinds(phase=3, parent_kind=None) == frozenset(
+        {ScriptTaskKind.ROOT_DELIVERY}
+    )
+    assert not allowed_child_kinds(phase=99, parent_kind=None)
+
+
+def test_catalog_keeps_element_write_ownership_narrow() -> None:
+    capability = task_capability(ScriptTaskKind.ELEMENT_SET)
+    assert capability.write_scope == ("script-build://writes/elements",)
+    assert "save_script_paragraphs" not in capability.allowed_tools

+ 37 - 1
script_build_host/tests/test_task_policy.py

@@ -1,7 +1,13 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
+import pytest
+
 from script_build_host.domain.artifacts import DirectionConstraint
 from script_build_host.domain.artifacts import DirectionConstraint
-from script_build_host.domain.task_contracts import PlannerCriterionInput, ScriptTaskKind
+from script_build_host.domain.task_contracts import (
+    PlannerCriterionInput,
+    ScriptTaskKind,
+    TaskContractError,
+)
 from script_build_host.domain.task_policy import TaskPolicyCatalog
 from script_build_host.domain.task_policy import TaskPolicyCatalog
 
 
 
 
@@ -32,3 +38,33 @@ def test_task_policy_injects_protected_kind_and_direction_constraint_criteria()
     assert criteria[1].hard is True
     assert criteria[1].hard is True
     assert criteria[2].description == "the opening is memorable"
     assert criteria[2].description == "the opening is memorable"
     assert criteria[2].hard is False
     assert criteria[2].hard is False
+
+
+def test_retrieval_criteria_are_typed_and_host_rendered() -> None:
+    criteria = TaskPolicyCatalog().criteria(
+        task_id="external",
+        task_kind=ScriptTaskKind.EXTERNAL_RETRIEVAL,
+        business=(
+            PlannerCriterionInput(
+                client_key="coverage",
+                criterion_type="concept_coverage",
+                params={"concepts": ["占器械", "玩手机", "双重标准"], "minimum_matches": 2},
+            ),
+        ),
+    )
+
+    assert criteria[1].description == "Evidence covers at least 2 of: 占器械, 玩手机, 双重标准"
+
+
+def test_retrieval_rejects_free_text_synthesis_contract_before_dispatch() -> None:
+    with pytest.raises(TaskContractError, match="TASK_CAPABILITY_MISMATCH"):
+        TaskPolicyCatalog().criteria(
+            task_id="external",
+            task_kind=ScriptTaskKind.EXTERNAL_RETRIEVAL,
+            business=(
+                PlannerCriterionInput(
+                    client_key="persona-analysis",
+                    description="write a persona analysis and element mapping document",
+                ),
+            ),
+        )