Просмотр исходного кода

feat(script-build): continue root delivery through phase three

SamLee 2 дней назад
Родитель
Сommit
269e97e97d

+ 32 - 0
script_build_host/src/script_build_host/agents/presets.py

@@ -15,6 +15,8 @@ from .prompts import (
     PORTFOLIO_WORKER_PROMPT,
     PORTFOLIO_WORKER_PROMPT,
     RETRIEVAL_VALIDATOR_PROMPT,
     RETRIEVAL_VALIDATOR_PROMPT,
     RETRIEVAL_WORKER_PROMPT,
     RETRIEVAL_WORKER_PROMPT,
+    ROOT_VALIDATOR_PROMPT,
+    ROOT_WORKER_PROMPT,
     SCRIPT_CANDIDATE_VALIDATOR_PROMPT,
     SCRIPT_CANDIDATE_VALIDATOR_PROMPT,
     STRUCTURE_WORKER_PROMPT,
     STRUCTURE_WORKER_PROMPT,
 )
 )
@@ -178,6 +180,18 @@ def register_script_presets() -> None:
             prompt=PORTFOLIO_WORKER_PROMPT,
             prompt=PORTFOLIO_WORKER_PROMPT,
         ),
         ),
     )
     )
+    register_preset(
+        "script_root_worker",
+        _worker(
+            [
+                "read_input_snapshot",
+                "read_accepted_portfolio",
+                "legacy_projection_dry_run",
+                "save_root_delivery_manifest",
+            ],
+            prompt=ROOT_WORKER_PROMPT,
+        ),
+    )
     validator_tools = [
     validator_tools = [
         "read_input_snapshot",
         "read_input_snapshot",
         "view_frozen_images",
         "view_frozen_images",
@@ -219,6 +233,24 @@ def register_script_presets() -> None:
             system_prompt=SCRIPT_CANDIDATE_VALIDATOR_PROMPT,
             system_prompt=SCRIPT_CANDIDATE_VALIDATOR_PROMPT,
         ),
         ),
     )
     )
+    register_preset(
+        "script_root_validator",
+        AgentPreset(
+            role=AgentRole.VALIDATOR,
+            allowed_tools=[
+                "read_input_snapshot",
+                "read_accepted_portfolio",
+                "query_validation_evidence",
+                "deterministic_precheck",
+                "submit_validation",
+            ],
+            denied_tools=validator_denied,
+            max_iterations=25,
+            temperature=0.0,
+            skills=[],
+            system_prompt=ROOT_VALIDATOR_PROMPT,
+        ),
+    )
 
 
 
 
 __all__ = ["register_script_presets"]
 __all__ = ["register_script_presets"]

+ 19 - 1
script_build_host/src/script_build_host/agents/prompt_catalog.py

@@ -15,6 +15,7 @@ PHASE_TWO_PRESETS = frozenset(
         "script_candidate_portfolio_worker",
         "script_candidate_portfolio_worker",
     }
     }
 )
 )
+PHASE_THREE_PRESETS = frozenset({"script_root_worker", "script_root_validator"})
 
 
 
 
 def script_prompt_requests() -> tuple[PromptRequest, ...]:
 def script_prompt_requests() -> tuple[PromptRequest, ...]:
@@ -31,4 +32,21 @@ def script_prompt_requests() -> tuple[PromptRequest, ...]:
     )
     )
 
 
 
 
-__all__ = ["PHASE_TWO_PRESETS", "script_prompt_requests"]
+def phase_three_prompt_requests() -> tuple[PromptRequest, ...]:
+    return tuple(
+        PromptRequest(
+            biz_type=str(item["biz_type"]),
+            filename=str(item["source_id"]),
+            preset=str(item["preset"]),
+            role=str(item["role"]),
+        )
+        for item in prompt_contracts.phase_three_prompt_manifest()
+    )
+
+
+__all__ = [
+    "PHASE_THREE_PRESETS",
+    "PHASE_TWO_PRESETS",
+    "phase_three_prompt_requests",
+    "script_prompt_requests",
+]

+ 6 - 0
script_build_host/src/script_build_host/agents/prompts/__init__.py

@@ -10,9 +10,12 @@ from .contracts import (
     PORTFOLIO_WORKER_PROMPT,
     PORTFOLIO_WORKER_PROMPT,
     RETRIEVAL_VALIDATOR_PROMPT,
     RETRIEVAL_VALIDATOR_PROMPT,
     RETRIEVAL_WORKER_PROMPT,
     RETRIEVAL_WORKER_PROMPT,
+    ROOT_VALIDATOR_PROMPT,
+    ROOT_WORKER_PROMPT,
     SCRIPT_CANDIDATE_VALIDATOR_PROMPT,
     SCRIPT_CANDIDATE_VALIDATOR_PROMPT,
     STRUCTURE_WORKER_PROMPT,
     STRUCTURE_WORKER_PROMPT,
     phase_one_prompt_manifest,
     phase_one_prompt_manifest,
+    phase_three_prompt_manifest,
     script_build_prompt_manifest,
     script_build_prompt_manifest,
 )
 )
 
 
@@ -26,8 +29,11 @@ __all__ = [
     "PORTFOLIO_WORKER_PROMPT",
     "PORTFOLIO_WORKER_PROMPT",
     "RETRIEVAL_VALIDATOR_PROMPT",
     "RETRIEVAL_VALIDATOR_PROMPT",
     "RETRIEVAL_WORKER_PROMPT",
     "RETRIEVAL_WORKER_PROMPT",
+    "ROOT_VALIDATOR_PROMPT",
+    "ROOT_WORKER_PROMPT",
     "SCRIPT_CANDIDATE_VALIDATOR_PROMPT",
     "SCRIPT_CANDIDATE_VALIDATOR_PROMPT",
     "STRUCTURE_WORKER_PROMPT",
     "STRUCTURE_WORKER_PROMPT",
     "phase_one_prompt_manifest",
     "phase_one_prompt_manifest",
+    "phase_three_prompt_manifest",
     "script_build_prompt_manifest",
     "script_build_prompt_manifest",
 ]
 ]

+ 27 - 0
script_build_host/src/script_build_host/agents/prompts/contracts.py

@@ -25,6 +25,8 @@ ELEMENT_SET_WORKER_PROMPT = _read("element_set_worker.md")
 COMPARISON_WORKER_PROMPT = _read("comparison_worker.md")
 COMPARISON_WORKER_PROMPT = _read("comparison_worker.md")
 COMPOSE_WORKER_PROMPT = _read("compose_worker.md")
 COMPOSE_WORKER_PROMPT = _read("compose_worker.md")
 PORTFOLIO_WORKER_PROMPT = _read("portfolio_worker.md")
 PORTFOLIO_WORKER_PROMPT = _read("portfolio_worker.md")
+ROOT_WORKER_PROMPT = _read("root_worker.md")
+ROOT_VALIDATOR_PROMPT = _read("root_validator.md")
 
 
 
 
 def script_build_prompt_manifest() -> tuple[dict[str, object], ...]:
 def script_build_prompt_manifest() -> tuple[dict[str, object], ...]:
@@ -126,6 +128,31 @@ def script_build_prompt_manifest() -> tuple[dict[str, object], ...]:
     )
     )
 
 
 
 
+def phase_three_prompt_manifest() -> tuple[dict[str, object], ...]:
+    values = {
+        "script_root_worker": ("worker", "root_worker.md", ROOT_WORKER_PROMPT, 1),
+        "script_root_validator": (
+            "validator",
+            "root_validator.md",
+            ROOT_VALIDATOR_PROMPT,
+            1,
+        ),
+    }
+    return tuple(
+        {
+            "biz_type": preset,
+            "preset": preset,
+            "role": role,
+            "source": "file",
+            "source_id": filename,
+            "version": version,
+            "content": content,
+            "content_sha256": f"sha256:{sha256(content.encode('utf-8')).hexdigest()}",
+        }
+        for preset, (role, filename, content, version) in values.items()
+    )
+
+
 def phase_one_prompt_manifest() -> tuple[dict[str, object], ...]:
 def phase_one_prompt_manifest() -> tuple[dict[str, object], ...]:
     """Compatibility view used by older callers and Phase1 fixture tests."""
     """Compatibility view used by older callers and Phase1 fixture tests."""
 
 

+ 8 - 0
script_build_host/src/script_build_host/agents/prompts/root_validator.md

@@ -0,0 +1,8 @@
+# Script Root Delivery Validator
+
+Independently validate the frozen RootDeliveryManifest and the exact accepted closure it pins.
+
+Run deterministic prechecks first. Verify schema/digests, Direction/Portfolio/StructuredScript
+closure, topic and persona coverage, evidence, narrative structure, placeholder/meta-description
+absence, and legacy projection readability. A passed result requires an empty blocking-defect
+set. Report criterion-level evidence and submit Validation; never edit or publish content.

+ 11 - 0
script_build_host/src/script_build_host/agents/prompts/root_worker.md

@@ -0,0 +1,11 @@
+# Script Root Delivery Worker
+
+You close one immutable accepted delivery; you do not rewrite its script.
+
+- Read the frozen InputSnapshot and the accepted Direction/Portfolio/StructuredScript closure.
+- Run the legacy projection dry-run with a concise build summary.
+- Save exactly one RootDeliveryManifest. Report any genuine blocking defect; never hide it.
+- Submit the Attempt after the manifest is frozen.
+
+Do not create or edit Paragraphs, Elements, Links, evidence, or candidate artifacts. Do not
+select a different candidate, scan “latest” state, or invent artifact references.

+ 13 - 3
script_build_host/src/script_build_host/agents/validation.py

@@ -50,6 +50,7 @@ PHASE_TWO_KINDS = frozenset(
         "candidate-portfolio",
         "candidate-portfolio",
     }
     }
 )
 )
+ROOT_DELIVERY_KIND = "root-delivery"
 _ARTIFACT_KIND_BY_TASK_KIND = {
 _ARTIFACT_KIND_BY_TASK_KIND = {
     "direction": ArtifactKind.DIRECTION,
     "direction": ArtifactKind.DIRECTION,
     "structure": ArtifactKind.STRUCTURE,
     "structure": ArtifactKind.STRUCTURE,
@@ -58,6 +59,7 @@ _ARTIFACT_KIND_BY_TASK_KIND = {
     "compare": ArtifactKind.COMPARISON,
     "compare": ArtifactKind.COMPARISON,
     "compose": ArtifactKind.STRUCTURED_SCRIPT,
     "compose": ArtifactKind.STRUCTURED_SCRIPT,
     "candidate-portfolio": ArtifactKind.CANDIDATE_PORTFOLIO,
     "candidate-portfolio": ArtifactKind.CANDIDATE_PORTFOLIO,
+    ROOT_DELIVERY_KIND: ArtifactKind.ROOT_DELIVERY_MANIFEST,
 }
 }
 _DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
 _DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
 
 
@@ -80,6 +82,8 @@ class ScriptBuildValidationPolicy:
         kind = task_kind(context.task.current_spec.context_refs)
         kind = task_kind(context.task.current_spec.context_refs)
         if kind in RETRIEVAL_KINDS:
         if kind in RETRIEVAL_KINDS:
             preset = "script_retrieval_validator"
             preset = "script_retrieval_validator"
+        elif kind == ROOT_DELIVERY_KIND:
+            preset = "script_root_validator"
         elif kind == "direction" or kind in PHASE_TWO_KINDS:
         elif kind == "direction" or kind in PHASE_TWO_KINDS:
             preset = "script_candidate_validator"
             preset = "script_candidate_validator"
         else:
         else:
@@ -141,9 +145,13 @@ def precheck_snapshot(context: ValidationContext) -> list[DeterministicRuleResul
     results.append(
     results.append(
         DeterministicRuleResult(
         DeterministicRuleResult(
             rule_id=(
             rule_id=(
-                "phase-two-artifact-shape"
-                if kind in PHASE_TWO_KINDS
-                else "phase-one-artifact-shape"
+                "root-delivery-artifact-shape"
+                if kind == ROOT_DELIVERY_KIND
+                else (
+                    "phase-two-artifact-shape"
+                    if kind in PHASE_TWO_KINDS
+                    else "phase-one-artifact-shape"
+                )
             ),
             ),
             verdict=ValidationVerdict.PASSED if shape_ok else ValidationVerdict.FAILED,
             verdict=ValidationVerdict.PASSED if shape_ok else ValidationVerdict.FAILED,
             reason=(
             reason=(
@@ -163,6 +171,8 @@ def validation_layer(kind: str) -> str:
         return "global"
         return "global"
     if kind == "candidate-portfolio":
     if kind == "candidate-portfolio":
         return "governance"
         return "governance"
+    if kind == ROOT_DELIVERY_KIND:
+        return "root-delivery"
     if kind in PHASE_TWO_KINDS:
     if kind in PHASE_TWO_KINDS:
         return "local"
         return "local"
     if kind in RETRIEVAL_KINDS or kind == "direction":
     if kind in RETRIEVAL_KINDS or kind == "direction":

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

@@ -127,6 +127,7 @@ class ScriptInputSnapshotService:
         *,
         *,
         prompt_requests: tuple[PromptRequest, ...],
         prompt_requests: tuple[PromptRequest, ...],
         model_manifest: dict[str, Any] | None = None,
         model_manifest: dict[str, Any] | None = None,
+        version: int = 2,
     ) -> ScriptBuildInputSnapshotV1:
     ) -> ScriptBuildInputSnapshotV1:
         """Append a v2 snapshot containing phase-two runtime inputs.
         """Append a v2 snapshot containing phase-two runtime inputs.
 
 
@@ -163,7 +164,7 @@ class ScriptInputSnapshotService:
             parent_snapshot_sha256=snapshot.canonical_sha256,
             parent_snapshot_sha256=snapshot.canonical_sha256,
             business_input_sha256=business_digest,
             business_input_sha256=business_digest,
         )
         )
-        return await self._snapshots.freeze(extended, version=2)
+        return await self._snapshots.freeze(extended, version=version)
 
 
 
 
 def _redacted_rows(
 def _redacted_rows(

+ 74 - 0
script_build_host/src/script_build_host/application/mission_factory.py

@@ -205,6 +205,80 @@ class ScriptMissionFactory:
             name=f"Script build {binding.script_build_id} phase two",
             name=f"Script build {binding.script_build_id} phase two",
         )
         )
 
 
+    def build_phase_three_policy(self, snapshot: ScriptBuildInputSnapshotV1) -> str:
+        frozen = _frozen_prompt(snapshot, "script_planner") or ""
+        policy = json.dumps(
+            {
+                "policy": "script-build-phase-three/v1",
+                "phase": 3,
+                "input_snapshot_ref": f"script-build://inputs/{snapshot.snapshot_id}",
+                "input_sha256": snapshot.canonical_sha256,
+                "instruction": (
+                    "Deliver only the accepted Direction and CandidatePortfolio closure. "
+                    "Dispatch the Host-frozen root-delivery contract, require independent "
+                    "validation, then explicitly ACCEPT Root. Do not rewrite the script."
+                ),
+            },
+            ensure_ascii=False,
+            sort_keys=True,
+        )
+        return f"{frozen}\n\n{policy}".strip()
+
+    def build_phase_three_message(
+        self,
+        binding: MissionBinding,
+        snapshot: ScriptBuildInputSnapshotV1,
+        *,
+        direction_ref: str,
+        portfolio_ref: str,
+        structured_script_ref: str,
+    ) -> str:
+        return json.dumps(
+            {
+                "mission": "close_root_delivery",
+                "script_build_id": binding.script_build_id,
+                "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,
+            },
+            ensure_ascii=False,
+            sort_keys=True,
+        )
+
+    def build_phase_three_run_config(
+        self,
+        binding: MissionBinding,
+        snapshot: ScriptBuildInputSnapshotV1,
+    ) -> RunConfig:
+        planner_config = _planner_model_config(snapshot.model_manifest)
+        return RunConfig(
+            agent_type="script_planner",
+            model=planner_config["model"],
+            temperature=planner_config["temperature"],
+            max_iterations=planner_config["max_iterations"],
+            completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
+            trace_id=binding.root_trace_id,
+            root_task_spec=None,
+            tool_groups=None,
+            parallel_tool_execution=False,
+            enable_memory=False,
+            enable_research_flow=False,
+            knowledge=KnowledgeConfig(
+                enable_extraction=False,
+                enable_completion_extraction=False,
+                enable_injection=False,
+            ),
+            context={
+                "script_build_id": binding.script_build_id,
+                "input_snapshot_id": snapshot.snapshot_id,
+                "phase": 3,
+            },
+            name=f"Script build {binding.script_build_id} phase three",
+        )
+
 
 
 def _planner_model_config(manifest: dict[str, object]) -> dict[str, object]:
 def _planner_model_config(manifest: dict[str, object]) -> dict[str, object]:
     raw_presets = manifest.get("presets", manifest)
     raw_presets = manifest.get("presets", manifest)

+ 583 - 0
script_build_host/src/script_build_host/application/phase_three.py

@@ -0,0 +1,583 @@
+"""Idempotent Phase2-to-Phase3 Root continuation."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import AsyncIterator, Callable
+from dataclasses import dataclass
+from typing import Any, cast
+
+from agent.orchestration import DecisionAction, OperationStatus, TaskStatus, ValidationVerdict
+
+from script_build_host.application.input_snapshot_service import ScriptInputSnapshotService
+from script_build_host.application.mission_factory import ScriptMissionFactory
+from script_build_host.domain.artifacts import ArtifactState, ScriptDirectionArtifactV1
+from script_build_host.domain.canonical_json import canonical_sha256
+from script_build_host.domain.errors import (
+    MissionRecoveryRequired,
+    PhaseThreeBoundaryNotReady,
+    ProtocolViolation,
+)
+from script_build_host.domain.phase_two_artifacts import CandidatePortfolioArtifactV1
+from script_build_host.domain.ports import (
+    BuildAuthorizer,
+    LegacyBuildStateRepository,
+    MissionBindingRepository,
+    PromptRequest,
+    ScriptBusinessArtifactRepository,
+)
+from script_build_host.domain.records import BuildStatus, MissionOwnerToken, Principal
+from script_build_host.domain.task_contracts import (
+    AcceptedDecisionRef,
+    ScriptCriterion,
+    ScriptIntentClass,
+    ScriptTaskBudget,
+    ScriptTaskContractV1,
+    ScriptTaskKind,
+)
+from script_build_host.domain.task_refs import TASK_KIND_PREFIX, task_kind
+from script_build_host.infrastructure.ownership import OwnerLease
+
+PHASE_TWO_CANDIDATE_PORTFOLIO_READY = "PHASE_TWO_CANDIDATE_PORTFOLIO_READY"
+PHASE_THREE_ROOT_ACCEPTED = "PHASE_THREE_ROOT_ACCEPTED"
+PHASE_THREE_POLICY = "script-build-phase-three/v1"
+
+
+@dataclass(frozen=True, slots=True)
+class PhaseThreeAdvanceResult:
+    script_build_id: int
+    status: BuildStatus
+    root_trace_id: str
+    input_snapshot_id: str
+
+
+@dataclass(frozen=True, slots=True)
+class _AcceptedRootClosure:
+    direction: AcceptedDecisionRef
+    portfolio: AcceptedDecisionRef
+    structured_script_ref: str
+
+
+class PhaseThreeContinuationService:
+    def __init__(
+        self,
+        *,
+        runner: Any,
+        coordinator: Any,
+        factory: ScriptMissionFactory,
+        input_snapshots: ScriptInputSnapshotService,
+        bindings: MissionBindingRepository,
+        artifacts: ScriptBusinessArtifactRepository,
+        legacy_state: LegacyBuildStateRepository,
+        authorizer: BuildAuthorizer,
+        contracts: Any,
+        owner_lease_factory: Callable[[int], OwnerLease],
+        phase_two_boundary_verifier: Any,
+        prompt_requests: tuple[PromptRequest, ...],
+        model_manifest: dict[str, Any] | None = None,
+        fenced_command_gate: Any | None = None,
+        owner_registry: Any | None = None,
+    ) -> None:
+        self.runner = runner
+        self.coordinator = coordinator
+        self.factory = factory
+        self.input_snapshots = input_snapshots
+        self.bindings = bindings
+        self.artifacts = artifacts
+        self.legacy_state = legacy_state
+        self.authorizer = authorizer
+        self.contracts = contracts
+        self.owner_lease_factory = owner_lease_factory
+        self.phase_two_boundary_verifier = phase_two_boundary_verifier
+        self.prompt_requests = prompt_requests
+        self.model_manifest = dict(model_manifest or {})
+        self.fenced_command_gate = fenced_command_gate
+        self.owner_registry = owner_registry
+        self._locks: dict[int, asyncio.Lock] = {}
+        self._runs: dict[int, asyncio.Task[None]] = {}
+
+    async def advance(self, script_build_id: int, principal: Principal) -> PhaseThreeAdvanceResult:
+        await self.authorizer.require_access(principal, script_build_id)
+        lock = self._locks.setdefault(script_build_id, asyncio.Lock())
+        async with lock:
+            binding = await self.bindings.get_by_build(script_build_id)
+            active = self._runs.get(script_build_id)
+            if active is not None and not active.done():
+                return PhaseThreeAdvanceResult(
+                    script_build_id,
+                    BuildStatus.RUNNING,
+                    binding.root_trace_id,
+                    str(binding.input_snapshot_id),
+                )
+            completion = await self.coordinator.root_completion(binding.root_trace_id)
+            status = await self.legacy_state.get_status(script_build_id)
+            if completion.get("status") == TaskStatus.COMPLETED.value:
+                return PhaseThreeAdvanceResult(
+                    script_build_id,
+                    status,
+                    binding.root_trace_id,
+                    str(binding.input_snapshot_id),
+                )
+            if status is not BuildStatus.PARTIAL:
+                raise PhaseThreeBoundaryNotReady("phase-three advance requires a partial build")
+            if (
+                completion.get("status") != TaskStatus.BLOCKED.value
+                or completion.get("blocked_reason") != PHASE_TWO_CANDIDATE_PORTFOLIO_READY
+            ):
+                raise PhaseThreeBoundaryNotReady(
+                    "phase-three advance requires the phase-two portfolio checkpoint"
+                )
+            if self.phase_two_boundary_verifier is None:
+                raise PhaseThreeBoundaryNotReady("phase-two closure verifier is unavailable")
+            await self.phase_two_boundary_verifier.verify_checkpoint(
+                script_build_id=script_build_id,
+                root_trace_id=binding.root_trace_id,
+                input_snapshot_id=str(binding.input_snapshot_id),
+            )
+            ledger = await self.coordinator.task_store.load(binding.root_trace_id)
+            if any(
+                item.status
+                in {
+                    OperationStatus.PENDING,
+                    OperationStatus.RUNNING,
+                    OperationStatus.STOP_REQUESTED,
+                }
+                for item in ledger.operations.values()
+            ):
+                raise PhaseThreeBoundaryNotReady("phase two still has active operations")
+            lease = self.owner_lease_factory(script_build_id)
+            token = await lease.acquire(script_build_id)
+            task = asyncio.create_task(
+                self._run_owned(script_build_id, token, lease),
+                name=f"script-build:{script_build_id}:phase-three",
+            )
+            self._runs[script_build_id] = task
+            task.add_done_callback(lambda done: self._discard(script_build_id, done))
+            return PhaseThreeAdvanceResult(
+                script_build_id,
+                BuildStatus.RUNNING,
+                binding.root_trace_id,
+                str(binding.input_snapshot_id),
+            )
+
+    async def _run_owned(
+        self, script_build_id: int, token: MissionOwnerToken, lease: OwnerLease
+    ) -> None:
+        if self.owner_registry is not None:
+            self.owner_registry.register_owner_token(token)
+        try:
+            await self.run(script_build_id, owner_token=token)
+        finally:
+            if self.owner_registry is not None:
+                self.owner_registry.unregister_owner_token(token)
+            await lease.release()
+
+    async def run(self, script_build_id: int, *, owner_token: MissionOwnerToken) -> None:
+        await self._verify_owner(owner_token)
+        binding = await self.bindings.get_by_build(script_build_id)
+        if binding.root_trace_id != owner_token.root_trace_id:
+            raise PhaseThreeBoundaryNotReady("owner token is bound to another Root")
+        status = await self.legacy_state.get_status(script_build_id)
+        if status is not BuildStatus.PARTIAL:
+            raise PhaseThreeBoundaryNotReady(f"cannot continue a {status.value} build")
+        snapshot = await self.input_snapshots.get(
+            str(binding.input_snapshot_id), script_build_id=script_build_id
+        )
+        ledger = await self.coordinator.task_store.load(binding.root_trace_id)
+        root = ledger.tasks[ledger.root_task_id]
+        if root.status is TaskStatus.COMPLETED:
+            return
+        if root.status is TaskStatus.BLOCKED:
+            if root.blocked_reason != PHASE_TWO_CANDIDATE_PORTFOLIO_READY:
+                raise PhaseThreeBoundaryNotReady("Root is not at the phase-two checkpoint")
+            if self.phase_two_boundary_verifier is None:
+                raise PhaseThreeBoundaryNotReady("phase-two closure verifier is unavailable")
+            await self.phase_two_boundary_verifier.verify_checkpoint(
+                script_build_id=script_build_id,
+                root_trace_id=binding.root_trace_id,
+                input_snapshot_id=str(binding.input_snapshot_id),
+            )
+        active_operations = [
+            item
+            for item in ledger.operations.values()
+            if item.status
+            in {OperationStatus.PENDING, OperationStatus.RUNNING, OperationStatus.STOP_REQUESTED}
+        ]
+        if active_operations:
+            raise PhaseThreeBoundaryNotReady("phase two still has active operations")
+        closure = await self._accepted_root_closure(script_build_id, ledger)
+        await self._verify_owner(owner_token)
+        snapshot, binding = await self._ensure_snapshot(snapshot, binding)
+        root_contract = await self._execute_fenced(
+            owner_token,
+            lambda: self._freeze_root_contract(
+                binding.root_trace_id, root, closure, str(snapshot.snapshot_id)
+            ),
+        )
+        policy = self.factory.build_phase_three_policy(snapshot)
+        message = self.factory.build_phase_three_message(
+            binding,
+            snapshot,
+            direction_ref=closure.direction.artifact_ref.uri,
+            portfolio_ref=closure.portfolio.artifact_ref.uri,
+            structured_script_ref=closure.structured_script_ref,
+        )
+        iterator = await self._execute_fenced(
+            owner_token,
+            lambda: self._prepare_policy_messages(binding, snapshot, policy, message),
+        )
+        await self._verify_owner(owner_token)
+        ledger = await self.coordinator.task_store.load(binding.root_trace_id)
+        root = ledger.tasks[ledger.root_task_id]
+        if root.status is TaskStatus.BLOCKED:
+            await self._execute_fenced(
+                owner_token,
+                lambda: self.coordinator.decide_task(
+                    binding.root_trace_id,
+                    root.task_id,
+                    None,
+                    DecisionAction.UNBLOCK,
+                    {},
+                    f"phase-three-unblock:{script_build_id}",
+                ),
+            )
+            ledger = await self.coordinator.task_store.load(binding.root_trace_id)
+            root = ledger.tasks[ledger.root_task_id]
+        if task_kind(root.current_spec.context_refs) != ScriptTaskKind.ROOT_DELIVERY.value:
+            if root.status is not TaskStatus.NEEDS_REPLAN:
+                raise PhaseThreeBoundaryNotReady("Root cannot accept its delivery contract")
+            await self._execute_fenced(
+                owner_token,
+                lambda: self.coordinator.decide_task(
+                    binding.root_trace_id,
+                    root.task_id,
+                    None,
+                    DecisionAction.REVISE,
+                    {
+                        "reason": "enter the Host-frozen phase-three delivery contract",
+                        "objective": root.current_spec.objective,
+                        "acceptance_criteria": [
+                            {
+                                "criterion_id": item.criterion_id,
+                                "description": item.description,
+                                "hard": item.hard,
+                            }
+                            for item in root.current_spec.acceptance_criteria
+                        ],
+                        "context_refs": [
+                            f"{TASK_KIND_PREFIX}{ScriptTaskKind.ROOT_DELIVERY.value}",
+                            root_contract.uri,
+                            f"script-build://inputs/{snapshot.snapshot_id}",
+                        ],
+                    },
+                    f"phase-three-contract:{script_build_id}",
+                ),
+            )
+        await self.legacy_state.begin_phase(script_build_id)
+        await self._verify_owner(owner_token)
+        if iterator is None:
+            await self.runner.run_result(
+                messages=[], config=self.factory.build_phase_three_run_config(binding, snapshot)
+            )
+        else:
+            async for _ in iterator:
+                pass
+        completion = await self.coordinator.root_completion(binding.root_trace_id)
+        await self._verify_owner(owner_token)
+        if completion.get("status") != TaskStatus.COMPLETED.value:
+            raise ProtocolViolation("phase-three Root was not explicitly ACCEPTed")
+        await self.legacy_state.set_checkpoint(
+            script_build_id,
+            checkpoint_code=PHASE_THREE_ROOT_ACCEPTED,
+            summary="Root accepted one validated immutable delivery manifest.",
+            root_trace_id=binding.root_trace_id,
+        )
+
+    async def _verify_owner(self, token: MissionOwnerToken) -> None:
+        if self.fenced_command_gate is not None:
+            await self.fenced_command_gate.verify(token)
+
+    async def _execute_fenced(self, token: MissionOwnerToken, mutation: Any) -> Any:
+        if self.owner_registry is not None:
+            return await self.owner_registry.execute_fenced(token.script_build_id, mutation)
+        await self._verify_owner(token)
+        return await mutation()
+
+    async def _ensure_snapshot(self, snapshot: Any, binding: Any) -> tuple[Any, Any]:
+        presets = {str(item.get("preset")) for item in snapshot.prompt_manifest}
+        required = {item.preset for item in self.prompt_requests}
+        if required <= presets:
+            return snapshot, binding
+        extended = await self.input_snapshots.extend_prompt_lineage(
+            snapshot,
+            prompt_requests=self.prompt_requests,
+            model_manifest=self.model_manifest or snapshot.model_manifest,
+            version=3,
+        )
+        binding = await self.bindings.compare_and_set_input_snapshot(
+            script_build_id=binding.script_build_id,
+            expected_snapshot_id=int(snapshot.snapshot_id),
+            new_snapshot_id=int(extended.snapshot_id),
+        )
+        return extended, binding
+
+    async def _accepted_root_closure(
+        self, script_build_id: int, ledger: Any
+    ) -> _AcceptedRootClosure:
+        root = ledger.tasks[ledger.root_task_id]
+        if len(root.child_task_ids) != 2:
+            raise PhaseThreeBoundaryNotReady(
+                "Root must have exactly the accepted Direction and Portfolio children"
+            )
+        accepted: dict[str, AcceptedDecisionRef] = {}
+        for child_id in root.child_task_ids:
+            child = ledger.tasks[child_id]
+            decisions = [
+                ledger.decisions[item]
+                for item in child.decision_ids
+                if ledger.decisions[item].action is DecisionAction.ACCEPT
+            ]
+            if len(decisions) != 1 or not decisions[0].attempt_id or not decisions[0].validation_id:
+                raise PhaseThreeBoundaryNotReady(
+                    "each Root child requires exactly one closed ACCEPT"
+                )
+            attempt = ledger.attempts[decisions[0].attempt_id]
+            validation = ledger.validations[decisions[0].validation_id]
+            if (
+                attempt.submission is None
+                or len(attempt.submission.artifact_refs) != 1
+                or validation.attempt_id != attempt.attempt_id
+                or validation.snapshot_id != attempt.snapshot_id
+                or validation.verdict is not ValidationVerdict.PASSED
+            ):
+                raise PhaseThreeBoundaryNotReady(
+                    "Root child ACCEPT is not closed over one passed snapshot"
+                )
+            kind = task_kind(child.current_spec.context_refs)
+            if (
+                kind
+                not in {
+                    ScriptTaskKind.DIRECTION.value,
+                    ScriptTaskKind.CANDIDATE_PORTFOLIO.value,
+                }
+                or kind in accepted
+            ):
+                raise PhaseThreeBoundaryNotReady(
+                    "Root children must be one Direction and one Portfolio"
+                )
+            artifact_ref = attempt.submission.artifact_refs[0]
+            version = await self.artifacts.read_by_ref(
+                artifact_ref,
+                script_build_id=script_build_id,
+                task_id=child.task_id,
+                attempt_id=attempt.attempt_id,
+            )
+            if version.state not in {ArtifactState.FROZEN, ArtifactState.PUBLISHED}:
+                raise PhaseThreeBoundaryNotReady("Root child Artifact is not frozen")
+            if kind == ScriptTaskKind.DIRECTION.value and not isinstance(
+                version.artifact, ScriptDirectionArtifactV1
+            ):
+                raise PhaseThreeBoundaryNotReady("accepted Direction has the wrong Artifact type")
+            accepted[kind] = AcceptedDecisionRef(
+                decision_id=decisions[0].decision_id,
+                artifact_ref=artifact_ref,
+                scope_ref="script-build://scope/root",
+                expected_task_kind=ScriptTaskKind(kind),
+            )
+        if set(accepted) != {
+            ScriptTaskKind.DIRECTION.value,
+            ScriptTaskKind.CANDIDATE_PORTFOLIO.value,
+        }:
+            raise PhaseThreeBoundaryNotReady("Root requires accepted Direction and Portfolio")
+        portfolio_ref = accepted[ScriptTaskKind.CANDIDATE_PORTFOLIO.value].artifact_ref
+        portfolio_version = await self.artifacts.read_by_ref(
+            portfolio_ref, script_build_id=script_build_id
+        )
+        if not isinstance(portfolio_version.artifact, CandidatePortfolioArtifactV1):
+            raise PhaseThreeBoundaryNotReady("accepted Portfolio has the wrong Artifact type")
+        return _AcceptedRootClosure(
+            accepted[ScriptTaskKind.DIRECTION.value],
+            accepted[ScriptTaskKind.CANDIDATE_PORTFOLIO.value],
+            portfolio_version.artifact.adopted_structured_script_ref,
+        )
+
+    async def _freeze_root_contract(
+        self,
+        root_trace_id: str,
+        root: Any,
+        closure: _AcceptedRootClosure,
+        snapshot_id: str,
+    ) -> Any:
+        contract = ScriptTaskContractV1(
+            task_kind=ScriptTaskKind.ROOT_DELIVERY,
+            scope_ref="script-build://scope/root",
+            intent_class=ScriptIntentClass.DELIVER,
+            objective=root.current_spec.objective,
+            input_decision_refs=(closure.direction, closure.portfolio),
+            base_artifact_ref=None,
+            write_scope=(),
+            gap_ref=None,
+            output_schema="root-delivery-manifest/v1",
+            criteria=tuple(
+                ScriptCriterion(item.criterion_id, item.description, item.hard)
+                for item in root.current_spec.acceptance_criteria
+            ),
+            budget=ScriptTaskBudget(),
+        )
+        frozen = await self.contracts.freeze(root_trace_id, contract)
+        # Snapshot identity is present in TaskSpec; this guards accidental use of
+        # a contract prepared for a different continuation.
+        if not snapshot_id:
+            raise PhaseThreeBoundaryNotReady("phase-three InputSnapshot is missing")
+        return frozen
+
+    async def _prepare_policy_messages(
+        self, binding: Any, snapshot: Any, policy: str, message: str
+    ) -> AsyncIterator[Any] | None:
+        events = await self.runner.trace_store.get_events(binding.root_trace_id, 0)
+        migrations = []
+        for event in events:
+            if (
+                event.get("event") != "planner_policy_migrated"
+                and event.get("event_type") != "planner_policy_migrated"
+            ):
+                continue
+            payload = event.get("data") or event.get("payload") or event
+            if payload.get("policy_version") == PHASE_THREE_POLICY:
+                migrations.append(payload)
+        policy_digest = canonical_sha256(policy).wire
+        toolset_digest = canonical_sha256(
+            sorted(self.runner.tools.get_tool_names(groups=["script_build"]))
+        ).wire
+        if len(migrations) > 1:
+            raise PhaseThreeBoundaryNotReady("multiple phase-three policy migrations exist")
+        if migrations:
+            payload = migrations[0]
+            expected = {
+                "policy_version": PHASE_THREE_POLICY,
+                "policy_digest": policy_digest,
+                "toolset_digest": toolset_digest,
+                "input_snapshot_id": snapshot.snapshot_id,
+            }
+            if any(str(payload.get(key)) != str(value) for key, value in expected.items()):
+                raise PhaseThreeBoundaryNotReady("phase-three policy migration digest differs")
+            system_sequence, user_sequence = await self._verify_policy_messages(
+                binding.root_trace_id, payload, policy, message
+            )
+            if await self._has_output_after(binding.root_trace_id, user_sequence):
+                raise MissionRecoveryRequired()
+            return None
+        prepared = await self._find_policy_messages(binding.root_trace_id, policy, message)
+        if prepared is not None:
+            system_sequence, user_sequence = prepared
+            await self.runner.trace_store.append_event(
+                binding.root_trace_id,
+                "planner_policy_migrated",
+                {
+                    "policy_version": PHASE_THREE_POLICY,
+                    "policy_digest": policy_digest,
+                    "toolset_digest": toolset_digest,
+                    "input_snapshot_id": snapshot.snapshot_id,
+                    "system_sequence": system_sequence,
+                    "user_sequence": user_sequence,
+                },
+            )
+            if await self._has_output_after(binding.root_trace_id, user_sequence):
+                raise MissionRecoveryRequired()
+            return None
+        iterator = cast(
+            AsyncIterator[Any],
+            self.runner.run(
+                messages=[
+                    {"role": "system", "content": policy},
+                    {"role": "user", "content": message},
+                ],
+                config=self.factory.build_phase_three_run_config(binding, snapshot),
+            ),
+        )
+        try:
+            await anext(iterator)
+            system_message = await anext(iterator)
+            user_message = await anext(iterator)
+        except Exception as exc:
+            raise PhaseThreeBoundaryNotReady(
+                "phase-three policy messages were not durable"
+            ) from exc
+        if (
+            getattr(system_message, "role", None) != "system"
+            or getattr(user_message, "role", None) != "user"
+        ):
+            raise PhaseThreeBoundaryNotReady("phase-three policy message order differs")
+        await self.runner.trace_store.append_event(
+            binding.root_trace_id,
+            "planner_policy_migrated",
+            {
+                "policy_version": PHASE_THREE_POLICY,
+                "policy_digest": policy_digest,
+                "toolset_digest": toolset_digest,
+                "input_snapshot_id": snapshot.snapshot_id,
+                "system_sequence": system_message.sequence,
+                "user_sequence": user_message.sequence,
+            },
+        )
+        return iterator
+
+    async def _find_policy_messages(
+        self, root_trace_id: str, policy: str, message: str
+    ) -> tuple[int, int] | None:
+        messages = await self.runner.trace_store.get_trace_messages(root_trace_id)
+        systems = [item for item in messages if item.role == "system" and item.content == policy]
+        users = [item for item in messages if item.role == "user" and item.content == message]
+        if not systems and not users:
+            return None
+        if len(systems) != 1 or len(users) != 1:
+            raise PhaseThreeBoundaryNotReady("phase-three policy messages are duplicated")
+        if getattr(users[0], "parent_sequence", None) != systems[0].sequence:
+            raise PhaseThreeBoundaryNotReady("phase-three policy message parent chain differs")
+        return systems[0].sequence, users[0].sequence
+
+    async def _verify_policy_messages(
+        self,
+        root_trace_id: str,
+        payload: Any,
+        policy: str,
+        message: str,
+    ) -> tuple[int, int]:
+        system_sequence = payload.get("system_sequence")
+        user_sequence = payload.get("user_sequence")
+        if (
+            isinstance(system_sequence, bool)
+            or not isinstance(system_sequence, int)
+            or isinstance(user_sequence, bool)
+            or not isinstance(user_sequence, int)
+        ):
+            raise PhaseThreeBoundaryNotReady("phase-three policy sequence is invalid")
+        observed = await self._find_policy_messages(root_trace_id, policy, message)
+        if observed != (system_sequence, user_sequence):
+            raise PhaseThreeBoundaryNotReady(
+                "phase-three migration is not backed by its durable messages"
+            )
+        return observed
+
+    async def _has_output_after(self, root_trace_id: str, user_sequence: int) -> bool:
+        messages = await self.runner.trace_store.get_trace_messages(root_trace_id)
+        return any(
+            item.sequence > user_sequence and item.role not in {"system", "user"}
+            for item in messages
+        )
+
+    def _discard(self, script_build_id: int, task: asyncio.Task[None]) -> None:
+        if self._runs.get(script_build_id) is task:
+            self._runs.pop(script_build_id, None)
+        try:
+            task.exception()
+        except (asyncio.CancelledError, Exception):
+            pass
+
+
+__all__ = [
+    "PHASE_THREE_POLICY",
+    "PHASE_THREE_ROOT_ACCEPTED",
+    "PhaseThreeAdvanceResult",
+    "PhaseThreeContinuationService",
+]

+ 18 - 0
script_build_host/src/script_build_host/application/phase_two_planning.py

@@ -48,6 +48,7 @@ TASK_PRESET_BY_KIND: dict[ScriptTaskKind, str] = {
     ScriptTaskKind.COMPARE: "script_candidate_compare_worker",
     ScriptTaskKind.COMPARE: "script_candidate_compare_worker",
     ScriptTaskKind.COMPOSE: "script_compose_worker",
     ScriptTaskKind.COMPOSE: "script_compose_worker",
     ScriptTaskKind.CANDIDATE_PORTFOLIO: "script_candidate_portfolio_worker",
     ScriptTaskKind.CANDIDATE_PORTFOLIO: "script_candidate_portfolio_worker",
+    ScriptTaskKind.ROOT_DELIVERY: "script_root_worker",
 }
 }
 
 
 _PHASE_TWO_KINDS = frozenset(
 _PHASE_TWO_KINDS = frozenset(
@@ -167,6 +168,13 @@ class PhaseTwoPlanningService:
         parent = ledger.tasks.get(parent_id)
         parent = ledger.tasks.get(parent_id)
         if parent is None:
         if parent is None:
             raise TaskContractError("TASK_CONTRACT_INVALID", "parent Task is outside this Root")
             raise TaskContractError("TASK_CONTRACT_INVALID", "parent Task is outside this Root")
+        if parent_id == ledger.root_task_id and (
+            task_kind_from_context_refs(parent.current_spec.context_refs)
+            == ScriptTaskKind.ROOT_DELIVERY.value
+        ):
+            raise TaskContractError(
+                "TASK_CONTRACT_INVALID", "Root delivery cannot plan additional child Tasks"
+            )
         supplied = contracts if contracts is not None else contract_payloads
         supplied = contracts if contracts is not None else contract_payloads
         if not supplied:
         if not supplied:
             raise TaskContractError("TASK_CONTRACT_INVALID", "at least one contract is required")
             raise TaskContractError("TASK_CONTRACT_INVALID", "at least one contract is required")
@@ -226,6 +234,16 @@ class PhaseTwoPlanningService:
             decision_action = DecisionAction(action)
             decision_action = DecisionAction(action)
         except ValueError as exc:
         except ValueError as exc:
             raise TaskContractError("TASK_CONTRACT_INVALID", "unsupported decision action") from exc
             raise TaskContractError("TASK_CONTRACT_INVALID", "unsupported decision action") from exc
+        if (
+            is_root
+            and task_kind_from_context_refs(task.current_spec.context_refs)
+            == ScriptTaskKind.ROOT_DELIVERY.value
+            and decision_action in {DecisionAction.REVISE, DecisionAction.SPLIT}
+        ):
+            raise TaskContractError(
+                "TASK_CONTRACT_INVALID",
+                "the Host-frozen Root delivery contract cannot be replaced or split",
+            )
         if decision_action is not DecisionAction.BLOCK:
         if decision_action is not DecisionAction.BLOCK:
             await self._guard_runtime_budget(ledger)
             await self._guard_runtime_budget(ledger)
         if decision_action is DecisionAction.SUPERSEDE:
         if decision_action is DecisionAction.SUPERSEDE:

+ 345 - 0
script_build_host/src/script_build_host/application/root_delivery.py

@@ -0,0 +1,345 @@
+"""Protected Root delivery tools over one frozen accepted closure."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from dataclasses import asdict
+from typing import Any
+
+from agent.orchestration import DecisionAction, ValidationVerdict
+
+from script_build_host.domain.artifacts import (
+    ArtifactKind,
+    ArtifactState,
+    ArtifactVersion,
+    ScriptDirectionArtifactV1,
+)
+from script_build_host.domain.errors import ProtocolViolation
+from script_build_host.domain.phase_three_artifacts import (
+    RootDeliveryManifestV1,
+    canonicalize_legacy_projection,
+    root_delivery_input_closure_digest,
+)
+from script_build_host.domain.phase_two_artifacts import (
+    CandidatePortfolioArtifactV1,
+    StructuredScriptArtifactV1,
+)
+from script_build_host.domain.ports import (
+    MissionBindingRepository,
+    ScriptBusinessArtifactRepository,
+)
+from script_build_host.domain.task_refs import task_kind
+
+
+class RootDeliveryService:
+    def __init__(
+        self,
+        *,
+        bindings: MissionBindingRepository,
+        task_store: Any,
+        artifacts: ScriptBusinessArtifactRepository,
+    ) -> None:
+        self._bindings = bindings
+        self._task_store = task_store
+        self._artifacts = artifacts
+
+    async def read_accepted_portfolio(self, *, context: Mapping[str, Any]) -> Mapping[str, Any]:
+        closure = await self._closure(context)
+        return {
+            "direction_ref": _uri(closure.direction),
+            "candidate_portfolio_ref": _uri(closure.portfolio),
+            "structured_script_ref": _uri(closure.structured),
+            "direction": closure.direction.artifact.content_payload(),
+            "candidate_portfolio": closure.portfolio.artifact.content_payload(),
+            "structured_script": closure.structured.artifact.content_payload(),
+        }
+
+    async def legacy_projection_dry_run(
+        self, *, build_summary: str, context: Mapping[str, Any]
+    ) -> Mapping[str, Any]:
+        closure = await self._closure(context)
+        direction = _direction(closure.direction)
+        structured = _structured(closure.structured)
+        canonical = canonicalize_legacy_projection(
+            structured,
+            direction=direction.legacy_markdown,
+            summary=build_summary,
+        )
+        return {
+            "legacy_projection_digest": canonical.canonical_sha256,
+            "paragraph_count": canonical.paragraph_count,
+            "element_count": canonical.element_count,
+            "link_count": canonical.link_count,
+        }
+
+    async def save_root_delivery_manifest(
+        self,
+        *,
+        build_summary: str,
+        blocking_defects: Sequence[str],
+        context: Mapping[str, Any],
+    ) -> Mapping[str, Any]:
+        closure = await self._closure(context)
+        binding = await self._bindings.get_by_root(_required(context, "root_trace_id"))
+        ledger = await self._task_store.load(binding.root_trace_id)
+        task = ledger.tasks.get(_required(context, "task_id"))
+        attempt = ledger.attempts.get(_required(context, "attempt_id"))
+        if task is None or attempt is None or attempt.task_id != task.task_id:
+            raise ProtocolViolation("Root delivery Attempt is outside protected context")
+        if (
+            task.task_id != ledger.root_task_id
+            or task_kind(task.current_spec.context_refs) != "root-delivery"
+        ):
+            raise ProtocolViolation("Root delivery manifest can be saved by Root only")
+        if attempt.spec_version != task.current_spec.version:
+            raise ProtocolViolation("Root delivery Attempt does not use the current TaskSpec")
+        direction = _direction(closure.direction)
+        portfolio = _portfolio(closure.portfolio)
+        structured = _structured(closure.structured)
+        canonical = canonicalize_legacy_projection(
+            structured,
+            direction=direction.legacy_markdown,
+            summary=build_summary,
+        )
+        manifest = RootDeliveryManifestV1(
+            direction_ref=_uri(closure.direction),
+            candidate_portfolio_ref=_uri(closure.portfolio),
+            structured_script_ref=_uri(closure.structured),
+            input_closure_digest=_closure_digest(closure),
+            legacy_projection_digest=canonical.canonical_sha256,
+            build_summary=build_summary,
+            blocking_defects=tuple(blocking_defects),
+        )
+        if portfolio.adopted_structured_script_ref != manifest.structured_script_ref:
+            raise ProtocolViolation("accepted Portfolio does not adopt the frozen StructuredScript")
+        version, ref = await self._artifacts.freeze(
+            script_build_id=binding.script_build_id,
+            task_id=task.task_id,
+            attempt_id=attempt.attempt_id,
+            spec_version=attempt.spec_version,
+            artifact=manifest,
+        )
+        return {
+            "artifact_ref": asdict(ref),
+            "artifact_version_id": version.artifact_version_id,
+            "legacy_projection_digest": canonical.canonical_sha256,
+        }
+
+    async def deterministic_precheck(
+        self, *, context: Mapping[str, Any]
+    ) -> Sequence[Mapping[str, Any]]:
+        binding = await self._bindings.get_by_root(_required(context, "root_trace_id"))
+        ledger = await self._task_store.load(binding.root_trace_id)
+        attempt = ledger.attempts.get(_required(context, "attempt_id"))
+        if attempt is None or attempt.submission is None:
+            raise ProtocolViolation("Root validation Attempt has no submission")
+        refs = [
+            item
+            for item in attempt.submission.artifact_refs
+            if item.kind == ArtifactKind.ROOT_DELIVERY_MANIFEST.value
+        ]
+        if len(refs) != 1:
+            return (
+                {
+                    "rule_id": "root-manifest-shape",
+                    "verdict": "failed",
+                    "reason": "Root validation requires exactly one delivery manifest",
+                    "error": None,
+                    "evidence_refs": [],
+                },
+            )
+        version = await self._artifacts.read_by_ref(
+            refs[0],
+            script_build_id=binding.script_build_id,
+            task_id=attempt.task_id,
+            attempt_id=attempt.attempt_id,
+        )
+        manifest = version.artifact
+        closure = await self._closure(context)
+        root_manifest = manifest if isinstance(manifest, RootDeliveryManifestV1) else None
+        manifest_shape_passed = root_manifest is not None
+        defects_passed = root_manifest is not None and not root_manifest.blocking_defects
+        closure_passed = root_manifest is not None and (
+            root_manifest.direction_ref == _uri(closure.direction)
+            and root_manifest.candidate_portfolio_ref == _uri(closure.portfolio)
+            and root_manifest.structured_script_ref == _uri(closure.structured)
+            and root_manifest.input_closure_digest == _closure_digest(closure)
+        )
+        projection_passed = False
+        if root_manifest is not None:
+            canonical = canonicalize_legacy_projection(
+                _structured(closure.structured),
+                direction=_direction(closure.direction).legacy_markdown,
+                summary=root_manifest.build_summary,
+            )
+            projection_passed = canonical.canonical_sha256 == root_manifest.legacy_projection_digest
+        return (
+            {
+                "rule_id": "root-manifest-shape",
+                "verdict": "passed" if manifest_shape_passed else "failed",
+                "reason": (
+                    "Root Attempt owns one frozen delivery manifest"
+                    if manifest_shape_passed
+                    else "Root Attempt submission is not a delivery manifest"
+                ),
+                "error": None,
+                "evidence_refs": [],
+            },
+            {
+                "rule_id": "root-blocking-defects-empty",
+                "verdict": "passed" if defects_passed else "failed",
+                "reason": (
+                    "Root delivery has no blocking defects"
+                    if defects_passed
+                    else "Root delivery retains blocking defects"
+                ),
+                "error": None,
+                "evidence_refs": [],
+            },
+            {
+                "rule_id": "root-input-closure",
+                "verdict": "passed" if closure_passed else "failed",
+                "reason": (
+                    "Root delivery pins the exact accepted immutable closure"
+                    if closure_passed
+                    else "Root delivery immutable closure differs"
+                ),
+                "error": None,
+                "evidence_refs": [],
+            },
+            {
+                "rule_id": "root-legacy-projection",
+                "verdict": "passed" if projection_passed else "failed",
+                "reason": (
+                    "Root delivery legacy projection digest is reproducible"
+                    if projection_passed
+                    else "Root delivery legacy projection digest differs"
+                ),
+                "error": None,
+                "evidence_refs": [],
+            },
+        )
+
+    async def _closure(self, context: Mapping[str, Any]) -> _RootClosure:
+        root_trace_id = _required(context, "root_trace_id")
+        binding = await self._bindings.get_by_root(root_trace_id)
+        ledger = await self._task_store.load(root_trace_id)
+        attempt = ledger.attempts.get(_required(context, "attempt_id"))
+        task = ledger.tasks.get(_required(context, "task_id"))
+        if (
+            attempt is None
+            or task is None
+            or attempt.task_id != task.task_id
+            or task.task_id != ledger.root_task_id
+            or attempt.accepted_child_decision_ids is None
+        ):
+            raise ProtocolViolation("Root Attempt has no frozen accepted child closure")
+        versions: dict[str, ArtifactVersion] = {}
+        for decision_id in attempt.accepted_child_decision_ids:
+            decision = ledger.decisions.get(decision_id)
+            if (
+                decision is None
+                or decision.action is not DecisionAction.ACCEPT
+                or not decision.attempt_id
+                or not decision.validation_id
+            ):
+                raise ProtocolViolation("Root accepted child decision is invalid")
+            child_attempt = ledger.attempts.get(decision.attempt_id)
+            child_validation = ledger.validations.get(decision.validation_id)
+            child_task = ledger.tasks.get(decision.task_id)
+            if (
+                child_attempt is None
+                or child_validation is None
+                or child_task is None
+                or child_task.parent_task_id != task.task_id
+                or child_attempt.submission is None
+                or child_validation.attempt_id != child_attempt.attempt_id
+                or child_validation.snapshot_id != child_attempt.snapshot_id
+                or child_validation.verdict is not ValidationVerdict.PASSED
+            ):
+                raise ProtocolViolation("Root child ACCEPT is not closed over a passed snapshot")
+            refs = child_attempt.submission.artifact_refs
+            if len(refs) != 1:
+                raise ProtocolViolation("Root child must submit exactly one business Artifact")
+            version = await self._artifacts.read_by_ref(
+                refs[0],
+                script_build_id=binding.script_build_id,
+                task_id=child_task.task_id,
+                attempt_id=child_attempt.attempt_id,
+            )
+            if version.state not in {ArtifactState.FROZEN, ArtifactState.PUBLISHED}:
+                raise ProtocolViolation("Root child Artifact is not frozen")
+            versions[task_kind(child_task.current_spec.context_refs) or ""] = version
+        if set(versions) != {"direction", "candidate-portfolio"}:
+            raise ProtocolViolation(
+                "Root requires exactly accepted Direction and CandidatePortfolio"
+            )
+        portfolio = _portfolio(versions["candidate-portfolio"])
+        structured_id = _artifact_id(portfolio.adopted_structured_script_ref)
+        structured = await self._artifacts.get_by_id(
+            structured_id, script_build_id=binding.script_build_id
+        )
+        _structured(structured)
+        return _RootClosure(versions["direction"], versions["candidate-portfolio"], structured)
+
+
+class _RootClosure:
+    def __init__(
+        self,
+        direction: ArtifactVersion,
+        portfolio: ArtifactVersion,
+        structured: ArtifactVersion,
+    ):
+        self.direction = direction
+        self.portfolio = portfolio
+        self.structured = structured
+
+
+def _direction(version: ArtifactVersion) -> ScriptDirectionArtifactV1:
+    if not isinstance(version.artifact, ScriptDirectionArtifactV1):
+        raise ProtocolViolation("Root Direction artifact has the wrong type")
+    return version.artifact
+
+
+def _portfolio(version: ArtifactVersion) -> CandidatePortfolioArtifactV1:
+    if not isinstance(version.artifact, CandidatePortfolioArtifactV1):
+        raise ProtocolViolation("Root Portfolio artifact has the wrong type")
+    return version.artifact
+
+
+def _structured(version: ArtifactVersion) -> StructuredScriptArtifactV1:
+    if not isinstance(version.artifact, StructuredScriptArtifactV1):
+        raise ProtocolViolation("Root StructuredScript artifact has the wrong type")
+    return version.artifact
+
+
+def _uri(version: ArtifactVersion) -> str:
+    return f"script-build://artifact-versions/{version.artifact_version_id}"
+
+
+def _closure_digest(closure: _RootClosure) -> str:
+    return root_delivery_input_closure_digest(
+        direction_ref=_uri(closure.direction),
+        direction_digest=closure.direction.canonical_sha256,
+        candidate_portfolio_ref=_uri(closure.portfolio),
+        candidate_portfolio_digest=closure.portfolio.canonical_sha256,
+        structured_script_ref=_uri(closure.structured),
+        structured_script_digest=closure.structured.canonical_sha256,
+    )
+
+
+def _artifact_id(uri: str) -> int:
+    value = uri.removeprefix("script-build://artifact-versions/")
+    if not value.isdigit():
+        raise ProtocolViolation("Root delivery Artifact URI is invalid")
+    return int(value)
+
+
+def _required(context: Mapping[str, Any], key: str) -> str:
+    value = context.get(key)
+    if not isinstance(value, str) or not value:
+        raise ProtocolViolation(f"protected context is missing {key}")
+    return value
+
+
+__all__ = ["RootDeliveryService"]

+ 26 - 1
script_build_host/src/script_build_host/tools/contracts.py

@@ -124,4 +124,29 @@ class ScriptCandidateToolPort(Protocol):
     async def resolve_attempt_manifest(self, *, context: Mapping[str, Any]) -> AttemptManifest: ...
     async def resolve_attempt_manifest(self, *, context: Mapping[str, Any]) -> AttemptManifest: ...
 
 
 
 
-__all__ = ["AttemptManifest", "ScriptCandidateToolPort", "ScriptPlannerToolPort"]
+class RootDeliveryToolPort(Protocol):
+    async def read_accepted_portfolio(self, *, context: Mapping[str, Any]) -> Mapping[str, Any]: ...
+
+    async def legacy_projection_dry_run(
+        self, *, build_summary: str, context: Mapping[str, Any]
+    ) -> Mapping[str, Any]: ...
+
+    async def save_root_delivery_manifest(
+        self,
+        *,
+        build_summary: str,
+        blocking_defects: Sequence[str],
+        context: Mapping[str, Any],
+    ) -> Mapping[str, Any]: ...
+
+    async def deterministic_precheck(
+        self, *, context: Mapping[str, Any]
+    ) -> Sequence[Mapping[str, Any]]: ...
+
+
+__all__ = [
+    "AttemptManifest",
+    "RootDeliveryToolPort",
+    "ScriptCandidateToolPort",
+    "ScriptPlannerToolPort",
+]

+ 137 - 29
script_build_host/src/script_build_host/tools/gateway.py

@@ -8,7 +8,7 @@ framework's protected context and then checked against durable bindings.
 from __future__ import annotations
 from __future__ import annotations
 
 
 import re
 import re
-from collections.abc import Mapping, Sequence
+from collections.abc import Awaitable, Callable, Mapping, Sequence
 from dataclasses import asdict, dataclass
 from dataclasses import asdict, dataclass
 from datetime import UTC, datetime
 from datetime import UTC, datetime
 from typing import Any, Protocol, cast
 from typing import Any, Protocol, cast
@@ -25,6 +25,7 @@ from agent.orchestration import (
 from script_build_host.agents.validation import (
 from script_build_host.agents.validation import (
     PHASE_TWO_KINDS,
     PHASE_TWO_KINDS,
     RETRIEVAL_KINDS,
     RETRIEVAL_KINDS,
+    ROOT_DELIVERY_KIND,
     precheck_business_artifact,
     precheck_business_artifact,
     precheck_snapshot,
     precheck_snapshot,
     task_kind,
     task_kind,
@@ -45,7 +46,7 @@ from script_build_host.domain.ports import (
 )
 )
 from script_build_host.infrastructure.redaction import redact, redact_text
 from script_build_host.infrastructure.redaction import redact, redact_text
 
 
-from .contracts import ScriptCandidateToolPort, ScriptPlannerToolPort
+from .contracts import RootDeliveryToolPort, ScriptCandidateToolPort, ScriptPlannerToolPort
 
 
 _DEFECT_SEVERITIES = frozenset({"warning", "hard", "critical"})
 _DEFECT_SEVERITIES = frozenset({"warning", "hard", "critical"})
 _DEFECT_ACTIONS = frozenset(
 _DEFECT_ACTIONS = frozenset(
@@ -82,6 +83,10 @@ class ImageAdapter(Protocol):
 class DispatchGuard(Protocol):
 class DispatchGuard(Protocol):
     async def ensure_dispatch_allowed(self, script_build_id: int) -> None: ...
     async def ensure_dispatch_allowed(self, script_build_id: int) -> None: ...
 
 
+    async def execute_fenced(
+        self, script_build_id: int, mutation: Callable[[], Awaitable[Any]]
+    ) -> Any: ...
+
 
 
 class PhaseTwoBudgetGuard(Protocol):
 class PhaseTwoBudgetGuard(Protocol):
     async def ensure_tool_budget(self, *, root_trace_id: str, task_id: str, entry: str) -> None: ...
     async def ensure_tool_budget(self, *, root_trace_id: str, task_id: str, entry: str) -> None: ...
@@ -103,6 +108,7 @@ class LegacyScriptToolGateway:
         planner_tools: ScriptPlannerToolPort | None = None,
         planner_tools: ScriptPlannerToolPort | None = None,
         candidate_tools: ScriptCandidateToolPort | None = None,
         candidate_tools: ScriptCandidateToolPort | None = None,
         budget_guard: PhaseTwoBudgetGuard | None = None,
         budget_guard: PhaseTwoBudgetGuard | None = None,
+        root_tools: RootDeliveryToolPort | None = None,
     ) -> None:
     ) -> None:
         self.bindings = bindings
         self.bindings = bindings
         self.snapshots = snapshots
         self.snapshots = snapshots
@@ -114,6 +120,7 @@ class LegacyScriptToolGateway:
         self.planner_tools = planner_tools
         self.planner_tools = planner_tools
         self.candidate_tools = candidate_tools
         self.candidate_tools = candidate_tools
         self.budget_guard = budget_guard
         self.budget_guard = budget_guard
+        self.root_tools = root_tools
         self._active_retrieval_attempts: set[tuple[str, str, str]] = set()
         self._active_retrieval_attempts: set[tuple[str, str, str]] = set()
 
 
     async def ensure_dispatch_allowed(self, context: Mapping[str, Any]) -> None:
     async def ensure_dispatch_allowed(self, context: Mapping[str, Any]) -> None:
@@ -121,6 +128,14 @@ class LegacyScriptToolGateway:
         if self.dispatch_guard is not None:
         if self.dispatch_guard is not None:
             await self.dispatch_guard.ensure_dispatch_allowed(binding.script_build_id)
             await self.dispatch_guard.ensure_dispatch_allowed(binding.script_build_id)
 
 
+    async def _execute_fenced(
+        self, context: Mapping[str, Any], mutation: Callable[[], Awaitable[Any]]
+    ) -> Any:
+        binding = await self.bindings.get_by_root(_required(context, "root_trace_id"))
+        if self.dispatch_guard is None:
+            return await mutation()
+        return await self.dispatch_guard.execute_fenced(binding.script_build_id, mutation)
+
     async def plan_script_tasks(
     async def plan_script_tasks(
         self,
         self,
         *,
         *,
@@ -128,10 +143,16 @@ class LegacyScriptToolGateway:
         parent_task_id: str | None,
         parent_task_id: str | None,
         context: Mapping[str, Any],
         context: Mapping[str, Any],
     ) -> Mapping[str, Any]:
     ) -> Mapping[str, Any]:
-        return await self._planner().plan_script_tasks(
-            contract_payloads=contracts,
-            parent_task_id=parent_task_id,
-            context=context,
+        return cast(
+            Mapping[str, Any],
+            await self._execute_fenced(
+                context,
+                lambda: self._planner().plan_script_tasks(
+                    contract_payloads=contracts,
+                    parent_task_id=parent_task_id,
+                    context=context,
+                ),
+            ),
         )
         )
 
 
     async def decide_script_task(
     async def decide_script_task(
@@ -145,14 +166,20 @@ class LegacyScriptToolGateway:
         child_contracts: Sequence[Mapping[str, Any]],
         child_contracts: Sequence[Mapping[str, Any]],
         context: Mapping[str, Any],
         context: Mapping[str, Any],
     ) -> Mapping[str, Any]:
     ) -> Mapping[str, Any]:
-        return await self._planner().decide_script_task(
-            task_id=task_id,
-            action=action,
-            reason=reason,
-            validation_id=validation_id,
-            replacement_contract=replacement_contract,
-            child_contracts=child_contracts,
-            context=context,
+        return cast(
+            Mapping[str, Any],
+            await self._execute_fenced(
+                context,
+                lambda: self._planner().decide_script_task(
+                    task_id=task_id,
+                    action=action,
+                    reason=reason,
+                    validation_id=validation_id,
+                    replacement_contract=replacement_contract,
+                    child_contracts=child_contracts,
+                    context=context,
+                ),
+            ),
         )
         )
 
 
     async def inspect_script_plan(self, *, context: Mapping[str, Any]) -> Mapping[str, Any]:
     async def inspect_script_plan(self, *, context: Mapping[str, Any]) -> Mapping[str, Any]:
@@ -161,8 +188,13 @@ class LegacyScriptToolGateway:
     async def dispatch_script_tasks(
     async def dispatch_script_tasks(
         self, *, task_ids: Sequence[str], context: Mapping[str, Any]
         self, *, task_ids: Sequence[str], context: Mapping[str, Any]
     ) -> Sequence[Mapping[str, Any]]:
     ) -> Sequence[Mapping[str, Any]]:
-        await self.ensure_dispatch_allowed(context)
-        return await self._planner().dispatch_script_tasks(task_ids=task_ids, context=context)
+        return cast(
+            Sequence[Mapping[str, Any]],
+            await self._execute_fenced(
+                context,
+                lambda: self._planner().dispatch_script_tasks(task_ids=task_ids, context=context),
+            ),
+        )
 
 
     async def read_input_snapshot(self, context: Mapping[str, Any]) -> dict[str, Any]:
     async def read_input_snapshot(self, context: Mapping[str, Any]) -> dict[str, Any]:
         binding, snapshot = await self._scope(context)
         binding, snapshot = await self._scope(context)
@@ -182,6 +214,35 @@ class LegacyScriptToolGateway:
         ]
         ]
         return cast(dict[str, Any], _json_values(payload))
         return cast(dict[str, Any], _json_values(payload))
 
 
+    async def read_accepted_portfolio(self, context: Mapping[str, Any]) -> Mapping[str, Any]:
+        return await self._root_tools().read_accepted_portfolio(context=context)
+
+    async def legacy_projection_dry_run(
+        self, build_summary: str, context: Mapping[str, Any]
+    ) -> Mapping[str, Any]:
+        return await self._root_tools().legacy_projection_dry_run(
+            build_summary=build_summary, context=context
+        )
+
+    async def save_root_delivery_manifest(
+        self,
+        *,
+        build_summary: str,
+        blocking_defects: Sequence[str],
+        context: Mapping[str, Any],
+    ) -> Mapping[str, Any]:
+        return cast(
+            Mapping[str, Any],
+            await self._execute_fenced(
+                context,
+                lambda: self._root_tools().save_root_delivery_manifest(
+                    build_summary=build_summary,
+                    blocking_defects=blocking_defects,
+                    context=context,
+                ),
+            ),
+        )
+
     async def load_frozen_strategy(
     async def load_frozen_strategy(
         self, strategy_ref: str, context: Mapping[str, Any]
         self, strategy_ref: str, context: Mapping[str, Any]
     ) -> dict[str, Any]:
     ) -> dict[str, Any]:
@@ -473,8 +534,33 @@ class LegacyScriptToolGateway:
                 task_id=_required(context, "task_id"),
                 task_id=_required(context, "task_id"),
                 entry="submit",
                 entry="submit",
             )
             )
-        manifest = await self._candidates().resolve_attempt_manifest(context=context)
-        ref = manifest.artifact_ref
+        root_trace_id = _required(context, "root_trace_id")
+        task_id = _required(context, "task_id")
+        attempt_id = _required(context, "attempt_id")
+        ledger = await self.coordinator.task_store.load(root_trace_id)
+        task = ledger.tasks.get(task_id)
+        if task is None:
+            raise ProtocolViolation("attempt task is outside the protected mission")
+        if task_kind(task.current_spec.context_refs) == ROOT_DELIVERY_KIND:
+            binding = await self.bindings.get_by_root(root_trace_id)
+            root_version = await self.artifacts.get_by_attempt(
+                script_build_id=binding.script_build_id,
+                task_id=task_id,
+                attempt_id=attempt_id,
+            )
+            if root_version.artifact_type is not ArtifactKind.ROOT_DELIVERY_MANIFEST:
+                raise ProtocolViolation("Root Attempt does not own a delivery manifest")
+            ref = ArtifactRef(
+                uri=(f"script-build://artifact-versions/{root_version.artifact_version_id}"),
+                kind=root_version.artifact_type.value,
+                version=str(root_version.artifact_version_id),
+                digest=root_version.canonical_sha256,
+            )
+            scope_ref = "script-build://scope/root"
+        else:
+            manifest = await self._candidates().resolve_attempt_manifest(context=context)
+            ref = manifest.artifact_ref
+            scope_ref = manifest.scope_ref
         if ref.kind not in _BUSINESS_ARTIFACT_KINDS or not ref.digest:
         if ref.kind not in _BUSINESS_ARTIFACT_KINDS or not ref.digest:
             raise ProtocolViolation("attempt manifest must contain one frozen business artifact")
             raise ProtocolViolation("attempt manifest must contain one frozen business artifact")
         binding = await self.bindings.get_by_root(_required(context, "root_trace_id"))
         binding = await self.bindings.get_by_root(_required(context, "root_trace_id"))
@@ -496,7 +582,7 @@ class LegacyScriptToolGateway:
             version=ref.version,
             version=ref.version,
             digest=ref.digest,
             digest=ref.digest,
         )
         )
-        summary = _attempt_summary(safe_ref, manifest.scope_ref)
+        summary = _attempt_summary(safe_ref, scope_ref)
         submission = AttemptSubmission(
         submission = AttemptSubmission(
             summary=summary,
             summary=summary,
             artifact_refs=([] if safe_ref.kind == ArtifactKind.EVIDENCE.value else [safe_ref]),
             artifact_refs=([] if safe_ref.kind == ArtifactKind.EVIDENCE.value else [safe_ref]),
@@ -504,7 +590,10 @@ class LegacyScriptToolGateway:
         )
         )
         return cast(
         return cast(
             dict[str, Any],
             dict[str, Any],
-            await self.coordinator.submit_attempt(dict(context), submission),
+            await self._execute_fenced(
+                context,
+                lambda: self.coordinator.submit_attempt(dict(context), submission),
+            ),
         )
         )
 
 
     async def submit_structured_validation(
     async def submit_structured_validation(
@@ -602,6 +691,15 @@ class LegacyScriptToolGateway:
                 raise ProtocolViolation(
                 raise ProtocolViolation(
                     "passed validation conflicts with phase-two deterministic precheck"
                     "passed validation conflicts with phase-two deterministic precheck"
                 )
                 )
+        if (
+            overall is ValidationVerdict.PASSED
+            and task_kind(task.current_spec.context_refs) == ROOT_DELIVERY_KIND
+        ):
+            root_rules = await self._root_tools().deterministic_precheck(context=context)
+            if any(item.get("verdict") != "passed" for item in root_rules):
+                raise ProtocolViolation(
+                    "passed validation conflicts with Root deterministic precheck"
+                )
         if overall is not ValidationVerdict.PASSED and not normalized_defects:
         if overall is not ValidationVerdict.PASSED and not normalized_defects:
             raise ProtocolViolation("failed or inconclusive validation must report a defect")
             raise ProtocolViolation("failed or inconclusive validation must report a defect")
         if any(
         if any(
@@ -630,15 +728,18 @@ class LegacyScriptToolGateway:
         )
         )
         return cast(
         return cast(
             dict[str, Any],
             dict[str, Any],
-            await self.coordinator.submit_validation(
-                dict(context),
-                overall,
-                tuple(result_by_id.values()),
-                summary,
-                evidence_refs,
-                (),
-                codes[:50],
-                bounded_recommendation,
+            await self._execute_fenced(
+                context,
+                lambda: self.coordinator.submit_validation(
+                    dict(context),
+                    overall,
+                    tuple(result_by_id.values()),
+                    summary,
+                    evidence_refs,
+                    (),
+                    codes[:50],
+                    bounded_recommendation,
+                ),
             ),
             ),
         )
         )
 
 
@@ -750,6 +851,8 @@ class LegacyScriptToolGateway:
         kind = task_kind(task.current_spec.context_refs)
         kind = task_kind(task.current_spec.context_refs)
         if kind in PHASE_TWO_KINDS:
         if kind in PHASE_TWO_KINDS:
             rules.extend(await self._candidates().deterministic_precheck(context=context))
             rules.extend(await self._candidates().deterministic_precheck(context=context))
+        elif kind == "root-delivery":
+            rules.extend(await self._root_tools().deterministic_precheck(context=context))
         return {"rule_results": rules}
         return {"rule_results": rules}
 
 
     async def _scope(self, context: Mapping[str, Any]) -> tuple[Any, Any]:
     async def _scope(self, context: Mapping[str, Any]) -> tuple[Any, Any]:
@@ -771,6 +874,11 @@ class LegacyScriptToolGateway:
             raise ProtocolViolation("script candidate tools are not configured")
             raise ProtocolViolation("script candidate tools are not configured")
         return self.candidate_tools
         return self.candidate_tools
 
 
+    def _root_tools(self) -> RootDeliveryToolPort:
+        if self.root_tools is None:
+            raise ProtocolViolation("script Root delivery tools are not configured")
+        return self.root_tools
+
 
 
 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)

+ 35 - 0
script_build_host/src/script_build_host/tools/registry.py

@@ -24,6 +24,7 @@ TASK_PRESET_BY_KIND = {
     "compare": "script_candidate_compare_worker",
     "compare": "script_candidate_compare_worker",
     "compose": "script_compose_worker",
     "compose": "script_compose_worker",
     "candidate-portfolio": "script_candidate_portfolio_worker",
     "candidate-portfolio": "script_candidate_portfolio_worker",
+    "root-delivery": "script_root_worker",
 }
 }
 
 
 
 
@@ -558,6 +559,40 @@ def register_script_tools(registry: ToolRegistry, gateway: LegacyScriptToolGatew
 
 
     _register(registry, save_candidate_portfolio, capabilities=["write"])
     _register(registry, save_candidate_portfolio, capabilities=["write"])
 
 
+    async def read_accepted_portfolio(context: dict[str, Any] | None = None) -> str:
+        """Read the Root Attempt's accepted Direction, Portfolio and adopted script."""
+
+        return _json(await gateway.read_accepted_portfolio(context or {}))
+
+    _register(registry, read_accepted_portfolio, capabilities=["read"])
+
+    async def legacy_projection_dry_run(
+        build_summary: str,
+        context: dict[str, Any] | None = None,
+    ) -> str:
+        """Compute the physical-ID-independent legacy projection digest."""
+
+        return _json(await gateway.legacy_projection_dry_run(build_summary, context or {}))
+
+    _register(registry, legacy_projection_dry_run, capabilities=["read"])
+
+    async def save_root_delivery_manifest(
+        build_summary: str,
+        blocking_defects: list[str] | None = None,
+        context: dict[str, Any] | None = None,
+    ) -> str:
+        """Freeze the unique Root delivery manifest without rewriting script content."""
+
+        return _json(
+            await gateway.save_root_delivery_manifest(
+                build_summary=build_summary,
+                blocking_defects=blocking_defects or [],
+                context=context or {},
+            )
+        )
+
+    _register(registry, save_root_delivery_manifest, capabilities=["write"])
+
     async def query_validation_evidence(
     async def query_validation_evidence(
         query: str, limit: int = 10, context: dict[str, Any] | None = None
         query: str, limit: int = 10, context: dict[str, Any] | None = None
     ) -> str:
     ) -> str: