ソースを参照

持久化:扩展阶段二制品迁移与隔离候选工作区

SamLee 1 日 前
コミット
4c2485a5df

+ 1 - 0
script_build_host/migrations/CHECKSUMS.sha256

@@ -1 +1,2 @@
 0d524c7647d567ec3fba3c6d747ec09c2c96c9805506857adf14996a2539bff7  migrations/versions/0001_phase_one_business_tables.py
+c570caabd328ca4ff0c7aaa6212f44af3bae916bcdb5f2b028e40a1f16da2ff7  migrations/versions/0002_phase_two_artifact_types.py

+ 71 - 0
script_build_host/migrations/versions/0002_phase_two_artifact_types.py

@@ -0,0 +1,71 @@
+"""Allow immutable phase-two candidate artifact versions.
+
+Revision ID: 0002_phase_two_artifacts
+Revises: 0001_phase_one
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0002_phase_two_artifacts"
+down_revision: str | None = "0001_phase_one"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+_PHASE_ONE_CHECK = "artifact_type IN ('evidence', 'direction')"
+_PHASE_TWO_CHECK = (
+    "artifact_type IN ("
+    "'evidence', 'direction', 'structure', 'paragraph', 'element_set', "
+    "'comparison', 'structured_script', 'candidate_portfolio'"
+    ")"
+)
+_PHASE_TWO_TYPES = (
+    "structure",
+    "paragraph",
+    "element_set",
+    "comparison",
+    "structured_script",
+    "candidate_portfolio",
+)
+_PHASE_ONE_CONTENT_CHECK = (
+    "(state = 'draft' AND canonical_json IS NULL AND canonical_sha256 IS NULL) OR "
+    "(state <> 'draft' AND canonical_json IS NOT NULL AND canonical_sha256 IS NOT NULL)"
+)
+_PHASE_TWO_CONTENT_CHECK = (
+    "(state IN ('draft', 'discarded') AND canonical_json IS NULL "
+    "AND canonical_sha256 IS NULL) OR "
+    "(state IN ('frozen', 'published', 'discarded') AND canonical_json IS NOT NULL "
+    "AND canonical_sha256 IS NOT NULL)"
+)
+
+
+def upgrade() -> None:
+    with op.batch_alter_table("script_build_artifact_version") as batch:
+        batch.drop_constraint("ck_artifact_phase_one_type", type_="check")
+        batch.drop_constraint("ck_artifact_frozen_content", type_="check")
+        batch.create_check_constraint("ck_artifact_business_type", _PHASE_TWO_CHECK)
+        batch.create_check_constraint("ck_artifact_frozen_content", _PHASE_TWO_CONTENT_CHECK)
+
+
+def downgrade() -> None:
+    table = sa.table("script_build_artifact_version", sa.column("artifact_type", sa.String()))
+    count = (
+        op.get_bind()
+        .execute(
+            sa.select(sa.func.count())
+            .select_from(table)
+            .where(table.c.artifact_type.in_(_PHASE_TWO_TYPES))
+        )
+        .scalar_one()
+    )
+    if int(count) > 0:
+        raise RuntimeError("cannot downgrade while phase-two artifacts exist")
+    with op.batch_alter_table("script_build_artifact_version") as batch:
+        batch.drop_constraint("ck_artifact_business_type", type_="check")
+        batch.drop_constraint("ck_artifact_frozen_content", type_="check")
+        batch.create_check_constraint("ck_artifact_phase_one_type", _PHASE_ONE_CHECK)
+        batch.create_check_constraint("ck_artifact_frozen_content", _PHASE_ONE_CONTENT_CHECK)

+ 91 - 0
script_build_host/src/script_build_host/infrastructure/legacy_tables.py

@@ -7,6 +7,7 @@ from sqlalchemy import (
     Column,
     DateTime,
     Float,
+    Index,
     Integer,
     MetaData,
     String,
@@ -200,3 +201,93 @@ script_build_record = Table(
     Column("start_time", DateTime),
     Column("end_time", DateTime),
 )
+
+script_build_paragraph = Table(
+    "script_build_paragraph",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True, autoincrement=True),
+    Column("script_build_id", BigInteger, nullable=False),
+    Column("branch_id", BigInteger, nullable=False),
+    Column("base_ref_id", BigInteger),
+    Column("paragraph_index", Integer, nullable=False),
+    Column("level", Integer, nullable=False),
+    Column("parent_id", BigInteger),
+    Column("name", String(500)),
+    Column("content_range", JSON),
+    Column("theme", Text),
+    Column("form", Text),
+    Column("function", Text),
+    Column("feeling", Text),
+    Column("theme_elements", JSON),
+    Column("form_elements", JSON),
+    Column("function_elements", JSON),
+    Column("feeling_elements", JSON),
+    Column("description", Text),
+    Column("full_description", Text),
+    Column("is_active", Boolean, nullable=False, default=True),
+    Column("created_at", DateTime),
+    Column("updated_at", DateTime),
+    Index("ix_sbp_build_branch", "script_build_id", "branch_id"),
+)
+
+script_build_element = Table(
+    "script_build_element",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True, autoincrement=True),
+    Column("script_build_id", BigInteger, nullable=False),
+    Column("branch_id", BigInteger, nullable=False),
+    Column("base_ref_id", BigInteger),
+    Column("name", String(500), nullable=False),
+    Column("dimension_primary", String(50), nullable=False),
+    Column("dimension_secondary", String(100), nullable=False),
+    Column("commonality_analysis", JSON),
+    Column("topic_support", JSON),
+    Column("weight_score", JSON),
+    Column("support_elements", JSON),
+    Column("is_active", Boolean, nullable=False, default=True),
+    Column("created_at", DateTime),
+    Column("updated_at", DateTime),
+    Index("ix_sbe_build_branch", "script_build_id", "branch_id"),
+)
+
+script_build_paragraph_element = Table(
+    "script_build_paragraph_element",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True, autoincrement=True),
+    Column("script_build_id", BigInteger, nullable=False),
+    Column("branch_id", BigInteger, nullable=False),
+    Column("paragraph_id", BigInteger, nullable=False),
+    Column("element_id", BigInteger, nullable=False),
+    Index(
+        "uq_sbpe_branch_paragraph_element",
+        "branch_id",
+        "paragraph_id",
+        "element_id",
+        unique=True,
+    ),
+)
+
+script_build_task_plan_step = Table(
+    "script_build_task_plan_step",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True, autoincrement=True),
+    Column("script_build_id", BigInteger, nullable=False),
+    Column("branch_id", BigInteger, nullable=False),
+    Column("round_index", Integer),
+    Column("step_key", String(50), nullable=False),
+    Column("title", String(500)),
+    Column("data_needs", Text),
+    Column("status", String(20), nullable=False),
+    Column("result_note", Text),
+    Column("depends_on", JSON),
+    Column("sort_order", BigInteger),
+    Column("created_at", DateTime),
+    Column("updated_at", DateTime),
+    Index(
+        "uq_sbtps_build_branch_step",
+        "script_build_id",
+        "branch_id",
+        "step_key",
+        unique=True,
+    ),
+)

+ 9 - 3
script_build_host/src/script_build_host/infrastructure/tables.py

@@ -71,14 +71,20 @@ artifact_version_table = Table(
     UniqueConstraint("attempt_id", name="uq_artifact_attempt"),
     UniqueConstraint("script_build_id", "legacy_branch_id", name="uq_artifact_branch"),
     CheckConstraint(
-        "artifact_type IN ('evidence', 'direction')", name="ck_artifact_phase_one_type"
+        "artifact_type IN ("
+        "'evidence', 'direction', 'structure', 'paragraph', 'element_set', "
+        "'comparison', 'structured_script', 'candidate_portfolio'"
+        ")",
+        name="ck_artifact_business_type",
     ),
     CheckConstraint(
         "state IN ('draft', 'frozen', 'published', 'discarded')", name="ck_artifact_state"
     ),
     CheckConstraint(
-        "(state = 'draft' AND canonical_json IS NULL AND canonical_sha256 IS NULL) OR "
-        "(state <> 'draft' AND canonical_json IS NOT NULL AND canonical_sha256 IS NOT NULL)",
+        "(state IN ('draft', 'discarded') AND canonical_json IS NULL "
+        "AND canonical_sha256 IS NULL) OR "
+        "(state IN ('frozen', 'published', 'discarded') AND canonical_json IS NOT NULL "
+        "AND canonical_sha256 IS NOT NULL)",
         name="ck_artifact_frozen_content",
     ),
     Index("ix_artifact_task", "script_build_id", "task_id"),

+ 193 - 0
script_build_host/src/script_build_host/infrastructure/task_contract_store.py

@@ -0,0 +1,193 @@
+"""Content-addressed, first-write-wins storage for TaskContract values."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import re
+from dataclasses import dataclass
+from hashlib import sha256
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+from typing import Any
+
+from script_build_host.domain.digests import Sha256Digest
+from script_build_host.domain.task_contracts import (
+    FrozenTaskContract,
+    ScriptTaskContractV1,
+    TaskContractError,
+)
+from script_build_host.infrastructure.canonical_json import canonical_json_bytes
+
+_ROOT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
+_URI_PREFIX = "script-build://task-contracts/sha256/"
+
+
+@dataclass(frozen=True, slots=True)
+class FileScriptTaskContractStore:
+    root: Path
+
+    async def freeze(
+        self, root_trace_id: str, contract: ScriptTaskContractV1
+    ) -> FrozenTaskContract:
+        directory = self._directory(root_trace_id)
+        content = canonical_json_bytes(contract.to_payload())
+        digest = sha256(content).hexdigest()
+        await asyncio.to_thread(self._write_once, directory, digest, content)
+        return FrozenTaskContract(
+            uri=f"{_URI_PREFIX}{digest}",
+            digest=Sha256Digest(digest).wire,
+            canonical_size=len(content),
+            contract=contract,
+        )
+
+    async def read(self, root_trace_id: str, uri: str) -> FrozenTaskContract:
+        directory = self._directory(root_trace_id)
+        digest = _digest_from_uri(uri)
+        return await asyncio.to_thread(self._read, directory, digest)
+
+    async def verify(self, root_trace_id: str, uri: str, digest: str) -> bool:
+        try:
+            expected = Sha256Digest.from_wire(digest).hex_value
+            value = await self.read(root_trace_id, uri)
+        except (OSError, ValueError, TaskContractError):
+            return False
+        return value.digest == Sha256Digest(expected).wire
+
+    async def collect_unreferenced(
+        self, root_trace_id: str, referenced_digests: set[str]
+    ) -> tuple[str, ...]:
+        directory = self._directory(root_trace_id)
+        normalized = {
+            Sha256Digest.from_wire(value).hex_value if value.startswith("sha256:") else value
+            for value in referenced_digests
+        }
+        return await asyncio.to_thread(self._collect, directory, normalized)
+
+    async def prune_unreferenced(
+        self, root_trace_id: str, referenced_digests: set[str]
+    ) -> tuple[str, ...]:
+        """Remove only well-formed blobs not referenced by the durable Ledger."""
+
+        directory = self._directory(root_trace_id)
+        normalized = {
+            Sha256Digest.from_wire(value).hex_value if value.startswith("sha256:") else value
+            for value in referenced_digests
+        }
+        return await asyncio.to_thread(self._prune, directory, normalized)
+
+    def _directory(self, root_trace_id: str) -> Path:
+        if not _ROOT_ID.fullmatch(root_trace_id):
+            raise TaskContractError(
+                "TASK_CONTRACT_OWNERSHIP_MISMATCH", "root trace identity is invalid"
+            )
+        resolved_root = self.root.resolve()
+        directory = (resolved_root / "script-task-contracts" / root_trace_id / "sha256").resolve()
+        if not directory.is_relative_to(resolved_root):
+            raise TaskContractError(
+                "TASK_CONTRACT_OWNERSHIP_MISMATCH", "contract path escapes its configured root"
+            )
+        return directory
+
+    @staticmethod
+    def _write_once(directory: Path, digest: str, content: bytes) -> None:
+        directory.mkdir(parents=True, exist_ok=True)
+        target = directory / digest
+        if target.exists():
+            existing = target.read_bytes()
+            if sha256(existing).hexdigest() != digest or existing != content:
+                raise TaskContractError(
+                    "TASK_CONTRACT_DIGEST_MISMATCH",
+                    "existing contract failed first-write-wins verification",
+                )
+            return
+        with NamedTemporaryFile(dir=directory, delete=False) as temporary:
+            temporary.write(content)
+            temporary.flush()
+            os.fsync(temporary.fileno())
+            temporary_path = Path(temporary.name)
+        try:
+            os.replace(temporary_path, target)
+            directory_fd = os.open(directory, os.O_DIRECTORY)
+            try:
+                os.fsync(directory_fd)
+            finally:
+                os.close(directory_fd)
+        finally:
+            temporary_path.unlink(missing_ok=True)
+
+    @staticmethod
+    def _read(directory: Path, digest: str) -> FrozenTaskContract:
+        target = directory / digest
+        if not target.is_file():
+            raise TaskContractError("TASK_CONTRACT_NOT_FOUND", "task contract does not exist")
+        content = target.read_bytes()
+        if sha256(content).hexdigest() != digest:
+            raise TaskContractError(
+                "TASK_CONTRACT_DIGEST_MISMATCH", "stored task contract digest is invalid"
+            )
+        try:
+            payload: Any = json.loads(content)
+        except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+            raise TaskContractError(
+                "TASK_CONTRACT_DIGEST_MISMATCH", "stored task contract is not canonical JSON"
+            ) from exc
+        if not isinstance(payload, dict) or canonical_json_bytes(payload) != content:
+            raise TaskContractError(
+                "TASK_CONTRACT_DIGEST_MISMATCH", "stored task contract is not canonical JSON"
+            )
+        contract = ScriptTaskContractV1.from_payload(payload)
+        return FrozenTaskContract(
+            uri=f"{_URI_PREFIX}{digest}",
+            digest=Sha256Digest(digest).wire,
+            canonical_size=len(content),
+            contract=contract,
+        )
+
+    @staticmethod
+    def _collect(directory: Path, referenced: set[str]) -> tuple[str, ...]:
+        if not directory.exists():
+            return ()
+        return tuple(
+            f"{_URI_PREFIX}{path.name}"
+            for path in sorted(directory.iterdir(), key=lambda item: item.name)
+            if path.is_file()
+            and re.fullmatch(r"[0-9a-f]{64}", path.name)
+            and path.name not in referenced
+        )
+
+    @classmethod
+    def _prune(cls, directory: Path, referenced: set[str]) -> tuple[str, ...]:
+        orphan_uris = cls._collect(directory, referenced)
+        removed: list[str] = []
+        for uri in orphan_uris:
+            digest = _digest_from_uri(uri)
+            target = directory / digest
+            if target.is_file() and target.parent == directory:
+                target.unlink()
+                removed.append(uri)
+        if removed:
+            directory_fd = os.open(directory, os.O_DIRECTORY)
+            try:
+                os.fsync(directory_fd)
+            finally:
+                os.close(directory_fd)
+        return tuple(removed)
+
+
+def _digest_from_uri(uri: str) -> str:
+    if not uri.startswith(_URI_PREFIX):
+        raise TaskContractError(
+            "TASK_CONTRACT_OWNERSHIP_MISMATCH", "contract URI is outside its namespace"
+        )
+    value = uri.removeprefix(_URI_PREFIX)
+    try:
+        return Sha256Digest(value).hex_value
+    except Exception as exc:
+        raise TaskContractError(
+            "TASK_CONTRACT_OWNERSHIP_MISMATCH", "contract URI has an invalid digest"
+        ) from exc
+
+
+__all__ = ["FileScriptTaskContractStore"]

+ 2 - 0
script_build_host/src/script_build_host/repositories/__init__.py

@@ -6,9 +6,11 @@ from .sqlalchemy import (
     SqlAlchemyPublicationRepository,
     SqlAlchemyScriptBusinessArtifactRepository,
 )
+from .workspace import SqlAlchemyCandidateWorkspaceRepository
 
 __all__ = [
     "LegacySqlAlchemyInputReader",
+    "SqlAlchemyCandidateWorkspaceRepository",
     "SqlAlchemyInputSnapshotRepository",
     "SqlAlchemyLegacyBuildStateRepository",
     "SqlAlchemyMissionBindingRepository",

+ 118 - 13
script_build_host/src/script_build_host/repositories/sqlalchemy.py

@@ -26,10 +26,20 @@ from script_build_host.domain.errors import (
     ArtifactNotFound,
     ArtifactOwnershipMismatch,
     BuildNotFound,
+    InputDigestMismatch,
     InputHashConflict,
     ProtocolViolation,
 )
 from script_build_host.domain.input_snapshot import ScriptBuildInput, ScriptBuildInputSnapshotV1
+from script_build_host.domain.phase_two_artifacts import (
+    CandidatePortfolioArtifactV1,
+    ComparisonArtifactV1,
+    ElementSetArtifactV1,
+    ParagraphArtifactV1,
+    StructureArtifactV1,
+    StructuredScriptArtifactV1,
+    hydrate_phase_two_artifact,
+)
 from script_build_host.domain.records import (
     MissionBinding,
     Publication,
@@ -79,6 +89,13 @@ def _snapshot_from_row(row: RowMapping) -> ScriptBuildInputSnapshotV1:
         model_manifest=dict(payload.get("model_manifest", {})),
         canonical_sha256=Sha256Digest.from_db(str(row["canonical_sha256"])).wire,
         created_at=cast(datetime, row["created_at"]),
+        parent_snapshot_id=(
+            str(payload["parent_snapshot_id"])
+            if payload.get("parent_snapshot_id") is not None
+            else None
+        ),
+        parent_snapshot_sha256=payload.get("parent_snapshot_sha256"),
+        business_input_sha256=payload.get("business_input_sha256"),
     )
 
 
@@ -131,7 +148,7 @@ class SqlAlchemyInputSnapshotRepository:
                         canonical_json=payload,
                         canonical_sha256=digest.hex_value,
                         prompt_manifest_json=list(assembled.prompt_manifest),
-                        schema_version="script-build-input/v1",
+                        schema_version=str(payload["schema_version"]),
                         created_at=now,
                     )
                 )
@@ -207,6 +224,9 @@ class SqlAlchemyInputSnapshotRepository:
             )
         if row is None:
             raise BuildNotFound()
+        stored = Sha256Digest.from_db(str(row["canonical_sha256"])).wire
+        if canonical_sha256(dict(row["canonical_json"])).wire != stored:
+            raise InputDigestMismatch()
         return _snapshot_from_row(row)
 
 
@@ -338,6 +358,26 @@ class SqlAlchemyMissionBindingRepository:
         self, *, script_build_id: int, artifact_version_id: int
     ) -> MissionBinding:
         async with self._sessions() as session, session.begin():
+            current = (
+                (
+                    await session.execute(
+                        select(mission_binding_table).where(
+                            mission_binding_table.c.script_build_id == script_build_id
+                        )
+                    )
+                )
+                .mappings()
+                .one_or_none()
+            )
+            if current is None:
+                raise BuildNotFound()
+            existing = current["active_direction_artifact_version_id"]
+            if existing is not None and int(existing) != artifact_version_id:
+                raise ProtocolViolation(
+                    "active direction is already bound to another immutable artifact"
+                )
+            if existing is not None:
+                return _binding_from_row(current)
             result = await session.execute(
                 update(mission_binding_table)
                 .where(mission_binding_table.c.script_build_id == script_build_id)
@@ -350,13 +390,61 @@ class SqlAlchemyMissionBindingRepository:
                 raise BuildNotFound()
         return await self.get_by_build(script_build_id)
 
+    async def compare_and_set_input_snapshot(
+        self,
+        *,
+        script_build_id: int,
+        expected_snapshot_id: int,
+        new_snapshot_id: int,
+    ) -> MissionBinding:
+        """Advance the append-only input lineage without losing a concurrent winner."""
+
+        async with self._sessions() as session, session.begin():
+            result = await session.execute(
+                update(mission_binding_table)
+                .where(
+                    mission_binding_table.c.script_build_id == script_build_id,
+                    mission_binding_table.c.input_snapshot_id == expected_snapshot_id,
+                )
+                .values(input_snapshot_id=new_snapshot_id, updated_at=_utc_now())
+            )
+            if _rowcount(result) == 0:
+                current = (
+                    (
+                        await session.execute(
+                            select(mission_binding_table).where(
+                                mission_binding_table.c.script_build_id == script_build_id
+                            )
+                        )
+                    )
+                    .mappings()
+                    .one_or_none()
+                )
+                if current is None:
+                    raise BuildNotFound()
+                if int(current["input_snapshot_id"]) != new_snapshot_id:
+                    raise ProtocolViolation("input snapshot binding changed concurrently")
+                return _binding_from_row(current)
+        return await self.get_by_build(script_build_id)
+
 
 def _artifact_kind(value: BusinessArtifact) -> ArtifactKind:
     if isinstance(value, EvidenceRecordV1):
         return ArtifactKind.EVIDENCE
     if isinstance(value, ScriptDirectionArtifactV1):
         return ArtifactKind.DIRECTION
-    raise ProtocolViolation("phase one accepts only evidence and direction artifacts")
+    mapping = (
+        (StructureArtifactV1, ArtifactKind.STRUCTURE),
+        (ParagraphArtifactV1, ArtifactKind.PARAGRAPH),
+        (ElementSetArtifactV1, ArtifactKind.ELEMENT_SET),
+        (ComparisonArtifactV1, ArtifactKind.COMPARISON),
+        (StructuredScriptArtifactV1, ArtifactKind.STRUCTURED_SCRIPT),
+        (CandidatePortfolioArtifactV1, ArtifactKind.CANDIDATE_PORTFOLIO),
+    )
+    for artifact_type, kind in mapping:
+        if isinstance(value, artifact_type):
+            return kind
+    raise ProtocolViolation("unsupported script-build business artifact")
 
 
 def _artifact_payload(value: BusinessArtifact) -> dict[str, Any]:
@@ -379,17 +467,19 @@ def _hydrate_artifact(kind: ArtifactKind, payload: dict[str, Any], digest: str)
             content_sha256=digest,
             created_at=datetime.fromisoformat(str(payload["created_at"]).replace("Z", "+00:00")),
         )
-    return ScriptDirectionArtifactV1(
-        goals=tuple(DirectionGoal(**item) for item in payload["goals"]),
-        criteria=tuple(Criterion(**item) for item in payload.get("criteria", [])),
-        domain_criteria=tuple(Criterion(**item) for item in payload.get("domain_criteria", [])),
-        topic_refs=tuple(payload.get("topic_refs", [])),
-        persona_refs=tuple(payload.get("persona_refs", [])),
-        strategy_refs=tuple(payload.get("strategy_refs", [])),
-        evidence_refs=tuple(payload.get("evidence_refs", [])),
-        legacy_markdown=str(payload.get("legacy_markdown", "")),
-        canonical_sha256=digest,
-    )
+    if kind is ArtifactKind.DIRECTION:
+        return ScriptDirectionArtifactV1(
+            goals=tuple(DirectionGoal(**item) for item in payload["goals"]),
+            criteria=tuple(Criterion(**item) for item in payload.get("criteria", [])),
+            domain_criteria=tuple(Criterion(**item) for item in payload.get("domain_criteria", [])),
+            topic_refs=tuple(payload.get("topic_refs", [])),
+            persona_refs=tuple(payload.get("persona_refs", [])),
+            strategy_refs=tuple(payload.get("strategy_refs", [])),
+            evidence_refs=tuple(payload.get("evidence_refs", [])),
+            legacy_markdown=str(payload.get("legacy_markdown", "")),
+            canonical_sha256=digest,
+        )
+    return replace(hydrate_phase_two_artifact(payload), canonical_sha256=digest)
 
 
 def _version_from_row(row: RowMapping) -> ArtifactVersion:
@@ -621,6 +711,21 @@ class SqlAlchemyScriptBusinessArtifactRepository:
             raise ArtifactDigestMismatch()
         return version
 
+    async def get_by_attempt(
+        self, *, script_build_id: int, task_id: str, attempt_id: str
+    ) -> ArtifactVersion:
+        row = await self._find_by_attempt(attempt_id)
+        if row is None:
+            raise ArtifactNotFound()
+        version = _version_from_row(row)
+        if version.script_build_id != script_build_id or version.task_id != task_id:
+            raise ArtifactOwnershipMismatch()
+        if version.state not in {ArtifactState.FROZEN, ArtifactState.PUBLISHED}:
+            raise ArtifactNotFound()
+        if canonical_sha256(_artifact_payload(version.artifact)).wire != version.canonical_sha256:
+            raise ArtifactDigestMismatch()
+        return version
+
 
 def _publication_from_row(row: RowMapping) -> Publication:
     return Publication(

+ 1570 - 0
script_build_host/src/script_build_host/repositories/workspace.py

@@ -0,0 +1,1570 @@
+"""SQLAlchemy adapter for isolated, Attempt-owned candidate workspaces."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Mapping, Sequence
+from dataclasses import replace
+from datetime import UTC, datetime
+from typing import Any, cast
+
+from agent.orchestration import ArtifactRef
+from sqlalchemy import delete, insert, null, select, update
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from script_build_host.domain.artifacts import (
+    ArtifactKind,
+    ArtifactState,
+    ArtifactVersion,
+)
+from script_build_host.domain.phase_two_artifacts import (
+    CandidateLineageV1,
+    ElementSetArtifactV1,
+    ParagraphArtifactV1,
+    ScriptElementV1,
+    ScriptParagraphElementLinkV1,
+    ScriptParagraphV1,
+    StructureArtifactV1,
+    StructuredScriptArtifactV1,
+)
+from script_build_host.domain.workspaces import (
+    CandidateWorkspace,
+    CandidateWorkspaceSnapshot,
+    CandidateWriteContext,
+    WorkspaceError,
+)
+from script_build_host.infrastructure.canonical_json import canonical_sha256, normalize_json
+from script_build_host.infrastructure.legacy_tables import (
+    script_build_element,
+    script_build_paragraph,
+    script_build_paragraph_element,
+    script_build_task_plan_step,
+)
+from script_build_host.infrastructure.tables import artifact_version_table
+from script_build_host.repositories.sqlalchemy import (
+    SqlAlchemyScriptBusinessArtifactRepository,
+)
+
+_CREATIVE_KINDS = frozenset(
+    {
+        ArtifactKind.STRUCTURE,
+        ArtifactKind.PARAGRAPH,
+        ArtifactKind.ELEMENT_SET,
+        ArtifactKind.STRUCTURED_SCRIPT,
+    }
+)
+_ATOM_COLUMNS = frozenset(
+    {"theme_elements", "form_elements", "function_elements", "feeling_elements"}
+)
+_PARAGRAPH_KINDS = frozenset(
+    {ArtifactKind.STRUCTURE, ArtifactKind.PARAGRAPH, ArtifactKind.STRUCTURED_SCRIPT}
+)
+_ELEMENT_KINDS = frozenset({ArtifactKind.ELEMENT_SET, ArtifactKind.STRUCTURED_SCRIPT})
+
+
+class SqlAlchemyCandidateWorkspaceRepository:
+    def __init__(
+        self,
+        sessions: async_sessionmaker[AsyncSession],
+        artifacts: SqlAlchemyScriptBusinessArtifactRepository,
+    ) -> None:
+        self._sessions = sessions
+        self._artifacts = artifacts
+
+    async def get_or_create(
+        self,
+        context: CandidateWriteContext,
+        *,
+        artifact_kind: ArtifactKind,
+        base_artifact_ref: ArtifactRef | None = None,
+    ) -> CandidateWorkspace:
+        if artifact_kind not in _CREATIVE_KINDS:
+            raise WorkspaceError(
+                "LEGACY_WRITE_INVALID", "this artifact kind does not own a candidate workspace"
+            )
+        parent: ArtifactVersion | None = None
+        if base_artifact_ref is not None:
+            parent = await self._artifacts.read_by_ref(
+                base_artifact_ref, script_build_id=context.script_build_id
+            )
+            if parent.state not in {ArtifactState.FROZEN, ArtifactState.PUBLISHED}:
+                raise WorkspaceError(
+                    "STALE_BASE_REVISION", "base artifact is not an immutable accepted candidate"
+                )
+            if parent.artifact_type not in _CREATIVE_KINDS:
+                raise WorkspaceError(
+                    "STALE_BASE_REVISION",
+                    "base artifact does not contain an immutable candidate projection",
+                )
+        now = _now()
+        try:
+            async with self._sessions() as session, session.begin():
+                existing = await self._row_by_attempt(session, context.attempt_id, lock=True)
+                if existing is not None:
+                    return self._validate_workspace(existing, context, artifact_kind, parent)
+                result = await session.execute(
+                    insert(artifact_version_table).values(
+                        script_build_id=context.script_build_id,
+                        task_id=context.task_id,
+                        attempt_id=context.attempt_id,
+                        spec_version=context.spec_version,
+                        artifact_type=artifact_kind.value,
+                        legacy_branch_id=None,
+                        base_revision=(parent.artifact_version_id if parent else None),
+                        parent_artifact_version_id=(parent.artifact_version_id if parent else None),
+                        canonical_json=null(),
+                        canonical_sha256=None,
+                        state=ArtifactState.DRAFT.value,
+                        created_at=now,
+                    )
+                )
+                artifact_id = int(cast(Any, result).inserted_primary_key[0])
+                if artifact_id < 1:
+                    raise WorkspaceError(
+                        "LEGACY_WRITE_INVALID", "database did not allocate a workspace identity"
+                    )
+                await session.execute(
+                    update(artifact_version_table)
+                    .where(artifact_version_table.c.id == artifact_id)
+                    .values(legacy_branch_id=artifact_id)
+                )
+                row = await self._row_by_attempt(session, context.attempt_id, lock=True)
+                if row is None:
+                    raise WorkspaceError(
+                        "ATTEMPT_WORKSPACE_NOT_FOUND", "candidate workspace was not persisted"
+                    )
+                workspace = self._workspace(row)
+                await self._ensure_task_plan(session, context, workspace)
+                if parent is not None:
+                    await self._materialize_base(session, workspace, parent)
+                return workspace
+        except IntegrityError:
+            async with self._sessions() as session:
+                row = await self._row_by_attempt(session, context.attempt_id)
+            if row is None:
+                raise
+            return self._validate_workspace(row, context, artifact_kind, parent)
+
+    async def require(self, context: CandidateWriteContext) -> CandidateWorkspace:
+        async with self._sessions() as session:
+            row = await self._row_by_attempt(session, context.attempt_id)
+        if row is None:
+            raise WorkspaceError(
+                "ATTEMPT_WORKSPACE_NOT_FOUND", "candidate workspace does not exist"
+            )
+        workspace = self._workspace(row)
+        self._validate_context(workspace, context)
+        return workspace
+
+    async def snapshot(self, context: CandidateWriteContext) -> CandidateWorkspaceSnapshot:
+        async with self._sessions() as session:
+            workspace = await self._locked_workspace(session, context, require_draft=False)
+            return await self._snapshot_in_session(session, workspace)
+
+    async def create_paragraph(
+        self,
+        context: CandidateWriteContext,
+        *,
+        paragraph_index: int,
+        name: str,
+        content_range: Mapping[str, Any],
+        level: int = 1,
+        parent_paragraph_id: int | None = None,
+        theme_elements: Sequence[Mapping[str, Any]] = (),
+        form_elements: Sequence[Mapping[str, Any]] = (),
+        function_elements: Sequence[Mapping[str, Any]] = (),
+        feeling_elements: Sequence[Mapping[str, Any]] = (),
+    ) -> int:
+        if paragraph_index < 1 or level not in (1, 2) or not name.strip():
+            raise WorkspaceError("LEGACY_WRITE_INVALID", "paragraph fields are invalid")
+        if level == 1 and parent_paragraph_id is not None:
+            raise WorkspaceError(
+                "LEGACY_REFERENCE_INVALID", "level-one paragraph cannot have a parent"
+            )
+        if level == 2 and parent_paragraph_id is None:
+            raise WorkspaceError(
+                "LEGACY_REFERENCE_INVALID", "level-two paragraph requires a parent"
+            )
+        atoms = {
+            "theme_elements": _atoms(theme_elements, feeling=False),
+            "form_elements": _atoms(form_elements, feeling=False),
+            "function_elements": _atoms(function_elements, feeling=False),
+            "feeling_elements": _atoms(feeling_elements, feeling=True),
+        }
+        _authorize_write(
+            context,
+            entity="paragraphs",
+            operation="create",
+            identities=(paragraph_index, name.strip()),
+            content_range=content_range,
+        )
+        async with self._sessions() as session, session.begin():
+            workspace = await self._locked_workspace(session, context)
+            _require_workspace_kind(workspace, _PARAGRAPH_KINDS)
+            await self._ensure_task_plan(session, context, workspace)
+            if parent_paragraph_id is not None:
+                parent = await self._paragraph(session, workspace, parent_paragraph_id, active=True)
+                if parent is None or int(parent["level"]) != 1:
+                    raise WorkspaceError(
+                        "LEGACY_REFERENCE_INVALID", "paragraph parent is outside this workspace"
+                    )
+                if not _content_range_subset(dict(content_range), parent["content_range"] or {}):
+                    raise WorkspaceError(
+                        "LEGACY_REFERENCE_INVALID", "child content range exceeds its parent"
+                    )
+            result = await session.execute(
+                insert(script_build_paragraph).values(
+                    script_build_id=workspace.script_build_id,
+                    branch_id=workspace.branch_id,
+                    base_ref_id=None,
+                    paragraph_index=paragraph_index,
+                    level=level,
+                    parent_id=parent_paragraph_id,
+                    name=name.strip(),
+                    content_range=dict(content_range),
+                    **atoms,
+                    is_active=True,
+                    created_at=_now(),
+                    updated_at=_now(),
+                )
+            )
+            return int(cast(Any, result).inserted_primary_key[0])
+
+    async def batch_update_paragraphs(
+        self, context: CandidateWriteContext, updates: Sequence[Mapping[str, Any]]
+    ) -> tuple[int, ...]:
+        if not updates:
+            raise WorkspaceError("LEGACY_WRITE_INVALID", "paragraph update batch is empty")
+        allowed = {
+            "name",
+            "theme",
+            "form",
+            "function",
+            "feeling",
+            "full_description",
+            "description",
+            "content_range",
+            "paragraph_index",
+            "level",
+            "parent_paragraph_id",
+            "is_active",
+        }
+        async with self._sessions() as session, session.begin():
+            workspace = await self._locked_workspace(session, context)
+            _require_workspace_kind(workspace, _PARAGRAPH_KINDS)
+            prepared: list[tuple[int, dict[str, Any]]] = []
+            for item in updates:
+                paragraph_id = _positive_int(item.get("paragraph_id"), "paragraph_id")
+                row = await self._paragraph(session, workspace, paragraph_id)
+                if row is None:
+                    raise WorkspaceError(
+                        "LEGACY_REFERENCE_INVALID", "paragraph is outside this workspace"
+                    )
+                unknown = set(item) - allowed - {"paragraph_id"}
+                if unknown:
+                    raise WorkspaceError(
+                        "LEGACY_WRITE_INVALID", "paragraph update contains unsupported fields"
+                    )
+                values = {key: item[key] for key in allowed if key in item}
+                if "name" in values:
+                    if not isinstance(values["name"], str) or not values["name"].strip():
+                        raise WorkspaceError(
+                            "LEGACY_WRITE_INVALID", "paragraph name must not be blank"
+                        )
+                    values["name"] = values["name"].strip()
+                for field in (
+                    "theme",
+                    "form",
+                    "function",
+                    "feeling",
+                    "full_description",
+                    "description",
+                ):
+                    if (
+                        field in values
+                        and values[field] is not None
+                        and not isinstance(values[field], str)
+                    ):
+                        raise WorkspaceError(
+                            "LEGACY_WRITE_INVALID", f"paragraph {field} must be text or null"
+                        )
+                if "content_range" in values:
+                    if not isinstance(values["content_range"], Mapping):
+                        raise WorkspaceError(
+                            "LEGACY_WRITE_INVALID", "paragraph content_range must be an object"
+                        )
+                    values["content_range"] = dict(values["content_range"])
+                if "paragraph_index" in values:
+                    values["paragraph_index"] = _positive_int(
+                        values["paragraph_index"], "paragraph_index"
+                    )
+                if "level" in values:
+                    if (
+                        isinstance(values["level"], bool)
+                        or not isinstance(values["level"], int)
+                        or values["level"] not in (1, 2)
+                    ):
+                        raise WorkspaceError(
+                            "LEGACY_WRITE_INVALID", "paragraph level must be 1 or 2"
+                        )
+                if "parent_paragraph_id" in values and values["parent_paragraph_id"] is not None:
+                    values["parent_paragraph_id"] = _positive_int(
+                        values["parent_paragraph_id"], "parent_paragraph_id"
+                    )
+                if "is_active" in values and not isinstance(values["is_active"], bool):
+                    raise WorkspaceError(
+                        "LEGACY_WRITE_INVALID", "paragraph is_active must be boolean"
+                    )
+                _authorize_write(
+                    context,
+                    entity="paragraphs",
+                    operation="update",
+                    identities=(paragraph_id,),
+                    content_range=cast(
+                        Mapping[str, Any], values.get("content_range", row["content_range"] or {})
+                    ),
+                )
+                if "parent_paragraph_id" in values:
+                    parent_id = values.pop("parent_paragraph_id")
+                    values["parent_id"] = parent_id
+                level = values.get("level", row["level"])
+                parent_id = values.get("parent_id", row["parent_id"])
+                if level not in (1, 2) or (level == 2 and parent_id is None):
+                    raise WorkspaceError(
+                        "LEGACY_REFERENCE_INVALID", "updated paragraph hierarchy is invalid"
+                    )
+                if level == 1:
+                    values["parent_id"] = None
+                elif parent_id == paragraph_id:
+                    raise WorkspaceError(
+                        "LEGACY_REFERENCE_INVALID", "paragraph cannot be its own parent"
+                    )
+                else:
+                    parent = await self._paragraph(session, workspace, int(parent_id), active=True)
+                    if parent is None or int(parent["level"]) != 1:
+                        raise WorkspaceError(
+                            "LEGACY_REFERENCE_INVALID", "updated paragraph parent is invalid"
+                        )
+                    content_range = cast(
+                        Mapping[str, Any], values.get("content_range", row["content_range"] or {})
+                    )
+                    if not _content_range_subset(content_range, parent["content_range"] or {}):
+                        raise WorkspaceError(
+                            "LEGACY_REFERENCE_INVALID",
+                            "updated child content range exceeds its parent",
+                        )
+                if values.get("is_active") is False:
+                    active_child = await session.scalar(
+                        select(script_build_paragraph.c.id).where(
+                            script_build_paragraph.c.script_build_id == workspace.script_build_id,
+                            script_build_paragraph.c.branch_id == workspace.branch_id,
+                            script_build_paragraph.c.parent_id == paragraph_id,
+                            script_build_paragraph.c.is_active.is_(True),
+                        )
+                    )
+                    if active_child is not None:
+                        raise WorkspaceError(
+                            "LEGACY_REFERENCE_INVALID",
+                            "a paragraph with active children cannot be deactivated",
+                        )
+                if not values:
+                    raise WorkspaceError("LEGACY_WRITE_INVALID", "paragraph update is empty")
+                values["updated_at"] = _now()
+                prepared.append((paragraph_id, values))
+            for paragraph_id, values in prepared:
+                await session.execute(
+                    update(script_build_paragraph)
+                    .where(
+                        script_build_paragraph.c.id == paragraph_id,
+                        script_build_paragraph.c.script_build_id == workspace.script_build_id,
+                        script_build_paragraph.c.branch_id == workspace.branch_id,
+                    )
+                    .values(**values)
+                )
+                if values.get("is_active") is False:
+                    await session.execute(
+                        delete(script_build_paragraph_element).where(
+                            script_build_paragraph_element.c.script_build_id
+                            == workspace.script_build_id,
+                            script_build_paragraph_element.c.branch_id == workspace.branch_id,
+                            script_build_paragraph_element.c.paragraph_id == paragraph_id,
+                        )
+                    )
+            return tuple(item[0] for item in prepared)
+
+    async def append_paragraph_atoms(
+        self,
+        context: CandidateWriteContext,
+        *,
+        paragraph_id: int,
+        column: str,
+        atoms: Sequence[Mapping[str, Any]],
+    ) -> int:
+        if column not in _ATOM_COLUMNS:
+            raise WorkspaceError("LEGACY_WRITE_INVALID", "paragraph atom column is invalid")
+        normalized = _atoms(atoms, feeling=column == "feeling_elements")
+        _authorize_write(
+            context, entity="paragraphs", operation="append-atoms", identities=(paragraph_id,)
+        )
+        async with self._sessions() as session, session.begin():
+            workspace = await self._locked_workspace(session, context)
+            _require_workspace_kind(workspace, _PARAGRAPH_KINDS)
+            row = await self._paragraph(session, workspace, paragraph_id, active=True)
+            if row is None:
+                raise WorkspaceError(
+                    "LEGACY_REFERENCE_INVALID", "paragraph is outside this workspace"
+                )
+            existing = list(row[column] or [])
+            dimensions = {str(item.get("维度", "")) for item in existing}
+            added = [item for item in normalized if item["维度"] not in dimensions]
+            await session.execute(
+                update(script_build_paragraph)
+                .where(script_build_paragraph.c.id == paragraph_id)
+                .values(**{column: [*existing, *added], "updated_at": _now()})
+            )
+            return len(added)
+
+    async def delete_paragraph_atom(
+        self,
+        context: CandidateWriteContext,
+        *,
+        paragraph_id: int,
+        column: str,
+        dimension: str,
+        atom_value: str,
+    ) -> int:
+        if column not in _ATOM_COLUMNS or not dimension.strip() or not atom_value.strip():
+            raise WorkspaceError("LEGACY_WRITE_INVALID", "paragraph atom selector is invalid")
+        _authorize_write(
+            context, entity="paragraphs", operation="delete-atom", identities=(paragraph_id,)
+        )
+        async with self._sessions() as session, session.begin():
+            workspace = await self._locked_workspace(session, context)
+            _require_workspace_kind(workspace, _PARAGRAPH_KINDS)
+            row = await self._paragraph(session, workspace, paragraph_id)
+            if row is None:
+                raise WorkspaceError(
+                    "LEGACY_REFERENCE_INVALID", "paragraph is outside this workspace"
+                )
+            existing = list(row[column] or [])
+            kept = [
+                item
+                for item in existing
+                if not (
+                    str(item.get("维度", "")).strip() == dimension.strip()
+                    and str(item.get("原子点", "")).strip() == atom_value.strip()
+                )
+            ]
+            deleted_count = len(existing) - len(kept)
+            if deleted_count == 0:
+                raise WorkspaceError("LEGACY_REFERENCE_INVALID", "paragraph atom does not exist")
+            await session.execute(
+                update(script_build_paragraph)
+                .where(script_build_paragraph.c.id == paragraph_id)
+                .values(**{column: kept, "updated_at": _now()})
+            )
+            return deleted_count
+
+    async def create_element(
+        self,
+        context: CandidateWriteContext,
+        *,
+        name: str,
+        dimension_primary: str,
+        dimension_secondary: str,
+        commonality_analysis: Mapping[str, Any] | None = None,
+        topic_support: Mapping[str, Any] | None = None,
+        weight_score: Mapping[str, Any] | None = None,
+        support_elements: Sequence[Mapping[str, Any]] = (),
+    ) -> int:
+        _element_fields(name, dimension_primary, dimension_secondary)
+        _authorize_write(context, entity="elements", operation="create", identities=(name.strip(),))
+        async with self._sessions() as session, session.begin():
+            workspace = await self._locked_workspace(session, context)
+            _require_workspace_kind(workspace, _ELEMENT_KINDS)
+            await self._ensure_task_plan(session, context, workspace)
+            existing = (
+                (
+                    await session.execute(
+                        select(script_build_element.c.id).where(
+                            script_build_element.c.script_build_id == workspace.script_build_id,
+                            script_build_element.c.branch_id == workspace.branch_id,
+                            script_build_element.c.name == name.strip(),
+                            script_build_element.c.dimension_primary == dimension_primary,
+                            script_build_element.c.dimension_secondary
+                            == dimension_secondary.strip(),
+                        )
+                    )
+                )
+                .scalars()
+                .one_or_none()
+            )
+            if existing is not None:
+                return int(existing)
+            result = await session.execute(
+                insert(script_build_element).values(
+                    script_build_id=workspace.script_build_id,
+                    branch_id=workspace.branch_id,
+                    base_ref_id=None,
+                    name=name.strip(),
+                    dimension_primary=dimension_primary,
+                    dimension_secondary=dimension_secondary.strip(),
+                    commonality_analysis=(
+                        dict(commonality_analysis) if commonality_analysis is not None else None
+                    ),
+                    topic_support=dict(topic_support) if topic_support is not None else None,
+                    weight_score=dict(weight_score) if weight_score is not None else None,
+                    support_elements=[dict(item) for item in support_elements],
+                    is_active=True,
+                    created_at=_now(),
+                    updated_at=_now(),
+                )
+            )
+            return int(cast(Any, result).inserted_primary_key[0])
+
+    async def update_element(
+        self, context: CandidateWriteContext, *, element_id: int, values: Mapping[str, Any]
+    ) -> None:
+        allowed = {
+            "name",
+            "dimension_primary",
+            "dimension_secondary",
+            "commonality_analysis",
+            "topic_support",
+            "weight_score",
+            "support_elements",
+            "is_active",
+        }
+        if not values or set(values) - allowed:
+            raise WorkspaceError("LEGACY_WRITE_INVALID", "element update is invalid")
+        _authorize_write(context, entity="elements", operation="update", identities=(element_id,))
+        async with self._sessions() as session, session.begin():
+            workspace = await self._locked_workspace(session, context)
+            _require_workspace_kind(workspace, _ELEMENT_KINDS)
+            row = await self._element(session, workspace, element_id)
+            if row is None:
+                raise WorkspaceError(
+                    "LEGACY_REFERENCE_INVALID", "element is outside this workspace"
+                )
+            name = values.get("name", row["name"])
+            primary = values.get("dimension_primary", row["dimension_primary"])
+            secondary = values.get("dimension_secondary", row["dimension_secondary"])
+            if not all(isinstance(item, str) for item in (name, primary, secondary)):
+                raise WorkspaceError("LEGACY_WRITE_INVALID", "element text fields are invalid")
+            _element_fields(name, primary, secondary)
+            normalized = dict(values)
+            if "name" in normalized:
+                normalized["name"] = name.strip()
+            if "dimension_secondary" in normalized:
+                normalized["dimension_secondary"] = secondary.strip()
+            for field in ("commonality_analysis", "topic_support", "weight_score"):
+                if field in normalized:
+                    value = normalized[field]
+                    if value is not None and not isinstance(value, Mapping):
+                        raise WorkspaceError(
+                            "LEGACY_WRITE_INVALID", f"element {field} must be an object or null"
+                        )
+                    normalized[field] = dict(value) if value is not None else None
+            if "support_elements" in normalized:
+                raw_support = normalized["support_elements"]
+                if (
+                    not isinstance(raw_support, Sequence)
+                    or isinstance(raw_support, (str, bytes))
+                    or any(not isinstance(item, Mapping) for item in raw_support)
+                ):
+                    raise WorkspaceError(
+                        "LEGACY_WRITE_INVALID", "element support_elements must be an object array"
+                    )
+                support_elements = cast(Sequence[Mapping[str, Any]], raw_support)
+                normalized["support_elements"] = [dict(item) for item in support_elements]
+            if "is_active" in normalized and not isinstance(normalized["is_active"], bool):
+                raise WorkspaceError("LEGACY_WRITE_INVALID", "element is_active must be boolean")
+            normalized["updated_at"] = _now()
+            await session.execute(
+                update(script_build_element)
+                .where(
+                    script_build_element.c.id == element_id,
+                    script_build_element.c.script_build_id == workspace.script_build_id,
+                    script_build_element.c.branch_id == workspace.branch_id,
+                )
+                .values(**normalized)
+            )
+            if normalized.get("is_active") is False:
+                await session.execute(
+                    delete(script_build_paragraph_element).where(
+                        script_build_paragraph_element.c.script_build_id
+                        == workspace.script_build_id,
+                        script_build_paragraph_element.c.branch_id == workspace.branch_id,
+                        script_build_paragraph_element.c.element_id == element_id,
+                    )
+                )
+
+    async def batch_link(
+        self,
+        context: CandidateWriteContext,
+        links: Sequence[tuple[int, Sequence[int]]],
+    ) -> int:
+        if not links:
+            raise WorkspaceError("LEGACY_WRITE_INVALID", "link batch is empty")
+        for paragraph_id, element_ids in links:
+            if not element_ids:
+                raise WorkspaceError("LEGACY_REFERENCE_INVALID", "link element list is empty")
+            for element_id in element_ids:
+                _authorize_link_write(context, paragraph_id, int(element_id), operation="create")
+        async with self._sessions() as session, session.begin():
+            workspace = await self._locked_workspace(session, context)
+            _require_workspace_kind(workspace, _ELEMENT_KINDS)
+            pairs: set[tuple[int, int]] = set()
+            for paragraph_id, element_ids in links:
+                paragraph = await self._paragraph(session, workspace, paragraph_id, active=True)
+                if paragraph is None or not element_ids:
+                    raise WorkspaceError("LEGACY_REFERENCE_INVALID", "link paragraph is invalid")
+                for element_id in element_ids:
+                    element = await self._element(session, workspace, int(element_id), active=True)
+                    if element is None:
+                        raise WorkspaceError("LEGACY_REFERENCE_INVALID", "link element is invalid")
+                    pairs.add((paragraph_id, int(element_id)))
+            inserted = 0
+            for paragraph_id, element_id in sorted(pairs):
+                exists = (
+                    (
+                        await session.execute(
+                            select(script_build_paragraph_element.c.id).where(
+                                script_build_paragraph_element.c.script_build_id
+                                == workspace.script_build_id,
+                                script_build_paragraph_element.c.branch_id == workspace.branch_id,
+                                script_build_paragraph_element.c.paragraph_id == paragraph_id,
+                                script_build_paragraph_element.c.element_id == element_id,
+                            )
+                        )
+                    )
+                    .scalars()
+                    .one_or_none()
+                )
+                if exists is None:
+                    await session.execute(
+                        insert(script_build_paragraph_element).values(
+                            script_build_id=workspace.script_build_id,
+                            branch_id=workspace.branch_id,
+                            paragraph_id=paragraph_id,
+                            element_id=element_id,
+                        )
+                    )
+                    inserted += 1
+            return inserted
+
+    async def delete_links(
+        self,
+        context: CandidateWriteContext,
+        *,
+        pairs: Sequence[tuple[int, int]] = (),
+        paragraph_ids: Sequence[int] = (),
+        element_ids: Sequence[int] = (),
+        only_dangling: bool = False,
+    ) -> int:
+        if not (pairs or paragraph_ids or element_ids or only_dangling):
+            raise WorkspaceError("LEGACY_WRITE_INVALID", "link deletion selector is empty")
+        if only_dangling:
+            _authorize_write(context, entity="links", operation="delete", identities=())
+        for paragraph_id, element_id in pairs:
+            _authorize_link_write(context, paragraph_id, element_id, operation="delete")
+        for paragraph_id in paragraph_ids:
+            _authorize_write(
+                context, entity="links", operation="delete", identities=(paragraph_id,)
+            )
+        for element_id in element_ids:
+            _authorize_write(context, entity="links", operation="delete", identities=(element_id,))
+        async with self._sessions() as session, session.begin():
+            workspace = await self._locked_workspace(session, context)
+            _require_workspace_kind(workspace, _ELEMENT_KINDS)
+            rows = (
+                (
+                    await session.execute(
+                        select(script_build_paragraph_element).where(
+                            script_build_paragraph_element.c.script_build_id
+                            == workspace.script_build_id,
+                            script_build_paragraph_element.c.branch_id == workspace.branch_id,
+                        )
+                    )
+                )
+                .mappings()
+                .all()
+            )
+            paragraph_set = set(paragraph_ids)
+            element_set = set(element_ids)
+            pair_set = set(pairs)
+            active_paragraphs = {
+                int(item["id"])
+                for item in (
+                    (
+                        await session.execute(
+                            select(script_build_paragraph.c.id).where(
+                                script_build_paragraph.c.script_build_id
+                                == workspace.script_build_id,
+                                script_build_paragraph.c.branch_id == workspace.branch_id,
+                                script_build_paragraph.c.is_active.is_(True),
+                            )
+                        )
+                    )
+                    .mappings()
+                    .all()
+                )
+            }
+            active_elements = set(
+                (
+                    await session.execute(
+                        select(script_build_element.c.id).where(
+                            script_build_element.c.script_build_id == workspace.script_build_id,
+                            script_build_element.c.branch_id == workspace.branch_id,
+                            script_build_element.c.is_active.is_(True),
+                        )
+                    )
+                ).scalars()
+            )
+            delete_ids = [
+                int(row["id"])
+                for row in rows
+                if (int(row["paragraph_id"]), int(row["element_id"])) in pair_set
+                or int(row["paragraph_id"]) in paragraph_set
+                or int(row["element_id"]) in element_set
+                or (
+                    only_dangling
+                    and (
+                        int(row["paragraph_id"]) not in active_paragraphs
+                        or int(row["element_id"]) not in active_elements
+                    )
+                )
+            ]
+            if delete_ids:
+                await session.execute(
+                    delete(script_build_paragraph_element).where(
+                        script_build_paragraph_element.c.id.in_(delete_ids)
+                    )
+                )
+            return len(delete_ids)
+
+    async def freeze(
+        self,
+        context: CandidateWriteContext,
+        *,
+        lineage: CandidateLineageV1,
+        structured_script: dict[str, Any] | None = None,
+    ) -> tuple[ArtifactVersion, ArtifactRef]:
+        existing_workspace = await self.require(context)
+        base_artifact: Any | None = None
+        if existing_workspace.parent_artifact_version_id is not None:
+            parent = await self._artifacts.get_by_id(
+                existing_workspace.parent_artifact_version_id,
+                script_build_id=context.script_build_id,
+            )
+            if parent.state not in {ArtifactState.FROZEN, ArtifactState.PUBLISHED}:
+                raise WorkspaceError("STALE_BASE_REVISION", "workspace base is no longer immutable")
+            base_artifact = parent.artifact
+            if (
+                lineage.base_revision != parent.artifact_version_id
+                or lineage.base_artifact_ref
+                != f"script-build://artifact-versions/{parent.artifact_version_id}"
+                or lineage.base_artifact_digest != parent.canonical_sha256
+            ):
+                raise WorkspaceError(
+                    "STALE_BASE_REVISION",
+                    "lineage does not identify the workspace base exactly",
+                )
+        elif any(
+            value is not None
+            for value in (
+                lineage.base_revision,
+                lineage.base_artifact_ref,
+                lineage.base_artifact_digest,
+            )
+        ):
+            raise WorkspaceError(
+                "STALE_BASE_REVISION", "a base-less workspace cannot claim base lineage"
+            )
+        async with self._sessions() as session, session.begin():
+            workspace = await self._locked_workspace(session, context, require_draft=False)
+            if workspace.base_revision != lineage.base_revision:
+                raise WorkspaceError(
+                    "STALE_BASE_REVISION", "workspace base revision differs from the task contract"
+                )
+            if workspace.state is not ArtifactState.DRAFT:
+                version = await self._artifacts.get_by_id(
+                    workspace.artifact_version_id, script_build_id=context.script_build_id
+                )
+                stored_lineage = getattr(version.artifact, "lineage", None)
+                if stored_lineage is not None and not _same_lineage_contract(
+                    stored_lineage, lineage
+                ):
+                    raise WorkspaceError(
+                        "STALE_BASE_REVISION",
+                        "frozen candidate belongs to a different immutable input closure",
+                    )
+                return version, _ref(version)
+            snapshot = await self._snapshot_in_session(session, workspace)
+            if snapshot.source_identity_map:
+                lineage = replace(
+                    lineage,
+                    source_lineage=(
+                        *lineage.source_lineage,
+                        *snapshot.source_identity_map,
+                    ),
+                )
+            artifact = _workspace_artifact(
+                workspace,
+                snapshot,
+                lineage,
+                base_artifact=base_artifact,
+                structured_script=structured_script,
+            )
+            payload = cast(dict[str, Any], normalize_json(artifact.content_payload()))
+            digest = canonical_sha256(payload)
+            now = _now()
+            result = await session.execute(
+                update(artifact_version_table)
+                .where(
+                    artifact_version_table.c.id == workspace.artifact_version_id,
+                    artifact_version_table.c.state == ArtifactState.DRAFT.value,
+                )
+                .values(
+                    canonical_json=payload,
+                    canonical_sha256=digest.hex_value,
+                    state=ArtifactState.FROZEN.value,
+                    frozen_at=now,
+                )
+            )
+            if int(cast(Any, result).rowcount) != 1:
+                raise WorkspaceError(
+                    "ATTEMPT_WORKSPACE_FROZEN", "candidate was frozen concurrently"
+                )
+            await session.execute(
+                update(script_build_task_plan_step)
+                .where(
+                    script_build_task_plan_step.c.script_build_id == workspace.script_build_id,
+                    script_build_task_plan_step.c.branch_id == workspace.branch_id,
+                    script_build_task_plan_step.c.step_key == "1",
+                )
+                .values(
+                    status="完成",
+                    result_note=(
+                        f"script-build://artifact-versions/{workspace.artifact_version_id} "
+                        f"{digest.wire}"
+                    ),
+                    updated_at=now,
+                )
+            )
+        version = await self._artifacts.get_by_id(
+            workspace.artifact_version_id, script_build_id=context.script_build_id
+        )
+        return version, _ref(version)
+
+    async def discard(self, context: CandidateWriteContext, *, reason: str) -> CandidateWorkspace:
+        bounded_reason = " ".join(reason.split())
+        if not bounded_reason or len(bounded_reason) > 500:
+            raise WorkspaceError("LEGACY_WRITE_INVALID", "discard reason is invalid")
+        async with self._sessions() as session, session.begin():
+            workspace = await self._locked_workspace(session, context, require_draft=False)
+            if workspace.state is ArtifactState.DISCARDED:
+                return workspace
+            if workspace.state is not ArtifactState.DRAFT:
+                raise WorkspaceError(
+                    "ATTEMPT_WORKSPACE_FROZEN", "an immutable workspace cannot be discarded"
+                )
+            now = _now()
+            result = await session.execute(
+                update(artifact_version_table)
+                .where(
+                    artifact_version_table.c.id == workspace.artifact_version_id,
+                    artifact_version_table.c.state == ArtifactState.DRAFT.value,
+                )
+                .values(state=ArtifactState.DISCARDED.value)
+            )
+            if int(cast(Any, result).rowcount) != 1:
+                raise WorkspaceError(
+                    "ATTEMPT_WORKSPACE_FROZEN", "candidate workspace changed concurrently"
+                )
+            await session.execute(
+                update(script_build_task_plan_step)
+                .where(
+                    script_build_task_plan_step.c.script_build_id == workspace.script_build_id,
+                    script_build_task_plan_step.c.branch_id == workspace.branch_id,
+                    script_build_task_plan_step.c.step_key == "1",
+                )
+                .values(status="受阻", result_note=bounded_reason, updated_at=now)
+            )
+            return replace(workspace, state=ArtifactState.DISCARDED)
+
+    async def _locked_workspace(
+        self,
+        session: AsyncSession,
+        context: CandidateWriteContext,
+        *,
+        require_draft: bool = True,
+    ) -> CandidateWorkspace:
+        row = await self._row_by_attempt(session, context.attempt_id, lock=True)
+        if row is None:
+            raise WorkspaceError(
+                "ATTEMPT_WORKSPACE_NOT_FOUND", "candidate workspace does not exist"
+            )
+        workspace = self._workspace(row)
+        self._validate_context(workspace, context)
+        if require_draft and workspace.state is not ArtifactState.DRAFT:
+            raise WorkspaceError("ATTEMPT_WORKSPACE_FROZEN", "candidate workspace is immutable")
+        if not context.write_scope:
+            raise WorkspaceError("WRITE_SCOPE_VIOLATION", "task has no candidate write scope")
+        return workspace
+
+    @staticmethod
+    async def _row_by_attempt(
+        session: AsyncSession, attempt_id: str, *, lock: bool = False
+    ) -> Mapping[str, Any] | None:
+        statement = select(artifact_version_table).where(
+            artifact_version_table.c.attempt_id == attempt_id
+        )
+        if lock:
+            statement = statement.with_for_update()
+        return cast(
+            Mapping[str, Any] | None,
+            (await session.execute(statement)).mappings().one_or_none(),
+        )
+
+    @staticmethod
+    def _workspace(row: Mapping[str, Any]) -> CandidateWorkspace:
+        branch = row["legacy_branch_id"]
+        if branch is None:
+            raise WorkspaceError(
+                "LEGACY_WRITE_INVALID", "candidate workspace has no allocated branch"
+            )
+        return CandidateWorkspace(
+            artifact_version_id=int(row["id"]),
+            script_build_id=int(row["script_build_id"]),
+            task_id=str(row["task_id"]),
+            attempt_id=str(row["attempt_id"]),
+            spec_version=int(row["spec_version"]),
+            artifact_kind=ArtifactKind(str(row["artifact_type"])),
+            branch_id=int(branch),
+            state=ArtifactState(str(row["state"])),
+            base_revision=(int(row["base_revision"]) if row["base_revision"] is not None else None),
+            parent_artifact_version_id=(
+                int(row["parent_artifact_version_id"])
+                if row["parent_artifact_version_id"] is not None
+                else None
+            ),
+        )
+
+    @staticmethod
+    def _validate_context(workspace: CandidateWorkspace, context: CandidateWriteContext) -> None:
+        if (
+            workspace.script_build_id != context.script_build_id
+            or workspace.task_id != context.task_id
+            or workspace.attempt_id != context.attempt_id
+            or workspace.spec_version != context.spec_version
+        ):
+            raise WorkspaceError(
+                "LEGACY_REFERENCE_INVALID", "workspace is outside the protected attempt context"
+            )
+
+    def _validate_workspace(
+        self,
+        row: Mapping[str, Any],
+        context: CandidateWriteContext,
+        artifact_kind: ArtifactKind,
+        parent: ArtifactVersion | None,
+    ) -> CandidateWorkspace:
+        workspace = self._workspace(row)
+        self._validate_context(workspace, context)
+        expected_parent = parent.artifact_version_id if parent else None
+        if (
+            workspace.artifact_kind is not artifact_kind
+            or workspace.parent_artifact_version_id != expected_parent
+            or workspace.base_revision != expected_parent
+        ):
+            raise WorkspaceError(
+                "STALE_BASE_REVISION", "attempt already owns a different workspace contract"
+            )
+        return workspace
+
+    @staticmethod
+    async def _paragraph(
+        session: AsyncSession,
+        workspace: CandidateWorkspace,
+        paragraph_id: int,
+        *,
+        active: bool | None = None,
+    ) -> Mapping[str, Any] | None:
+        statement = select(script_build_paragraph).where(
+            script_build_paragraph.c.id == paragraph_id,
+            script_build_paragraph.c.script_build_id == workspace.script_build_id,
+            script_build_paragraph.c.branch_id == workspace.branch_id,
+        )
+        if active is not None:
+            statement = statement.where(script_build_paragraph.c.is_active.is_(active))
+        return cast(
+            Mapping[str, Any] | None,
+            (await session.execute(statement)).mappings().one_or_none(),
+        )
+
+    @staticmethod
+    async def _element(
+        session: AsyncSession,
+        workspace: CandidateWorkspace,
+        element_id: int,
+        *,
+        active: bool | None = None,
+    ) -> Mapping[str, Any] | None:
+        statement = select(script_build_element).where(
+            script_build_element.c.id == element_id,
+            script_build_element.c.script_build_id == workspace.script_build_id,
+            script_build_element.c.branch_id == workspace.branch_id,
+        )
+        if active is not None:
+            statement = statement.where(script_build_element.c.is_active.is_(active))
+        return cast(
+            Mapping[str, Any] | None,
+            (await session.execute(statement)).mappings().one_or_none(),
+        )
+
+    async def _snapshot_in_session(
+        self, session: AsyncSession, workspace: CandidateWorkspace
+    ) -> CandidateWorkspaceSnapshot:
+        paragraph_rows = (
+            (
+                await session.execute(
+                    select(script_build_paragraph)
+                    .where(
+                        script_build_paragraph.c.script_build_id == workspace.script_build_id,
+                        script_build_paragraph.c.branch_id == workspace.branch_id,
+                    )
+                    .order_by(
+                        script_build_paragraph.c.paragraph_index,
+                        script_build_paragraph.c.id,
+                    )
+                )
+            )
+            .mappings()
+            .all()
+        )
+        element_rows = (
+            (
+                await session.execute(
+                    select(script_build_element)
+                    .where(
+                        script_build_element.c.script_build_id == workspace.script_build_id,
+                        script_build_element.c.branch_id == workspace.branch_id,
+                    )
+                    .order_by(script_build_element.c.id)
+                )
+            )
+            .mappings()
+            .all()
+        )
+        link_rows = (
+            (
+                await session.execute(
+                    select(script_build_paragraph_element)
+                    .where(
+                        script_build_paragraph_element.c.script_build_id
+                        == workspace.script_build_id,
+                        script_build_paragraph_element.c.branch_id == workspace.branch_id,
+                    )
+                    .order_by(
+                        script_build_paragraph_element.c.paragraph_id,
+                        script_build_paragraph_element.c.element_id,
+                    )
+                )
+            )
+            .mappings()
+            .all()
+        )
+        source_identity_map: list[dict[str, int | str]] = [
+            {
+                "entity": "paragraph",
+                "source_local_id": int(row["base_ref_id"]),
+                "local_id": int(row["id"]),
+            }
+            for row in paragraph_rows
+            if row["base_ref_id"] is not None
+        ]
+        source_identity_map.extend(
+            {
+                "entity": "element",
+                "source_local_id": int(row["base_ref_id"]),
+                "local_id": int(row["id"]),
+            }
+            for row in element_rows
+            if row["base_ref_id"] is not None
+        )
+        return CandidateWorkspaceSnapshot(
+            workspace=workspace,
+            paragraphs=tuple(
+                _paragraph_record(cast(Mapping[str, Any], row)) for row in paragraph_rows
+            ),
+            elements=tuple(_element_record(cast(Mapping[str, Any], row)) for row in element_rows),
+            links=tuple(
+                ScriptParagraphElementLinkV1(
+                    paragraph_id=int(row["paragraph_id"]),
+                    element_id=int(row["element_id"]),
+                )
+                for row in link_rows
+            ),
+            source_identity_map=tuple(source_identity_map),
+        )
+
+    @staticmethod
+    async def _ensure_task_plan(
+        session: AsyncSession,
+        context: CandidateWriteContext,
+        workspace: CandidateWorkspace,
+    ) -> None:
+        existing = (
+            (
+                await session.execute(
+                    select(script_build_task_plan_step.c.id).where(
+                        script_build_task_plan_step.c.script_build_id == workspace.script_build_id,
+                        script_build_task_plan_step.c.branch_id == workspace.branch_id,
+                        script_build_task_plan_step.c.step_key == "1",
+                    )
+                )
+            )
+            .scalars()
+            .one_or_none()
+        )
+        if existing is not None:
+            return
+        await session.execute(
+            insert(script_build_task_plan_step).values(
+                script_build_id=workspace.script_build_id,
+                branch_id=workspace.branch_id,
+                round_index=None,
+                step_key="1",
+                title=context.objective[:500],
+                data_needs=json.dumps(
+                    list(context.input_refs), ensure_ascii=False, separators=(",", ":")
+                ),
+                status="进行中",
+                result_note=None,
+                depends_on=[],
+                sort_order=1,
+                created_at=_now(),
+                updated_at=_now(),
+            )
+        )
+
+    async def _materialize_base(
+        self,
+        session: AsyncSession,
+        workspace: CandidateWorkspace,
+        parent: ArtifactVersion,
+    ) -> None:
+        artifact = parent.artifact
+        paragraphs = getattr(artifact, "paragraphs", ())
+        elements = getattr(artifact, "elements", ())
+        links = getattr(artifact, "paragraph_element_links", ())
+        paragraph_map: dict[int, int] = {}
+        for item in sorted(paragraphs, key=lambda value: (value.level, value.paragraph_index)):
+            parent_id = paragraph_map.get(item.parent_id) if item.parent_id is not None else None
+            result = await session.execute(
+                insert(script_build_paragraph).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(),
+                )
+            )
+            paragraph_map[item.paragraph_id] = int(cast(Any, result).inserted_primary_key[0])
+        element_map: dict[int, int] = {}
+        for item in elements:
+            result = await session.execute(
+                insert(script_build_element).values(
+                    script_build_id=workspace.script_build_id,
+                    branch_id=workspace.branch_id,
+                    base_ref_id=item.element_id,
+                    name=item.name,
+                    dimension_primary=item.dimension_primary,
+                    dimension_secondary=item.dimension_secondary,
+                    commonality_analysis=item.commonality_analysis,
+                    topic_support=item.topic_support,
+                    weight_score=item.weight_score,
+                    support_elements=list(item.support_elements),
+                    is_active=item.is_active,
+                    created_at=_now(),
+                    updated_at=_now(),
+                )
+            )
+            element_map[item.element_id] = int(cast(Any, result).inserted_primary_key[0])
+        for item in links:
+            paragraph_id = paragraph_map.get(item.paragraph_id)
+            element_id = element_map.get(item.element_id)
+            if paragraph_id is not None and element_id is not None:
+                await session.execute(
+                    insert(script_build_paragraph_element).values(
+                        script_build_id=workspace.script_build_id,
+                        branch_id=workspace.branch_id,
+                        paragraph_id=paragraph_id,
+                        element_id=element_id,
+                    )
+                )
+
+
+def _workspace_artifact(
+    workspace: CandidateWorkspace,
+    snapshot: CandidateWorkspaceSnapshot,
+    lineage: CandidateLineageV1,
+    *,
+    base_artifact: Any | None,
+    structured_script: dict[str, Any] | None,
+) -> Any:
+    change_manifest = _change_manifest(snapshot, base_artifact)
+    if workspace.artifact_kind is ArtifactKind.STRUCTURE:
+        return StructureArtifactV1(lineage=lineage, paragraphs=snapshot.paragraphs)
+    if workspace.artifact_kind is ArtifactKind.PARAGRAPH:
+        return ParagraphArtifactV1(
+            lineage=lineage,
+            paragraphs=snapshot.paragraphs,
+            elements=snapshot.elements,
+            paragraph_element_links=snapshot.links,
+            change_manifest=change_manifest,
+        )
+    if workspace.artifact_kind is ArtifactKind.ELEMENT_SET:
+        return ElementSetArtifactV1(
+            lineage=lineage,
+            paragraphs=snapshot.paragraphs,
+            elements=snapshot.elements,
+            paragraph_element_links=snapshot.links,
+            change_manifest=change_manifest,
+        )
+    if workspace.artifact_kind is ArtifactKind.STRUCTURED_SCRIPT:
+        if structured_script is None:
+            raise WorkspaceError(
+                "LEGACY_WRITE_INVALID", "StructuredScript freeze requires its closed manifest"
+            )
+        return StructuredScriptArtifactV1(
+            direction_ref=str(structured_script.get("direction_ref", "")),
+            input_closure_digest=lineage.input_closure_digest,
+            paragraphs=snapshot.paragraphs,
+            elements=snapshot.elements,
+            paragraph_element_links=snapshot.links,
+            source_artifact_refs=tuple(structured_script.get("source_artifact_refs", ())),
+            evidence_refs=tuple(structured_script.get("evidence_refs", ())),
+            acceptance_notes=tuple(structured_script.get("acceptance_notes", ())),
+        )
+    raise WorkspaceError("LEGACY_WRITE_INVALID", "unsupported workspace artifact kind")
+
+
+def _change_manifest(
+    snapshot: CandidateWorkspaceSnapshot,
+    base_artifact: Any | None,
+) -> dict[str, Any]:
+    paragraph_source_by_local = {
+        int(item["local_id"]): int(item["source_local_id"])
+        for item in snapshot.source_identity_map
+        if item["entity"] == "paragraph"
+    }
+    element_source_by_local = {
+        int(item["local_id"]): int(item["source_local_id"])
+        for item in snapshot.source_identity_map
+        if item["entity"] == "element"
+    }
+    base_paragraphs = {item.paragraph_id: item for item in getattr(base_artifact, "paragraphs", ())}
+    base_elements = {item.element_id: item for item in getattr(base_artifact, "elements", ())}
+
+    created_paragraph_ids = [
+        item.paragraph_id
+        for item in snapshot.paragraphs
+        if item.paragraph_id not in paragraph_source_by_local
+    ]
+    created_element_ids = [
+        item.element_id
+        for item in snapshot.elements
+        if item.element_id not in element_source_by_local
+    ]
+    updated_paragraph_ids = [
+        item.paragraph_id
+        for item in snapshot.paragraphs
+        if (source_id := paragraph_source_by_local.get(item.paragraph_id)) is not None
+        and source_id in base_paragraphs
+        and _paragraph_comparison_payload(item, paragraph_source_by_local)
+        != _paragraph_comparison_payload(base_paragraphs[source_id], {})
+    ]
+    updated_element_ids = [
+        item.element_id
+        for item in snapshot.elements
+        if (source_id := element_source_by_local.get(item.element_id)) is not None
+        and source_id in base_elements
+        and item.to_payload() | {"element_id": source_id} != base_elements[source_id].to_payload()
+    ]
+
+    base_links = {
+        (item.paragraph_id, item.element_id)
+        for item in getattr(base_artifact, "paragraph_element_links", ())
+    }
+    current_base_links: set[tuple[int, int]] = set()
+    created_links: list[dict[str, int]] = []
+    for item in snapshot.links:
+        source_paragraph = paragraph_source_by_local.get(item.paragraph_id)
+        source_element = element_source_by_local.get(item.element_id)
+        if source_paragraph is not None and source_element is not None:
+            current_base_links.add((source_paragraph, source_element))
+        else:
+            created_links.append(item.to_payload())
+
+    return {
+        "created": {
+            "paragraph_ids": created_paragraph_ids,
+            "element_ids": created_element_ids,
+            "links": created_links,
+        },
+        "updated": {
+            "paragraph_ids": updated_paragraph_ids,
+            "element_ids": updated_element_ids,
+        },
+        "deactivated": {
+            "paragraph_ids": [
+                item.paragraph_id for item in snapshot.paragraphs if not item.is_active
+            ],
+            "element_ids": [item.element_id for item in snapshot.elements if not item.is_active],
+        },
+        "deleted_links": [
+            {"paragraph_id": paragraph_id, "element_id": element_id}
+            for paragraph_id, element_id in sorted(base_links - current_base_links)
+        ],
+    }
+
+
+def _paragraph_comparison_payload(
+    paragraph: ScriptParagraphV1,
+    source_by_local: Mapping[int, int],
+) -> dict[str, Any]:
+    payload = paragraph.to_payload()
+    payload["paragraph_id"] = source_by_local.get(paragraph.paragraph_id, paragraph.paragraph_id)
+    if paragraph.parent_id is not None:
+        payload["parent_id"] = source_by_local.get(paragraph.parent_id, paragraph.parent_id)
+    return payload
+
+
+def _same_lineage_contract(first: CandidateLineageV1, second: CandidateLineageV1) -> bool:
+    return (
+        first.scope_ref == second.scope_ref
+        and first.input_snapshot_ref == second.input_snapshot_ref
+        and first.input_closure_digest == second.input_closure_digest
+        and first.write_scope == second.write_scope
+        and first.base_artifact_ref == second.base_artifact_ref
+        and first.base_artifact_digest == second.base_artifact_digest
+        and first.base_revision == second.base_revision
+        and first.supersedes_decision_ids == second.supersedes_decision_ids
+    )
+
+
+def _paragraph_record(row: Mapping[str, Any]) -> ScriptParagraphV1:
+    return ScriptParagraphV1(
+        paragraph_id=int(row["id"]),
+        paragraph_index=int(row["paragraph_index"]),
+        level=int(row["level"]),
+        parent_id=int(row["parent_id"]) if row["parent_id"] is not None else None,
+        name=str(row["name"] or ""),
+        content_range=dict(row["content_range"] or {}),
+        theme=row["theme"],
+        form=row["form"],
+        function=row["function"],
+        feeling=row["feeling"],
+        theme_elements=tuple(row["theme_elements"] or ()),
+        form_elements=tuple(row["form_elements"] or ()),
+        function_elements=tuple(row["function_elements"] or ()),
+        feeling_elements=tuple(row["feeling_elements"] or ()),
+        description=row["description"],
+        full_description=row["full_description"],
+        is_active=bool(row["is_active"]),
+    )
+
+
+def _element_record(row: Mapping[str, Any]) -> ScriptElementV1:
+    return ScriptElementV1(
+        element_id=int(row["id"]),
+        name=str(row["name"]),
+        dimension_primary=str(row["dimension_primary"]),
+        dimension_secondary=str(row["dimension_secondary"]),
+        commonality_analysis=(
+            dict(row["commonality_analysis"]) if row["commonality_analysis"] is not None else None
+        ),
+        topic_support=dict(row["topic_support"]) if row["topic_support"] is not None else None,
+        weight_score=dict(row["weight_score"]) if row["weight_score"] is not None else None,
+        support_elements=tuple(row["support_elements"] or ()),
+        is_active=bool(row["is_active"]),
+    )
+
+
+def _ref(version: ArtifactVersion) -> ArtifactRef:
+    return ArtifactRef(
+        uri=f"script-build://artifact-versions/{version.artifact_version_id}",
+        kind=version.artifact_type.value,
+        version=str(version.artifact_version_id),
+        digest=version.canonical_sha256,
+        summary=(
+            f"kind={version.artifact_type.value};version={version.artifact_version_id};"
+            f"digest={version.canonical_sha256}"
+        ),
+    )
+
+
+def _now() -> datetime:
+    return datetime.now(UTC)
+
+
+def _positive_int(value: Any, label: str) -> int:
+    if isinstance(value, bool):
+        raise WorkspaceError("LEGACY_WRITE_INVALID", f"{label} must be a positive integer")
+    try:
+        result = int(value)
+    except (TypeError, ValueError) as exc:
+        raise WorkspaceError("LEGACY_WRITE_INVALID", f"{label} must be a positive integer") from exc
+    if result < 1:
+        raise WorkspaceError("LEGACY_WRITE_INVALID", f"{label} must be a positive integer")
+    return result
+
+
+def _atoms(values: Sequence[Mapping[str, Any]], *, feeling: bool) -> list[dict[str, Any]]:
+    if not values:
+        return []
+    output: list[dict[str, Any]] = []
+    dimensions: set[str] = set()
+    for value in values:
+        atom = str(value.get("原子点", "")).strip()
+        dimension = str(value.get("维度", "")).strip()
+        kind = value.get("维度类型")
+        if not atom or not dimension or (not feeling and kind not in {"主维度", "从维度"}):
+            raise WorkspaceError("LEGACY_WRITE_INVALID", "paragraph atom is invalid")
+        if dimension in dimensions:
+            raise WorkspaceError("LEGACY_WRITE_INVALID", "one atom batch cannot repeat a dimension")
+        dimensions.add(dimension)
+        normalized: dict[str, Any] = {"原子点": atom, "维度": dimension}
+        if not feeling:
+            normalized["维度类型"] = kind
+        if "id" in value:
+            normalized["id"] = value["id"]
+        output.append(normalized)
+    return output
+
+
+def _element_fields(name: str, primary: str, secondary: str) -> None:
+    if primary not in {"实质", "形式"} or not name.strip() or not secondary.strip():
+        raise WorkspaceError("LEGACY_WRITE_INVALID", "element fields are invalid")
+
+
+def _content_range_subset(child: Mapping[str, Any], parent: Mapping[str, Any]) -> bool:
+    for key, value in child.items():
+        if key not in parent:
+            return False
+        parent_value = parent[key]
+        if isinstance(value, list) and isinstance(parent_value, list):
+            if not set(value) <= set(parent_value):
+                return False
+        elif value != parent_value:
+            return False
+    return True
+
+
+def _authorize_write(
+    context: CandidateWriteContext,
+    *,
+    entity: str,
+    operation: str,
+    identities: Sequence[int | str],
+    content_range: Mapping[str, Any] | None = None,
+) -> None:
+    scopes = tuple(context.write_scope)
+    if not scopes:
+        raise WorkspaceError("WRITE_SCOPE_VIOLATION", "task has no candidate write scope")
+    if any(
+        value in {"script-build://writes", "script-build://writes/full"}
+        or value.startswith("script-build://scopes/")
+        for value in scopes
+    ):
+        return
+    entity_prefix = f"script-build://writes/{entity}"
+    operation_ref = f"script-build://writes/operations/{operation}"
+    for value in scopes:
+        if value in {entity_prefix, f"{entity_prefix}/*", operation_ref}:
+            return
+        if any(value == f"{entity_prefix}/{identity}" for identity in identities):
+            return
+        if value.startswith(f"{entity_prefix}/"):
+            selector = value.removeprefix(f"{entity_prefix}/").casefold()
+            # A named selector denotes the immutable business scope owned by this
+            # workspace; numeric selectors continue to address one local entity.
+            if selector and not selector.isdigit():
+                return
+            if selector and any(
+                selector in str(identity).casefold().replace("_", "-").split()
+                or str(identity).casefold().endswith(selector)
+                for identity in identities
+            ):
+                return
+    if entity == "paragraphs" and content_range is not None:
+        requested = tuple(
+            f"script-build://writes/content-ranges/{key}/{value}"
+            for key, raw in content_range.items()
+            for value in (raw if isinstance(raw, list) else [raw])
+        )
+        if requested and all(value in scopes for value in requested):
+            return
+    raise WorkspaceError(
+        "WRITE_SCOPE_VIOLATION",
+        f"{operation} on {entity} is outside the immutable task write scope",
+    )
+
+
+def _authorize_link_write(
+    context: CandidateWriteContext,
+    paragraph_id: int,
+    element_id: int,
+    *,
+    operation: str,
+) -> None:
+    scopes = tuple(context.write_scope)
+    pair_ref = f"script-build://writes/links/{paragraph_id}/{element_id}"
+    if pair_ref in scopes:
+        return
+    if any(
+        value == "script-build://writes/elements"
+        or value.startswith("script-build://writes/elements/")
+        for value in scopes
+    ):
+        # Element-set candidates own their paragraph placement links; repository
+        # ownership checks below still require both endpoints in this workspace.
+        return
+    _authorize_write(
+        context,
+        entity="links",
+        operation=operation,
+        identities=(paragraph_id, element_id),
+    )
+
+
+def _require_workspace_kind(
+    workspace: CandidateWorkspace, allowed: frozenset[ArtifactKind]
+) -> None:
+    if workspace.artifact_kind not in allowed:
+        raise WorkspaceError(
+            "WRITE_SCOPE_VIOLATION",
+            f"{workspace.artifact_kind.value} workspace cannot use this write adapter",
+        )
+
+
+__all__ = ["SqlAlchemyCandidateWorkspaceRepository"]