Kaynağa Gözat

持久化:建立阶段一业务表与幂等仓储

新增 Mission binding、Input snapshot、Artifact version 和 Publication 四张表及可回滚 Alembic 迁移。

实现读写数据库会话、旧构建状态投影、快照冻结、制品归属校验、发布状态和 active direction 指针的幂等仓储。
SamLee 19 saat önce
ebeveyn
işleme
fedd03edbc

+ 39 - 0
script_build_host/alembic.ini

@@ -0,0 +1,39 @@
+[alembic]
+script_location = migrations
+prepend_sys_path = .
+path_separator = os
+sqlalchemy.url =
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 1 - 0
script_build_host/migrations/CHECKSUMS.sha256

@@ -0,0 +1 @@
+0d524c7647d567ec3fba3c6d747ec09c2c96c9805506857adf14996a2539bff7  migrations/versions/0001_phase_one_business_tables.py

+ 6 - 0
script_build_host/migrations/README.md

@@ -0,0 +1,6 @@
+# Script Build Host migrations
+
+Production schema changes run only through the numbered Alembic revisions in `versions/`.
+Runtime code must not invoke `metadata.create_all()`. `CHECKSUMS.sha256` pins every applied
+revision; deployment verifies it before `alembic upgrade` and uses the revision's `downgrade()`
+for rollback. Tests may create the same metadata in a temporary SQLite database.

+ 61 - 0
script_build_host/migrations/env.py

@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+import asyncio
+import os
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import pool
+from sqlalchemy.ext.asyncio import async_engine_from_config
+
+from script_build_host.infrastructure.tables import metadata
+
+config = context.config
+if config.config_file_name is not None:
+    fileConfig(config.config_file_name)
+target_metadata = metadata
+
+
+def _database_url() -> str:
+    arguments = context.get_x_argument(as_dictionary=True)
+    value = arguments.get("database_url") or os.getenv("SCRIPT_BUILD_WRITE_DATABASE_URL")
+    if not value:
+        raise RuntimeError(
+            "migration database URL is required via -x database_url=... or "
+            "SCRIPT_BUILD_WRITE_DATABASE_URL"
+        )
+    return value
+
+
+def run_migrations_offline() -> None:
+    url = _database_url()
+    context.configure(
+        url=url,
+        target_metadata=target_metadata,
+        literal_binds=True,
+        dialect_opts={"paramstyle": "named"},
+        compare_type=True,
+    )
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def _run_migrations(connection: object) -> None:
+    context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+async def run_migrations_online() -> None:
+    configuration = config.get_section(config.config_ini_section, {})
+    configuration["sqlalchemy.url"] = _database_url()
+    engine = async_engine_from_config(configuration, prefix="sqlalchemy.", poolclass=pool.NullPool)
+    async with engine.connect() as connection:
+        await connection.run_sync(_run_migrations)
+    await engine.dispose()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    asyncio.run(run_migrations_online())

+ 26 - 0
script_build_host/migrations/script.py.mako

@@ -0,0 +1,26 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+revision: str = ${repr(up_revision)}
+down_revision: str | None = ${repr(down_revision)}
+branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
+depends_on: str | Sequence[str] | None = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+    ${downgrades if downgrades else "pass"}

+ 135 - 0
script_build_host/migrations/versions/0001_phase_one_business_tables.py

@@ -0,0 +1,135 @@
+"""Create the four phase-one script-build business tables.
+
+Revision ID: 0001_phase_one
+Revises:
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0001_phase_one"
+down_revision: str | None = None
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "script_build_mission_binding",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("script_build_id", sa.BigInteger(), nullable=False),
+        sa.Column("root_trace_id", sa.String(128), nullable=False),
+        sa.Column("input_snapshot_id", sa.BigInteger(), nullable=False),
+        sa.Column("active_direction_artifact_version_id", sa.BigInteger()),
+        sa.Column("accepted_root_artifact_version_id", sa.BigInteger()),
+        sa.Column("engine_version", sa.String(64), nullable=False),
+        sa.Column("schema_version", sa.String(32), nullable=False),
+        sa.Column("created_at", sa.DateTime(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("script_build_id", name="uq_binding_build"),
+        sa.UniqueConstraint("root_trace_id", name="uq_binding_root"),
+        mysql_engine="InnoDB",
+        mysql_charset="utf8mb4",
+    )
+    op.create_table(
+        "script_build_input_snapshot",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("script_build_id", sa.BigInteger(), nullable=False),
+        sa.Column("version", sa.Integer(), nullable=False),
+        sa.Column("canonical_json", sa.JSON(), nullable=False),
+        sa.Column("canonical_sha256", sa.CHAR(64), nullable=False),
+        sa.Column("prompt_manifest_json", sa.JSON(), nullable=False),
+        sa.Column("schema_version", sa.String(32), nullable=False),
+        sa.Column("created_at", sa.DateTime(), nullable=False),
+        sa.CheckConstraint("length(canonical_sha256) = 64", name="ck_input_sha256_length"),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("script_build_id", "canonical_sha256", name="uq_input_hash"),
+        sa.UniqueConstraint("script_build_id", "version", name="uq_input_version"),
+        mysql_engine="InnoDB",
+        mysql_charset="utf8mb4",
+    )
+    op.create_table(
+        "script_build_artifact_version",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("script_build_id", sa.BigInteger(), nullable=False),
+        sa.Column("task_id", sa.String(128), nullable=False),
+        sa.Column("attempt_id", sa.String(128), nullable=False),
+        sa.Column("spec_version", sa.Integer(), nullable=False),
+        sa.Column("artifact_type", sa.String(64), nullable=False),
+        sa.Column("legacy_branch_id", sa.BigInteger()),
+        sa.Column("base_revision", sa.BigInteger()),
+        sa.Column("parent_artifact_version_id", sa.BigInteger()),
+        sa.Column("canonical_json", sa.JSON()),
+        sa.Column("canonical_sha256", sa.CHAR(64)),
+        sa.Column("state", sa.String(16), nullable=False),
+        sa.Column("created_at", sa.DateTime(), nullable=False),
+        sa.Column("frozen_at", sa.DateTime()),
+        sa.Column("published_at", sa.DateTime()),
+        sa.CheckConstraint(
+            "artifact_type IN ('evidence', 'direction')", name="ck_artifact_phase_one_type"
+        ),
+        sa.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)",
+            name="ck_artifact_frozen_content",
+        ),
+        sa.CheckConstraint(
+            "state IN ('draft', 'frozen', 'published', 'discarded')", name="ck_artifact_state"
+        ),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("script_build_id", "legacy_branch_id", name="uq_artifact_branch"),
+        sa.UniqueConstraint("attempt_id", name="uq_artifact_attempt"),
+        mysql_engine="InnoDB",
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "ix_artifact_task", "script_build_artifact_version", ["script_build_id", "task_id"]
+    )
+    op.create_table(
+        "script_build_publication",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("script_build_id", sa.BigInteger(), nullable=False),
+        sa.Column("publication_type", sa.String(16), nullable=False),
+        sa.Column("accept_decision_id", sa.String(128), nullable=False),
+        sa.Column("artifact_version_id", sa.BigInteger(), nullable=False),
+        sa.Column("expected_sha256", sa.CHAR(64), nullable=False),
+        sa.Column("state", sa.String(16), nullable=False),
+        sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
+        sa.Column("publication_revision", sa.BigInteger(), server_default="0", nullable=False),
+        sa.Column("last_error_code", sa.String(64)),
+        sa.Column("last_error_summary", sa.String(1000)),
+        sa.Column("created_at", sa.DateTime(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), nullable=False),
+        sa.Column("published_at", sa.DateTime()),
+        sa.CheckConstraint(
+            "publication_type IN ('direction', 'final')", name="ck_publication_type"
+        ),
+        sa.CheckConstraint(
+            "state IN ('pending', 'publishing', 'published', 'failed')",
+            name="ck_publication_state",
+        ),
+        sa.CheckConstraint("length(expected_sha256) = 64", name="ck_publication_sha256_length"),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("accept_decision_id", name="uq_publication_decision"),
+        mysql_engine="InnoDB",
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "ix_publication_build",
+        "script_build_publication",
+        ["script_build_id", "publication_type", "state"],
+    )
+
+
+def downgrade() -> None:
+    op.drop_index("ix_publication_build", table_name="script_build_publication")
+    op.drop_table("script_build_publication")
+    op.drop_index("ix_artifact_task", table_name="script_build_artifact_version")
+    op.drop_table("script_build_artifact_version")
+    op.drop_table("script_build_input_snapshot")
+    op.drop_table("script_build_mission_binding")

+ 36 - 0
script_build_host/src/script_build_host/infrastructure/db.py

@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from sqlalchemy.ext.asyncio import (
+    AsyncEngine,
+    AsyncSession,
+    async_sessionmaker,
+    create_async_engine,
+)
+
+from .config import ScriptBuildSettings
+
+
+@dataclass(frozen=True, slots=True)
+class DatabaseSessions:
+    read_engine: AsyncEngine
+    write_engine: AsyncEngine
+    read: async_sessionmaker[AsyncSession]
+    write: async_sessionmaker[AsyncSession]
+
+    async def dispose(self) -> None:
+        await self.read_engine.dispose()
+        if self.write_engine is not self.read_engine:
+            await self.write_engine.dispose()
+
+
+def create_database_sessions(settings: ScriptBuildSettings) -> DatabaseSessions:
+    read_engine = create_async_engine(settings.read_dsn(), pool_pre_ping=True)
+    write_engine = create_async_engine(settings.write_dsn(), pool_pre_ping=True)
+    return DatabaseSessions(
+        read_engine=read_engine,
+        write_engine=write_engine,
+        read=async_sessionmaker(read_engine, expire_on_commit=False),
+        write=async_sessionmaker(write_engine, expire_on_commit=False),
+    )

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

@@ -0,0 +1,202 @@
+from __future__ import annotations
+
+from sqlalchemy import (
+    JSON,
+    BigInteger,
+    Boolean,
+    Column,
+    DateTime,
+    Float,
+    Integer,
+    MetaData,
+    String,
+    Table,
+    Text,
+)
+
+legacy_metadata = MetaData()
+_primary_key_type = BigInteger().with_variant(Integer, "sqlite")
+
+topic_pattern_execution = Table(
+    "topic_pattern_execution",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True),
+    Column("snapshot_id", String(36)),
+    Column("status", String(50), nullable=False),
+    Column("error_message", Text),
+    Column("start_time", DateTime),
+    Column("end_time", DateTime),
+)
+
+topic_build_record = Table(
+    "topic_build_record",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True),
+    Column("execution_id", BigInteger, nullable=False),
+    Column("demand", Text),
+    Column("demand_constraints", JSON),
+    Column("agent_type", String(50)),
+    Column("agent_config", JSON),
+    Column("status", String(50), nullable=False),
+    Column("is_deleted", Boolean, nullable=False, default=False),
+    Column("strategies_config", JSON),
+    Column("personal_config", JSON),
+    Column("origin", String(32), nullable=False),
+    Column("raw_upload_data", JSON),
+    Column("start_time", DateTime),
+    Column("end_time", DateTime),
+)
+
+topic_build_topic = Table(
+    "topic_build_topic",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True),
+    Column("build_id", BigInteger, nullable=False),
+    Column("execution_id", BigInteger, nullable=False),
+    Column("sort_order", Integer, nullable=False),
+    Column("topic_direction", Text),
+    Column("topic_understanding", Text),
+    Column("inspiration_evaluation_config", Text),
+    Column("result", Text),
+    Column("status", String(50), nullable=False),
+    Column("failure_reason", Text),
+)
+
+topic_build_point = Table(
+    "topic_build_point",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True),
+    Column("topic_id", BigInteger, nullable=False),
+    Column("build_id", BigInteger, nullable=False),
+    Column("point_type", String(64), nullable=False),
+    Column("point_result", String(255), nullable=False),
+    Column("reason", Text),
+    Column("is_active", Boolean, nullable=False),
+    Column("note", Text),
+    Column("created_at", DateTime),
+    Column("updated_at", DateTime),
+)
+
+topic_build_point_item_relation = Table(
+    "topic_build_point_item_relation",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True),
+    Column("point_id", BigInteger, nullable=False),
+    Column("item_id", BigInteger, nullable=False),
+)
+
+topic_build_composition_item = Table(
+    "topic_build_composition_item",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True),
+    Column("topic_id", BigInteger, nullable=False),
+    Column("build_id", BigInteger, nullable=False),
+    Column("item_level", String(20), nullable=False),
+    Column("dimension", String(50)),
+    Column("point_type", String(50)),
+    Column("element_name", String(500), nullable=False),
+    Column("category_path", String(1000)),
+    Column("category_id", BigInteger),
+    Column("derivation_type", String(50)),
+    Column("step", Integer, nullable=False),
+    Column("reason", Text),
+    Column("sort_order", Integer, nullable=False),
+    Column("is_active", Boolean, nullable=False),
+    Column("note", Text),
+    Column("created_at", DateTime),
+    Column("updated_at", DateTime),
+)
+
+topic_build_item_relation = Table(
+    "topic_build_item_relation",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True),
+    Column("topic_id", BigInteger, nullable=False),
+    Column("source_item_id", BigInteger, nullable=False),
+    Column("target_item_id", BigInteger, nullable=False),
+    Column("reason", Text),
+    Column("created_at", DateTime),
+)
+
+topic_build_item_source = Table(
+    "topic_build_item_source",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True),
+    Column("topic_id", BigInteger, nullable=False),
+    Column("target_item_id", BigInteger, nullable=False),
+    Column("derivation_type", String(50)),
+    Column("source_type", String(50), nullable=False),
+    Column("source_reference_id", String(200)),
+    Column("source_detail", JSON),
+    Column("dataset_from", String(32)),
+    Column("reason", Text),
+    Column("is_active", Boolean, nullable=False),
+    Column("created_at", DateTime),
+)
+
+build_strategy = Table(
+    "build_strategy",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True),
+    Column("strategy_type", String(50), nullable=False),
+    Column("name", String(200), nullable=False),
+    Column("description", String(500), nullable=False),
+    Column("tags", JSON),
+    Column("is_active", Boolean, nullable=False),
+    Column("current_version", Integer, nullable=False),
+    Column("created_at", DateTime),
+    Column("updated_at", DateTime),
+)
+
+build_strategy_version = Table(
+    "build_strategy_version",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True),
+    Column("strategy_id", BigInteger, nullable=False),
+    Column("version", Integer, nullable=False),
+    Column("content", Text, nullable=False),
+    Column("change_note", String(500)),
+    Column("created_at", DateTime),
+)
+
+prompt = Table(
+    "prompt",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True),
+    Column("biz_type", String(32), nullable=False),
+    Column("group_name", String(255)),
+    Column("name", String(100), nullable=False),
+    Column("prompt_content", Text, nullable=False),
+    Column("current_version", Integer, nullable=False),
+    Column("change_note", String(500)),
+    Column("create_time", DateTime),
+    Column("update_time", DateTime),
+)
+
+script_build_record = Table(
+    "script_build_record",
+    legacy_metadata,
+    Column("id", _primary_key_type, primary_key=True, autoincrement=True),
+    Column("execution_id", BigInteger, nullable=False),
+    Column("topic_build_id", BigInteger, nullable=False),
+    Column("topic_id", BigInteger, nullable=False),
+    Column("agent_type", String(50)),
+    Column("agent_config", JSON),
+    Column("data_source_url", String(500)),
+    Column("status", String(50), nullable=False),
+    Column("reson_trace_id", String(200)),
+    Column("is_deleted", Boolean, nullable=False, default=False),
+    Column("is_favorited", Boolean, nullable=False, default=False),
+    Column("summary", Text),
+    Column("error_message", Text),
+    Column("script_direction", Text),
+    Column("build_workflow", JSON),
+    Column("paragraph_count", Integer),
+    Column("element_count", Integer),
+    Column("input_tokens", Integer),
+    Column("output_tokens", Integer),
+    Column("cost_usd", Float),
+    Column("strategies_config", JSON),
+    Column("start_time", DateTime),
+    Column("end_time", DateTime),
+)

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

@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from sqlalchemy import (
+    JSON,
+    BigInteger,
+    CheckConstraint,
+    Column,
+    DateTime,
+    Index,
+    Integer,
+    MetaData,
+    String,
+    Table,
+    UniqueConstraint,
+)
+
+metadata = MetaData()
+_primary_key_type = BigInteger().with_variant(Integer, "sqlite")
+
+mission_binding_table = Table(
+    "script_build_mission_binding",
+    metadata,
+    Column("id", _primary_key_type, primary_key=True, autoincrement=True),
+    Column("script_build_id", BigInteger, nullable=False),
+    Column("root_trace_id", String(128), nullable=False),
+    Column("input_snapshot_id", BigInteger, nullable=False),
+    Column("active_direction_artifact_version_id", BigInteger),
+    Column("accepted_root_artifact_version_id", BigInteger),
+    Column("engine_version", String(64), nullable=False),
+    Column("schema_version", String(32), nullable=False),
+    Column("created_at", DateTime(timezone=True), nullable=False),
+    Column("updated_at", DateTime(timezone=True), nullable=False),
+    UniqueConstraint("script_build_id", name="uq_binding_build"),
+    UniqueConstraint("root_trace_id", name="uq_binding_root"),
+)
+
+input_snapshot_table = Table(
+    "script_build_input_snapshot",
+    metadata,
+    Column("id", _primary_key_type, primary_key=True, autoincrement=True),
+    Column("script_build_id", BigInteger, nullable=False),
+    Column("version", Integer, nullable=False),
+    Column("canonical_json", JSON, nullable=False),
+    Column("canonical_sha256", String(64), nullable=False),
+    Column("prompt_manifest_json", JSON, nullable=False),
+    Column("schema_version", String(32), nullable=False),
+    Column("created_at", DateTime(timezone=True), nullable=False),
+    UniqueConstraint("script_build_id", "version", name="uq_input_version"),
+    UniqueConstraint("script_build_id", "canonical_sha256", name="uq_input_hash"),
+    CheckConstraint("length(canonical_sha256) = 64", name="ck_input_sha256_length"),
+)
+
+artifact_version_table = Table(
+    "script_build_artifact_version",
+    metadata,
+    Column("id", _primary_key_type, primary_key=True, autoincrement=True),
+    Column("script_build_id", BigInteger, nullable=False),
+    Column("task_id", String(128), nullable=False),
+    Column("attempt_id", String(128), nullable=False),
+    Column("spec_version", Integer, nullable=False),
+    Column("artifact_type", String(64), nullable=False),
+    Column("legacy_branch_id", BigInteger),
+    Column("base_revision", BigInteger),
+    Column("parent_artifact_version_id", BigInteger),
+    Column("canonical_json", JSON),
+    Column("canonical_sha256", String(64)),
+    Column("state", String(16), nullable=False),
+    Column("created_at", DateTime(timezone=True), nullable=False),
+    Column("frozen_at", DateTime(timezone=True)),
+    Column("published_at", DateTime(timezone=True)),
+    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"
+    ),
+    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)",
+        name="ck_artifact_frozen_content",
+    ),
+    Index("ix_artifact_task", "script_build_id", "task_id"),
+)
+
+publication_table = Table(
+    "script_build_publication",
+    metadata,
+    Column("id", _primary_key_type, primary_key=True, autoincrement=True),
+    Column("script_build_id", BigInteger, nullable=False),
+    Column("publication_type", String(16), nullable=False),
+    Column("accept_decision_id", String(128), nullable=False),
+    Column("artifact_version_id", BigInteger, nullable=False),
+    Column("expected_sha256", String(64), nullable=False),
+    Column("state", String(16), nullable=False),
+    Column("attempt_count", Integer, nullable=False, default=0),
+    Column("publication_revision", BigInteger, nullable=False, default=0),
+    Column("last_error_code", String(64)),
+    Column("last_error_summary", String(1000)),
+    Column("created_at", DateTime(timezone=True), nullable=False),
+    Column("updated_at", DateTime(timezone=True), nullable=False),
+    Column("published_at", DateTime(timezone=True)),
+    UniqueConstraint("accept_decision_id", name="uq_publication_decision"),
+    CheckConstraint("publication_type IN ('direction', 'final')", name="ck_publication_type"),
+    CheckConstraint(
+        "state IN ('pending', 'publishing', 'published', 'failed')", name="ck_publication_state"
+    ),
+    CheckConstraint("length(expected_sha256) = 64", name="ck_publication_sha256_length"),
+    Index("ix_publication_build", "script_build_id", "publication_type", "state"),
+)

+ 100 - 0
script_build_host/src/script_build_host/repositories/legacy_state.py

@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Any, cast
+
+from sqlalchemy import CursorResult, insert, select, update
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from script_build_host.domain.errors import BuildNotFound, ProtocolViolation
+from script_build_host.domain.records import BuildStatus
+from script_build_host.infrastructure.legacy_tables import script_build_record
+from script_build_host.infrastructure.redaction import redact_text
+
+
+class SqlAlchemyLegacyBuildStateRepository:
+    def __init__(self, sessions: async_sessionmaker[AsyncSession]) -> None:
+        self._sessions = sessions
+
+    async def create(
+        self,
+        *,
+        execution_id: int,
+        topic_build_id: int,
+        topic_id: int,
+        agent_type: str | None,
+        agent_config: dict[str, Any] | None,
+        data_source_url: str | None,
+        strategies_config: dict[str, Any],
+    ) -> int:
+        async with self._sessions() as session, session.begin():
+            result = await session.execute(
+                insert(script_build_record).values(
+                    execution_id=execution_id,
+                    topic_build_id=topic_build_id,
+                    topic_id=topic_id,
+                    agent_type=agent_type,
+                    agent_config=agent_config,
+                    data_source_url=data_source_url,
+                    strategies_config=strategies_config,
+                    status=BuildStatus.RUNNING.value,
+                    is_deleted=False,
+                    is_favorited=False,
+                    start_time=datetime.now(UTC),
+                )
+            )
+            primary_key = cast(CursorResult[Any], result).inserted_primary_key
+            if primary_key is None or primary_key[0] is None:
+                raise BuildNotFound()
+            return int(primary_key[0])
+
+    async def set_status(
+        self, script_build_id: int, status: BuildStatus, *, error_summary: str | None = None
+    ) -> None:
+        if status is BuildStatus.SUCCESS:
+            raise ProtocolViolation("phase one cannot project script build success")
+        values: dict[str, Any] = {"status": status.value}
+        if error_summary is not None:
+            values["error_message"] = redact_text(error_summary)
+        if status in {
+            BuildStatus.FAILED,
+            BuildStatus.PARTIAL,
+            BuildStatus.STOPPED,
+        }:
+            values["end_time"] = datetime.now(UTC)
+        async with self._sessions() as session, session.begin():
+            result = await session.execute(
+                update(script_build_record)
+                .where(
+                    script_build_record.c.id == script_build_id,
+                    script_build_record.c.is_deleted.is_(False),
+                )
+                .values(**values)
+            )
+            if cast(CursorResult[Any], result).rowcount != 1:
+                raise BuildNotFound()
+
+    async def get_status(self, script_build_id: int) -> BuildStatus:
+        async with self._sessions() as session:
+            value = await session.scalar(
+                select(script_build_record.c.status).where(
+                    script_build_record.c.id == script_build_id,
+                    script_build_record.c.is_deleted.is_(False),
+                )
+            )
+        if value is None:
+            raise BuildNotFound()
+        return BuildStatus(str(value))
+
+    async def project_direction(self, script_build_id: int, legacy_markdown: str) -> None:
+        async with self._sessions() as session, session.begin():
+            result = await session.execute(
+                update(script_build_record)
+                .where(
+                    script_build_record.c.id == script_build_id,
+                    script_build_record.c.is_deleted.is_(False),
+                )
+                .values(script_direction=legacy_markdown)
+            )
+            if cast(CursorResult[Any], result).rowcount != 1:
+                raise BuildNotFound()

+ 857 - 0
script_build_host/src/script_build_host/repositories/sqlalchemy.py

@@ -0,0 +1,857 @@
+from __future__ import annotations
+
+from dataclasses import replace
+from datetime import UTC, datetime
+from typing import Any, cast
+
+from agent.orchestration import ArtifactRef
+from sqlalchemy import CursorResult, Result, RowMapping, insert, 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,
+    BusinessArtifact,
+    Criterion,
+    DirectionGoal,
+    EvidenceRecordV1,
+    ScriptDirectionArtifactV1,
+)
+from script_build_host.domain.digests import Sha256Digest
+from script_build_host.domain.errors import (
+    ArtifactAlreadyFrozen,
+    ArtifactDigestMismatch,
+    ArtifactNotFound,
+    ArtifactOwnershipMismatch,
+    BuildNotFound,
+    InputHashConflict,
+    ProtocolViolation,
+)
+from script_build_host.domain.input_snapshot import ScriptBuildInput, ScriptBuildInputSnapshotV1
+from script_build_host.domain.records import (
+    MissionBinding,
+    Publication,
+    PublicationState,
+    PublicationType,
+)
+from script_build_host.infrastructure.canonical_json import canonical_sha256, normalize_json
+from script_build_host.infrastructure.redaction import redact_text
+from script_build_host.infrastructure.tables import (
+    artifact_version_table,
+    input_snapshot_table,
+    mission_binding_table,
+    publication_table,
+)
+
+
+def _utc_now() -> datetime:
+    return datetime.now(UTC)
+
+
+def _inserted_id(result: Result[Any]) -> int:
+    primary_key = cast(CursorResult[Any], result).inserted_primary_key
+    if primary_key is None or primary_key[0] is None:
+        raise ProtocolViolation("database did not return an inserted primary key")
+    return int(primary_key[0])
+
+
+def _rowcount(result: Result[Any]) -> int:
+    return int(cast(CursorResult[Any], result).rowcount)
+
+
+def _snapshot_from_row(row: RowMapping) -> ScriptBuildInputSnapshotV1:
+    payload = dict(row["canonical_json"])
+    return ScriptBuildInputSnapshotV1(
+        snapshot_id=str(row["id"]),
+        script_build_id=int(row["script_build_id"]),
+        execution_id=int(payload["execution_id"]),
+        topic_build_id=int(payload["topic_build_id"]),
+        topic_id=int(payload["topic_id"]),
+        topic=dict(payload["topic"]),
+        account=dict(payload["account"]),
+        persona_points=tuple(payload.get("persona_points", [])),
+        section_patterns=tuple(payload.get("section_patterns", [])),
+        strategies=tuple(payload.get("strategies", [])),
+        prompt_manifest=tuple(payload.get("prompt_manifest", [])),
+        datasource_manifest=dict(payload.get("datasource_manifest", {})),
+        model_manifest=dict(payload.get("model_manifest", {})),
+        canonical_sha256=Sha256Digest.from_db(str(row["canonical_sha256"])).wire,
+        created_at=cast(datetime, row["created_at"]),
+    )
+
+
+class SqlAlchemyInputSnapshotRepository:
+    def __init__(self, sessions: async_sessionmaker[AsyncSession]) -> None:
+        self._sessions = sessions
+
+    async def freeze(
+        self, assembled: ScriptBuildInput, *, version: int = 1
+    ) -> ScriptBuildInputSnapshotV1:
+        payload = cast(dict[str, Any], normalize_json(assembled.canonical_payload()))
+        digest = canonical_sha256(payload)
+        now = _utc_now()
+        try:
+            async with self._sessions() as session, session.begin():
+                same_hash = (
+                    (
+                        await session.execute(
+                            select(input_snapshot_table).where(
+                                input_snapshot_table.c.script_build_id == assembled.script_build_id,
+                                input_snapshot_table.c.canonical_sha256 == digest.hex_value,
+                            )
+                        )
+                    )
+                    .mappings()
+                    .one_or_none()
+                )
+                if same_hash is not None:
+                    return _snapshot_from_row(same_hash)
+                existing = (
+                    (
+                        await session.execute(
+                            select(input_snapshot_table).where(
+                                input_snapshot_table.c.script_build_id == assembled.script_build_id,
+                                input_snapshot_table.c.version == version,
+                            )
+                        )
+                    )
+                    .mappings()
+                    .one_or_none()
+                )
+                if existing is not None:
+                    if existing["canonical_sha256"] != digest.hex_value:
+                        raise InputHashConflict()
+                    return _snapshot_from_row(existing)
+                result = await session.execute(
+                    insert(input_snapshot_table).values(
+                        script_build_id=assembled.script_build_id,
+                        version=version,
+                        canonical_json=payload,
+                        canonical_sha256=digest.hex_value,
+                        prompt_manifest_json=list(assembled.prompt_manifest),
+                        schema_version="script-build-input/v1",
+                        created_at=now,
+                    )
+                )
+                snapshot_id = _inserted_id(result)
+                row = (
+                    (
+                        await session.execute(
+                            select(input_snapshot_table).where(
+                                input_snapshot_table.c.id == snapshot_id
+                            )
+                        )
+                    )
+                    .mappings()
+                    .one()
+                )
+                return _snapshot_from_row(row)
+        except IntegrityError as error:
+            same_hash = await self._find_hash(assembled.script_build_id, digest.hex_value)
+            if same_hash is not None:
+                return _snapshot_from_row(same_hash)
+            existing = await self._find_version(assembled.script_build_id, version)
+            if existing is not None and existing["canonical_sha256"] == digest.hex_value:
+                return _snapshot_from_row(existing)
+            raise InputHashConflict() from error
+
+    async def _find_hash(self, script_build_id: int, digest: str) -> RowMapping | None:
+        async with self._sessions() as session:
+            return (
+                (
+                    await session.execute(
+                        select(input_snapshot_table).where(
+                            input_snapshot_table.c.script_build_id == script_build_id,
+                            input_snapshot_table.c.canonical_sha256 == digest,
+                        )
+                    )
+                )
+                .mappings()
+                .one_or_none()
+            )
+
+    async def _find_version(self, script_build_id: int, version: int) -> RowMapping | None:
+        async with self._sessions() as session:
+            return (
+                (
+                    await session.execute(
+                        select(input_snapshot_table).where(
+                            input_snapshot_table.c.script_build_id == script_build_id,
+                            input_snapshot_table.c.version == version,
+                        )
+                    )
+                )
+                .mappings()
+                .one_or_none()
+            )
+
+    async def get(self, snapshot_id: str, *, script_build_id: int) -> ScriptBuildInputSnapshotV1:
+        try:
+            identifier = int(snapshot_id)
+        except ValueError as error:
+            raise BuildNotFound() from error
+        async with self._sessions() as session:
+            row = (
+                (
+                    await session.execute(
+                        select(input_snapshot_table).where(
+                            input_snapshot_table.c.id == identifier,
+                            input_snapshot_table.c.script_build_id == script_build_id,
+                        )
+                    )
+                )
+                .mappings()
+                .one_or_none()
+            )
+        if row is None:
+            raise BuildNotFound()
+        return _snapshot_from_row(row)
+
+
+def _binding_from_row(row: RowMapping) -> MissionBinding:
+    return MissionBinding(
+        binding_id=int(row["id"]),
+        script_build_id=int(row["script_build_id"]),
+        root_trace_id=str(row["root_trace_id"]),
+        input_snapshot_id=int(row["input_snapshot_id"]),
+        active_direction_artifact_version_id=row["active_direction_artifact_version_id"],
+        accepted_root_artifact_version_id=row["accepted_root_artifact_version_id"],
+        engine_version=str(row["engine_version"]),
+        schema_version=str(row["schema_version"]),
+        created_at=cast(datetime, row["created_at"]),
+        updated_at=cast(datetime, row["updated_at"]),
+    )
+
+
+class SqlAlchemyMissionBindingRepository:
+    def __init__(self, sessions: async_sessionmaker[AsyncSession]) -> None:
+        self._sessions = sessions
+
+    async def create(
+        self,
+        *,
+        script_build_id: int,
+        root_trace_id: str,
+        input_snapshot_id: int,
+        engine_version: str,
+        schema_version: str,
+    ) -> MissionBinding:
+        now = _utc_now()
+        try:
+            async with self._sessions() as session, session.begin():
+                existing = (
+                    (
+                        await session.execute(
+                            select(mission_binding_table).where(
+                                mission_binding_table.c.script_build_id == script_build_id
+                            )
+                        )
+                    )
+                    .mappings()
+                    .one_or_none()
+                )
+                if existing is not None:
+                    return self._validate_existing(
+                        existing,
+                        root_trace_id=root_trace_id,
+                        input_snapshot_id=input_snapshot_id,
+                    )
+                result = await session.execute(
+                    insert(mission_binding_table).values(
+                        script_build_id=script_build_id,
+                        root_trace_id=root_trace_id,
+                        input_snapshot_id=input_snapshot_id,
+                        engine_version=engine_version,
+                        schema_version=schema_version,
+                        created_at=now,
+                        updated_at=now,
+                    )
+                )
+                binding_id = _inserted_id(result)
+                row = (
+                    (
+                        await session.execute(
+                            select(mission_binding_table).where(
+                                mission_binding_table.c.id == binding_id
+                            )
+                        )
+                    )
+                    .mappings()
+                    .one()
+                )
+                return _binding_from_row(row)
+        except IntegrityError:
+            existing = await self._find_by_build(script_build_id)
+            if existing is None:
+                raise
+            return self._validate_existing(
+                existing,
+                root_trace_id=root_trace_id,
+                input_snapshot_id=input_snapshot_id,
+            )
+
+    @staticmethod
+    def _validate_existing(
+        row: RowMapping, *, root_trace_id: str, input_snapshot_id: int
+    ) -> MissionBinding:
+        if (
+            row["root_trace_id"] != root_trace_id
+            or int(row["input_snapshot_id"]) != input_snapshot_id
+        ):
+            raise ProtocolViolation("script build is already bound to another immutable mission")
+        return _binding_from_row(row)
+
+    async def _find_by_build(self, script_build_id: int) -> RowMapping | None:
+        async with self._sessions() as session:
+            return (
+                (
+                    await session.execute(
+                        select(mission_binding_table).where(
+                            mission_binding_table.c.script_build_id == script_build_id
+                        )
+                    )
+                )
+                .mappings()
+                .one_or_none()
+            )
+
+    async def get_by_build(self, script_build_id: int) -> MissionBinding:
+        return await self._get(mission_binding_table.c.script_build_id == script_build_id)
+
+    async def get_by_root(self, root_trace_id: str) -> MissionBinding:
+        return await self._get(mission_binding_table.c.root_trace_id == root_trace_id)
+
+    async def _get(self, predicate: Any) -> MissionBinding:
+        async with self._sessions() as session:
+            row = (
+                (await session.execute(select(mission_binding_table).where(predicate)))
+                .mappings()
+                .one_or_none()
+            )
+        if row is None:
+            raise BuildNotFound()
+        return _binding_from_row(row)
+
+    async def set_active_direction(
+        self, *, script_build_id: int, artifact_version_id: int
+    ) -> MissionBinding:
+        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)
+                .values(
+                    active_direction_artifact_version_id=artifact_version_id,
+                    updated_at=_utc_now(),
+                )
+            )
+            if _rowcount(result) != 1:
+                raise BuildNotFound()
+        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")
+
+
+def _artifact_payload(value: BusinessArtifact) -> dict[str, Any]:
+    return dict(value.content_payload())
+
+
+def _hydrate_artifact(kind: ArtifactKind, payload: dict[str, Any], digest: str) -> BusinessArtifact:
+    if kind is ArtifactKind.EVIDENCE:
+        return EvidenceRecordV1(
+            evidence_id=str(payload["evidence_id"]),
+            source_type=str(payload["source_type"]),
+            tool_name=str(payload["tool_name"]),
+            query=dict(payload["query"]),
+            source_refs=tuple(payload.get("source_refs", [])),
+            raw_artifact_ref=payload.get("raw_artifact_ref"),
+            summary=str(payload["summary"]),
+            supports=tuple(payload.get("supports", [])),
+            confidence=str(payload["confidence"]),
+            limitations=tuple(payload.get("limitations", [])),
+            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,
+    )
+
+
+def _version_from_row(row: RowMapping) -> ArtifactVersion:
+    kind = ArtifactKind(str(row["artifact_type"]))
+    digest = Sha256Digest.from_db(str(row["canonical_sha256"])).wire
+    payload = dict(row["canonical_json"])
+    return ArtifactVersion(
+        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_type=kind,
+        canonical_sha256=digest,
+        state=ArtifactState(str(row["state"])),
+        artifact=_hydrate_artifact(kind, payload, digest),
+        created_at=cast(datetime, row["created_at"]),
+        frozen_at=cast(datetime, row["frozen_at"]),
+    )
+
+
+class SqlAlchemyScriptBusinessArtifactRepository:
+    _URI_PREFIX = "script-build://artifact-versions/"
+
+    def __init__(self, sessions: async_sessionmaker[AsyncSession]) -> None:
+        self._sessions = sessions
+
+    async def freeze(
+        self,
+        *,
+        script_build_id: int,
+        task_id: str,
+        attempt_id: str,
+        spec_version: int,
+        artifact: BusinessArtifact,
+    ) -> tuple[ArtifactVersion, ArtifactRef]:
+        kind = _artifact_kind(artifact)
+        payload = cast(dict[str, Any], normalize_json(_artifact_payload(artifact)))
+        digest = canonical_sha256(payload)
+        if isinstance(artifact, EvidenceRecordV1):
+            artifact = replace(artifact, content_sha256=digest.wire)
+        else:
+            artifact = replace(artifact, canonical_sha256=digest.wire)
+        now = _utc_now()
+        try:
+            async with self._sessions() as session, session.begin():
+                existing = (
+                    (
+                        await session.execute(
+                            select(artifact_version_table).where(
+                                artifact_version_table.c.attempt_id == attempt_id
+                            )
+                        )
+                    )
+                    .mappings()
+                    .one_or_none()
+                )
+                if existing is not None:
+                    version = self._validate_replay(
+                        existing,
+                        script_build_id=script_build_id,
+                        task_id=task_id,
+                        spec_version=spec_version,
+                        kind=kind,
+                        digest=digest,
+                    )
+                    return version, self._to_ref(version)
+                result = await session.execute(
+                    insert(artifact_version_table).values(
+                        script_build_id=script_build_id,
+                        task_id=task_id,
+                        attempt_id=attempt_id,
+                        spec_version=spec_version,
+                        artifact_type=kind.value,
+                        canonical_json=payload,
+                        canonical_sha256=digest.hex_value,
+                        state=ArtifactState.FROZEN.value,
+                        created_at=now,
+                        frozen_at=now,
+                    )
+                )
+                artifact_id = _inserted_id(result)
+                row = (
+                    (
+                        await session.execute(
+                            select(artifact_version_table).where(
+                                artifact_version_table.c.id == artifact_id
+                            )
+                        )
+                    )
+                    .mappings()
+                    .one()
+                )
+                version = _version_from_row(row)
+                return version, self._to_ref(version)
+        except IntegrityError:
+            existing = await self._find_by_attempt(attempt_id)
+            if existing is None:
+                raise
+            version = self._validate_replay(
+                existing,
+                script_build_id=script_build_id,
+                task_id=task_id,
+                spec_version=spec_version,
+                kind=kind,
+                digest=digest,
+            )
+            return version, self._to_ref(version)
+
+    @staticmethod
+    def _validate_replay(
+        row: RowMapping,
+        *,
+        script_build_id: int,
+        task_id: str,
+        spec_version: int,
+        kind: ArtifactKind,
+        digest: Sha256Digest,
+    ) -> ArtifactVersion:
+        if (
+            int(row["script_build_id"]) != script_build_id
+            or row["task_id"] != task_id
+            or row["artifact_type"] != kind.value
+            or int(row["spec_version"]) != spec_version
+            or row["canonical_sha256"] != digest.hex_value
+        ):
+            raise ArtifactAlreadyFrozen()
+        return _version_from_row(row)
+
+    async def _find_by_attempt(self, attempt_id: str) -> RowMapping | None:
+        async with self._sessions() as session:
+            return (
+                (
+                    await session.execute(
+                        select(artifact_version_table).where(
+                            artifact_version_table.c.attempt_id == attempt_id
+                        )
+                    )
+                )
+                .mappings()
+                .one_or_none()
+            )
+
+    def _to_ref(self, value: ArtifactVersion) -> ArtifactRef:
+        return ArtifactRef(
+            uri=f"{self._URI_PREFIX}{value.artifact_version_id}",
+            kind=value.artifact_type.value,
+            version=str(value.artifact_version_id),
+            digest=value.canonical_sha256,
+            metadata={
+                "script_build_id": value.script_build_id,
+                "task_id": value.task_id,
+                "attempt_id": value.attempt_id,
+            },
+        )
+
+    async def read_by_ref(
+        self,
+        artifact_ref: ArtifactRef,
+        *,
+        script_build_id: int,
+        task_id: str | None = None,
+        attempt_id: str | None = None,
+    ) -> ArtifactVersion:
+        if not artifact_ref.uri.startswith(self._URI_PREFIX):
+            raise ProtocolViolation("artifact URI is outside the script-build artifact namespace")
+        raw_identifier = artifact_ref.uri.removeprefix(self._URI_PREFIX)
+        if not raw_identifier.isdigit() or "/" in raw_identifier:
+            raise ProtocolViolation("artifact URI does not contain one immutable version ID")
+        identifier = int(raw_identifier)
+        async with self._sessions() as session:
+            row = (
+                (
+                    await session.execute(
+                        select(artifact_version_table).where(
+                            artifact_version_table.c.id == identifier
+                        )
+                    )
+                )
+                .mappings()
+                .one_or_none()
+            )
+        if row is None:
+            raise ArtifactNotFound()
+        version = _version_from_row(row)
+        if (
+            version.script_build_id != script_build_id
+            or (task_id is not None and version.task_id != task_id)
+            or (attempt_id is not None and version.attempt_id != attempt_id)
+        ):
+            raise ArtifactOwnershipMismatch()
+        if artifact_ref.kind != version.artifact_type.value:
+            raise ArtifactOwnershipMismatch()
+        if artifact_ref.version != str(version.artifact_version_id):
+            raise ArtifactOwnershipMismatch()
+        supplied_digest = Sha256Digest.from_wire(artifact_ref.digest or "")
+        if supplied_digest.wire != version.canonical_sha256:
+            raise ArtifactDigestMismatch()
+        recalculated = canonical_sha256(_artifact_payload(version.artifact)).wire
+        if recalculated != version.canonical_sha256:
+            raise ArtifactDigestMismatch()
+        return version
+
+    async def verify_digest(self, artifact_ref: ArtifactRef, *, script_build_id: int) -> bool:
+        try:
+            await self.read_by_ref(artifact_ref, script_build_id=script_build_id)
+        except (ArtifactDigestMismatch, ArtifactNotFound, ArtifactOwnershipMismatch):
+            return False
+        return True
+
+    async def get_by_id(self, artifact_version_id: int, *, script_build_id: int) -> ArtifactVersion:
+        async with self._sessions() as session:
+            row = (
+                (
+                    await session.execute(
+                        select(artifact_version_table).where(
+                            artifact_version_table.c.id == artifact_version_id,
+                            artifact_version_table.c.script_build_id == script_build_id,
+                        )
+                    )
+                )
+                .mappings()
+                .one_or_none()
+            )
+        if row is None:
+            raise ArtifactNotFound()
+        version = _version_from_row(row)
+        if canonical_sha256(_artifact_payload(version.artifact)).wire != version.canonical_sha256:
+            raise ArtifactDigestMismatch()
+        return version
+
+
+def _publication_from_row(row: RowMapping) -> Publication:
+    return Publication(
+        publication_id=int(row["id"]),
+        script_build_id=int(row["script_build_id"]),
+        publication_type=PublicationType(str(row["publication_type"])),
+        accept_decision_id=str(row["accept_decision_id"]),
+        artifact_version_id=int(row["artifact_version_id"]),
+        expected_sha256=Sha256Digest.from_db(str(row["expected_sha256"])).wire,
+        state=PublicationState(str(row["state"])),
+        attempt_count=int(row["attempt_count"]),
+        publication_revision=int(row["publication_revision"]),
+        last_error_code=row["last_error_code"],
+        last_error_summary=row["last_error_summary"],
+        created_at=cast(datetime, row["created_at"]),
+        updated_at=cast(datetime, row["updated_at"]),
+        published_at=row["published_at"],
+    )
+
+
+class SqlAlchemyPublicationRepository:
+    def __init__(self, sessions: async_sessionmaker[AsyncSession]) -> None:
+        self._sessions = sessions
+
+    async def prepare(
+        self,
+        *,
+        script_build_id: int,
+        publication_type: PublicationType,
+        accept_decision_id: str,
+        artifact_version_id: int,
+        expected_sha256: str,
+    ) -> Publication:
+        if publication_type is not PublicationType.DIRECTION:
+            raise ProtocolViolation("phase one can publish only an accepted direction")
+        digest = Sha256Digest.from_wire(expected_sha256)
+        now = _utc_now()
+        try:
+            async with self._sessions() as session, session.begin():
+                existing = (
+                    (
+                        await session.execute(
+                            select(publication_table).where(
+                                publication_table.c.accept_decision_id == accept_decision_id
+                            )
+                        )
+                    )
+                    .mappings()
+                    .one_or_none()
+                )
+                if existing is not None:
+                    return self._validate_existing(
+                        existing,
+                        script_build_id=script_build_id,
+                        publication_type=publication_type,
+                        artifact_version_id=artifact_version_id,
+                        digest=digest,
+                    )
+                result = await session.execute(
+                    insert(publication_table).values(
+                        script_build_id=script_build_id,
+                        publication_type=publication_type.value,
+                        accept_decision_id=accept_decision_id,
+                        artifact_version_id=artifact_version_id,
+                        expected_sha256=digest.hex_value,
+                        state=PublicationState.PENDING.value,
+                        attempt_count=0,
+                        publication_revision=0,
+                        created_at=now,
+                        updated_at=now,
+                    )
+                )
+                publication_id = _inserted_id(result)
+        except IntegrityError:
+            existing = await self._find_by_decision(accept_decision_id)
+            if existing is None:
+                raise
+            return self._validate_existing(
+                existing,
+                script_build_id=script_build_id,
+                publication_type=publication_type,
+                artifact_version_id=artifact_version_id,
+                digest=digest,
+            )
+        return await self._get(publication_id)
+
+    @staticmethod
+    def _validate_existing(
+        row: RowMapping,
+        *,
+        script_build_id: int,
+        publication_type: PublicationType,
+        artifact_version_id: int,
+        digest: Sha256Digest,
+    ) -> Publication:
+        if (
+            int(row["script_build_id"]) != script_build_id
+            or row["publication_type"] != publication_type.value
+            or int(row["artifact_version_id"]) != artifact_version_id
+            or row["expected_sha256"] != digest.hex_value
+        ):
+            raise ProtocolViolation("accept decision is already bound to another publication")
+        return _publication_from_row(row)
+
+    async def _find_by_decision(self, accept_decision_id: str) -> RowMapping | None:
+        async with self._sessions() as session:
+            return (
+                (
+                    await session.execute(
+                        select(publication_table).where(
+                            publication_table.c.accept_decision_id == accept_decision_id
+                        )
+                    )
+                )
+                .mappings()
+                .one_or_none()
+            )
+
+    async def mark_published(self, publication_id: int) -> Publication:
+        now = _utc_now()
+        async with self._sessions() as session, session.begin():
+            publication_row = (
+                (
+                    await session.execute(
+                        select(publication_table).where(publication_table.c.id == publication_id)
+                    )
+                )
+                .mappings()
+                .one_or_none()
+            )
+            if publication_row is None:
+                raise BuildNotFound()
+            if publication_row["state"] == PublicationState.PUBLISHED.value:
+                return _publication_from_row(publication_row)
+            artifact_version_id = publication_row["artifact_version_id"]
+            if artifact_version_id is None:
+                raise BuildNotFound()
+            result = await session.execute(
+                update(publication_table)
+                .where(publication_table.c.id == publication_id)
+                .values(
+                    state=PublicationState.PUBLISHED.value,
+                    publication_revision=publication_table.c.publication_revision + 1,
+                    attempt_count=publication_table.c.attempt_count + 1,
+                    updated_at=now,
+                    published_at=now,
+                    last_error_code=None,
+                    last_error_summary=None,
+                )
+            )
+            if _rowcount(result) != 1:
+                raise BuildNotFound()
+            artifact_result = await session.execute(
+                update(artifact_version_table)
+                .where(
+                    artifact_version_table.c.id == artifact_version_id,
+                    artifact_version_table.c.state.in_(
+                        [ArtifactState.FROZEN.value, ArtifactState.PUBLISHED.value]
+                    ),
+                )
+                .values(state=ArtifactState.PUBLISHED.value, published_at=now)
+            )
+            if _rowcount(artifact_result) != 1:
+                raise ProtocolViolation("publication artifact is not in a publishable state")
+        return await self._get(publication_id)
+
+    async def mark_failed(
+        self, publication_id: int, *, error_code: str, error_summary: str
+    ) -> Publication:
+        safe_summary = redact_text(error_summary)[:1000]
+        async with self._sessions() as session, session.begin():
+            row = (
+                (
+                    await session.execute(
+                        select(publication_table).where(publication_table.c.id == publication_id)
+                    )
+                )
+                .mappings()
+                .one_or_none()
+            )
+            if row is None:
+                raise BuildNotFound()
+            if row["state"] == PublicationState.PUBLISHED.value:
+                return _publication_from_row(row)
+            result = await session.execute(
+                update(publication_table)
+                .where(publication_table.c.id == publication_id)
+                .values(
+                    state=PublicationState.FAILED.value,
+                    attempt_count=publication_table.c.attempt_count + 1,
+                    last_error_code=error_code[:64],
+                    last_error_summary=safe_summary,
+                    updated_at=_utc_now(),
+                )
+            )
+            if _rowcount(result) != 1:
+                raise BuildNotFound()
+        return await self._get(publication_id)
+
+    async def _get(self, publication_id: int) -> Publication:
+        async with self._sessions() as session:
+            row = (
+                (
+                    await session.execute(
+                        select(publication_table).where(publication_table.c.id == publication_id)
+                    )
+                )
+                .mappings()
+                .one_or_none()
+            )
+        if row is None:
+            raise BuildNotFound()
+        return _publication_from_row(row)
+
+    async def get_by_build(
+        self, script_build_id: int, *, publication_type: PublicationType
+    ) -> Publication | None:
+        async with self._sessions() as session:
+            row = (
+                (
+                    await session.execute(
+                        select(publication_table)
+                        .where(
+                            publication_table.c.script_build_id == script_build_id,
+                            publication_table.c.publication_type == publication_type.value,
+                        )
+                        .order_by(publication_table.c.id.desc())
+                        .limit(1)
+                    )
+                )
+                .mappings()
+                .one_or_none()
+            )
+        return _publication_from_row(row) if row is not None else None