diff --git a/.github/workflows/email-writing-evidence-tdd.yml b/.github/workflows/email-writing-evidence-tdd.yml new file mode 100644 index 000000000..bfda34e17 --- /dev/null +++ b/.github/workflows/email-writing-evidence-tdd.yml @@ -0,0 +1,106 @@ +name: Email Writing Evidence TDD + +on: + push: + branches: + - feat/llm-email-writing-review-evidence-task3 + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: email-writing-evidence-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + review-evidence: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: backend/requirements-hashes.txt + - run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt + - name: Install hash-verified coverage tool + run: | + set -euo pipefail + mkdir -p /tmp/coverage-wheel + cat >/tmp/coverage-lock.txt <<'EOF' + coverage==7.15.2 --hash=sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c + EOF + python -m pip download \ + --disable-pip-version-check \ + --require-hashes \ + --no-deps \ + --only-binary=:all: \ + --platform any \ + --python-version 3.14 \ + --implementation py \ + --abi none \ + --dest /tmp/coverage-wheel \ + -r /tmp/coverage-lock.txt + python -m pip install --disable-pip-version-check --no-deps /tmp/coverage-wheel/coverage-7.15.2-py3-none-any.whl + - name: Run privacy-minimized model and migration tests + env: + PYTHONWARNINGS: error + DISABLE_BACKGROUND_WORKERS: "1" + run: | + set -euo pipefail + cd backend + output_file="$(mktemp)" + trap 'rm -f "$output_file"' EXIT + set +e + python -m pytest -q \ + tests/test_email_writing_models.py \ + tests/test_email_writing_migration.py 2>&1 | tee "$output_file" + test_status=${PIPESTATUS[0]} + set -e + if grep -Eiq '(^|[^[:alnum:]_])(Timeout|Fatal|Warn|Denied)([^[:alnum:]_]|$)' "$output_file"; then + echo "::error::Backend tests emitted prohibited stability output." + exit 1 + fi + exit "$test_status" + - name: Verify migration statement and branch coverage + env: + PYTHONWARNINGS: error + DISABLE_BACKGROUND_WORKERS: "1" + run: | + set -euo pipefail + cd backend + output_file="$(mktemp)" + trap 'rm -f "$output_file"' EXIT + python -m coverage erase + set +e + python -m coverage run --branch \ + --include='alembic/versions/20260812_0001_add_email_writing_review_evidence.py' \ + -m pytest -q tests/test_email_writing_migration.py 2>&1 | tee "$output_file" + test_status=${PIPESTATUS[0]} + set -e + if grep -Eiq '(^|[^[:alnum:]_])(Timeout|Fatal|Warn|Denied)([^[:alnum:]_]|$)' "$output_file"; then + echo "::error::Coverage tests emitted prohibited stability output." + exit 1 + fi + if [[ "$test_status" -ne 0 ]]; then + exit "$test_status" + fi + python -m coverage report --show-missing --fail-under=100 \ + alembic/versions/20260812_0001_add_email_writing_review_evidence.py + - name: Lint evidence implementation and tests + run: | + cd backend + python -m ruff check \ + db/email_writing_evidence.py \ + alembic/env.py \ + alembic/versions/20260812_0001_add_email_writing_review_evidence.py \ + tests/test_email_writing_models.py \ + tests/test_email_writing_migration.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 62eeb23e0..e8319725f 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -7,14 +7,14 @@ from sqlalchemy.ext.asyncio import async_engine_from_config from core.config import settings -from db.models import Base +from db.email_writing_evidence import EmailReviewSession config = context.config if config.config_file_name is not None: fileConfig(config.config_file_name) -target_metadata = Base.metadata +target_metadata = EmailReviewSession.__table__.metadata def _database_url() -> str: diff --git a/backend/alembic/versions/20260812_0001_add_email_writing_review_evidence.py b/backend/alembic/versions/20260812_0001_add_email_writing_review_evidence.py new file mode 100644 index 000000000..57f571434 --- /dev/null +++ b/backend/alembic/versions/20260812_0001_add_email_writing_review_evidence.py @@ -0,0 +1,279 @@ +"""Add privacy-minimized email-writing review evidence tables. + +Revision ID: 20260812_email_writing_evidence +Revises: 0017_merge_newsdom_carddav_heads +Create Date: 2026-08-12 15:05:00.000000 + +The revision stores ownership, immutable revision identifiers, bounded runtime +buckets, diagnostic hashes, Judge outcomes, and idempotent feedback codes. It +deliberately excludes authored mail or draft content, model payloads, credentials, +and complete execution traces. +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260812_email_writing_evidence" +down_revision = "0017_merge_newsdom_carddav_heads" +branch_labels = None +depends_on = None + +_SESSION_TABLE = "email_review_session" +_DIAGNOSTIC_TABLE = "writing_diagnostic_record" +_FEEDBACK_TABLE = "diagnostic_feedback_event" +_NEW_TABLE_NAMES = (_SESSION_TABLE, _DIAGNOSTIC_TABLE, _FEEDBACK_TABLE) + + +def _review_evidence_metadata() -> sa.MetaData: + """Build dialect-neutral table metadata for SQLite and PostgreSQL.""" + metadata = sa.MetaData() + sa.Table( + "email_records", + metadata, + sa.Column("id", sa.Integer(), primary_key=True), + ) + review_session = sa.Table( + _SESSION_TABLE, + metadata, + sa.Column("review_session_id", sa.String(length=36), nullable=False), + sa.Column("owner_user_id", sa.String(length=255), nullable=False), + sa.Column("owner_organization_id", sa.String(length=255), nullable=False), + sa.Column("source_email_id", sa.Integer(), nullable=False), + sa.Column("revision_algorithm", sa.String(length=16), nullable=False), + sa.Column("revision_digest", sa.String(length=64), nullable=False), + sa.Column("revision_entity_tag", sa.String(length=96), nullable=False), + sa.Column("projection_name", sa.String(length=128), nullable=False), + sa.Column("projection_version", sa.Integer(), nullable=False), + sa.Column("review_mode", sa.String(length=32), nullable=False), + sa.Column("language_profile", sa.String(length=64), nullable=False), + sa.Column("review_status", sa.String(length=32), nullable=False), + sa.Column("workflow_identifier", sa.String(length=128), nullable=False), + sa.Column("workflow_version", sa.String(length=128), nullable=False), + sa.Column("model_profile_id", sa.String(length=128), nullable=False), + sa.Column("rubric_version", sa.String(length=128), nullable=False), + sa.Column("judge_policy_version", sa.String(length=128), nullable=False), + sa.Column("orchestration_mode", sa.String(length=32), nullable=False), + sa.Column("prompt_hash", sa.String(length=71), nullable=False), + sa.Column("latency_bucket_ms", sa.Integer(), nullable=False), + sa.Column("cost_bucket_micro_usd", sa.Integer(), nullable=False), + sa.Column("prompt_token_bucket", sa.Integer(), nullable=False), + sa.Column("completion_token_bucket", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("evidence_expires_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint( + "review_session_id", + name="pk_email_review_session", + ), + sa.ForeignKeyConstraint( + ["source_email_id"], + ["email_records.id"], + name="fk_email_review_session_source_email", + ondelete="CASCADE", + ), + sa.CheckConstraint( + "revision_algorithm = 'SHA-256'", + name="ck_email_review_session_revision_algorithm", + ), + sa.CheckConstraint( + "length(revision_digest) = 64", + name="ck_email_review_session_revision_digest", + ), + sa.CheckConstraint( + "length(revision_entity_tag) >= 73", + name="ck_email_review_session_revision_entity_tag", + ), + sa.CheckConstraint( + "projection_version > 0", + name="ck_email_review_session_projection_version", + ), + sa.CheckConstraint( + "review_mode IN ('incremental', 'deep')", + name="ck_email_review_session_review_mode", + ), + sa.CheckConstraint( + "review_status IN (" + "'pending', 'completed', 'abstained', 'unavailable', 'stale', " + "'rejected', 'context_insufficient', 'judge_disagreement'" + ")", + name="ck_email_review_session_review_status", + ), + sa.CheckConstraint( + "orchestration_mode IN ('route', 'conduct')", + name="ck_email_review_session_orchestration_mode", + ), + sa.CheckConstraint( + "prompt_hash LIKE 'sha256:%' AND length(prompt_hash) = 71", + name="ck_email_review_session_prompt_hash", + ), + sa.CheckConstraint( + "latency_bucket_ms >= 0 AND cost_bucket_micro_usd >= 0 " + "AND prompt_token_bucket >= 0 AND completion_token_bucket >= 0", + name="ck_email_review_session_nonnegative_buckets", + ), + sa.CheckConstraint( + "evidence_expires_at > created_at", + name="ck_email_review_session_retention_window", + ), + ) + sa.Index( + "ix_email_review_session_owner_scope", + review_session.c.owner_user_id, + review_session.c.owner_organization_id, + review_session.c.created_at, + ) + sa.Index( + "ix_email_review_session_expiry_status", + review_session.c.evidence_expires_at, + review_session.c.review_status, + ) + sa.Index( + "ix_email_review_session_source_email", + review_session.c.source_email_id, + ) + + diagnostic_record = sa.Table( + _DIAGNOSTIC_TABLE, + metadata, + sa.Column("diagnostic_record_id", sa.String(length=36), nullable=False), + sa.Column("review_session_id", sa.String(length=36), nullable=False), + sa.Column("diagnostic_identifier", sa.String(length=128), nullable=False), + sa.Column("diagnostic_category", sa.String(length=128), nullable=False), + sa.Column("diagnostic_priority", sa.String(length=32), nullable=False), + sa.Column("selector_start", sa.Integer(), nullable=False), + sa.Column("selector_end", sa.Integer(), nullable=False), + sa.Column("candidate_hash", sa.String(length=71), nullable=False), + sa.Column("replacement_hash", sa.String(length=71), nullable=True), + sa.Column("explanation_hash", sa.String(length=71), nullable=False), + sa.Column("criterion_categories_json", sa.JSON(), nullable=False), + sa.Column("judge_score", sa.Float(), nullable=False), + sa.Column("admission_status", sa.String(length=32), nullable=False), + sa.Column("admission_reason_code", sa.String(length=128), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint( + "diagnostic_record_id", + name="pk_writing_diagnostic_record", + ), + sa.ForeignKeyConstraint( + ["review_session_id"], + ["email_review_session.review_session_id"], + name="fk_writing_diagnostic_record_review_session", + ondelete="CASCADE", + ), + sa.UniqueConstraint( + "review_session_id", + "diagnostic_identifier", + name="uq_writing_diagnostic_record_session_identifier", + ), + sa.CheckConstraint( + "selector_start >= 0 AND selector_end > selector_start", + name="ck_writing_diagnostic_record_selector_order", + ), + sa.CheckConstraint( + "diagnostic_priority IN ('critical', 'important', 'advisory')", + name="ck_writing_diagnostic_record_priority_code", + ), + sa.CheckConstraint( + "judge_score >= 0 AND judge_score <= 1", + name="ck_writing_diagnostic_record_judge_score", + ), + sa.CheckConstraint( + "admission_status IN ('admitted', 'rejected', 'abstained')", + name="ck_writing_diagnostic_record_admission_status", + ), + sa.CheckConstraint( + "candidate_hash LIKE 'sha256:%' AND length(candidate_hash) = 71 " + "AND explanation_hash LIKE 'sha256:%' " + "AND length(explanation_hash) = 71 " + "AND (replacement_hash IS NULL OR (" + "replacement_hash LIKE 'sha256:%' " + "AND length(replacement_hash) = 71))", + name="ck_writing_diagnostic_record_hash_shapes", + ), + ) + sa.Index( + "ix_writing_diagnostic_record_session_status", + diagnostic_record.c.review_session_id, + diagnostic_record.c.admission_status, + ) + + feedback_event = sa.Table( + _FEEDBACK_TABLE, + metadata, + sa.Column("feedback_event_id", sa.String(length=36), nullable=False), + sa.Column("diagnostic_record_id", sa.String(length=36), nullable=False), + sa.Column("owner_user_id", sa.String(length=255), nullable=False), + sa.Column("owner_organization_id", sa.String(length=255), nullable=False), + sa.Column("feedback_action", sa.String(length=32), nullable=False), + sa.Column("reviewed_revision_digest", sa.String(length=64), nullable=False), + sa.Column("resulting_revision_digest", sa.String(length=64), nullable=True), + sa.Column("conflict_reason_code", sa.String(length=128), nullable=True), + sa.Column("stale_reason_code", sa.String(length=128), nullable=True), + sa.Column("event_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("idempotency_key", sa.String(length=255), nullable=False), + sa.PrimaryKeyConstraint( + "feedback_event_id", + name="pk_diagnostic_feedback_event", + ), + sa.ForeignKeyConstraint( + ["diagnostic_record_id"], + ["writing_diagnostic_record.diagnostic_record_id"], + name="fk_diagnostic_feedback_event_diagnostic_record", + ondelete="CASCADE", + ), + sa.UniqueConstraint( + "owner_user_id", + "owner_organization_id", + "idempotency_key", + name="uq_diagnostic_feedback_event_owner_idempotency", + ), + sa.CheckConstraint( + "feedback_action IN (" + "'applied', 'ignored', 'dismissed', 'explanation_requested'" + ")", + name="ck_diagnostic_feedback_event_action_code", + ), + sa.CheckConstraint( + "feedback_action != 'applied' OR resulting_revision_digest IS NOT NULL", + name="ck_diagnostic_feedback_event_apply_revision", + ), + sa.CheckConstraint( + "length(reviewed_revision_digest) = 64 " + "AND (resulting_revision_digest IS NULL " + "OR length(resulting_revision_digest) = 64)", + name="ck_diagnostic_feedback_event_revision_digests", + ), + sa.CheckConstraint( + "length(idempotency_key) > 0", + name="ck_diagnostic_feedback_event_idempotency_key", + ), + ) + sa.Index( + "ix_diagnostic_feedback_event_owner_scope", + feedback_event.c.owner_user_id, + feedback_event.c.owner_organization_id, + feedback_event.c.event_time, + ) + sa.Index( + "ix_diagnostic_feedback_event_diagnostic_record", + feedback_event.c.diagnostic_record_id, + feedback_event.c.event_time, + ) + return metadata + + +def upgrade() -> None: + """Create the evidence tables and indexes without touching raw mail data.""" + connection = op.get_bind() + metadata = _review_evidence_metadata() + for table_name in _NEW_TABLE_NAMES: + metadata.tables[table_name].create(connection, checkfirst=True) + + +def downgrade() -> None: + """Drop only this revision's evidence objects in dependency-safe order.""" + connection = op.get_bind() + metadata = _review_evidence_metadata() + for table_name in reversed(_NEW_TABLE_NAMES): + metadata.tables[table_name].drop(connection, checkfirst=True) diff --git a/backend/db/email_writing_evidence.py b/backend/db/email_writing_evidence.py new file mode 100644 index 000000000..5e39a502c --- /dev/null +++ b/backend/db/email_writing_evidence.py @@ -0,0 +1,434 @@ +"""Privacy-minimized persistence models for LLM email-writing review evidence. + +The tables in this module deliberately retain only ownership, immutable revision +identifiers, bounded operational buckets, diagnostic hashes, admission outcomes, +and idempotent user feedback. They never persist source mail bodies, authored +drafts, replacement or explanation text, prompts, model output, provider tokens, +or orchestration traces. +""" + +from __future__ import annotations + +import datetime +import uuid +from typing import Any + +from sqlalchemy import ( + CheckConstraint, + DateTime, + Float, + ForeignKeyConstraint, + Index, + Integer, + JSON, + PrimaryKeyConstraint, + String, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from db.models import Base, Email + + +def _utc_now() -> datetime.datetime: + """Return a timezone-aware UTC timestamp for persisted evidence events.""" + return datetime.datetime.now(datetime.timezone.utc) + + +def _new_uuid() -> str: + """Return a transport-neutral UUID string supported by SQLite and PostgreSQL.""" + return str(uuid.uuid4()) + + +class EmailReviewSession(Base): + """One tenant-scoped review of an immutable email-draft revision.""" + + __tablename__ = "email_review_session" + __table_args__ = ( + PrimaryKeyConstraint( + "review_session_id", + name="pk_email_review_session", + ), + ForeignKeyConstraint( + ["source_email_id"], + ["email_records.id"], + name="fk_email_review_session_source_email", + ondelete="CASCADE", + ), + CheckConstraint( + "revision_algorithm = 'SHA-256'", + name="ck_email_review_session_revision_algorithm", + ), + CheckConstraint( + "length(revision_digest) = 64", + name="ck_email_review_session_revision_digest", + ), + CheckConstraint( + "length(revision_entity_tag) >= 73", + name="ck_email_review_session_revision_entity_tag", + ), + CheckConstraint( + "projection_version > 0", + name="ck_email_review_session_projection_version", + ), + CheckConstraint( + "review_mode IN ('incremental', 'deep')", + name="ck_email_review_session_review_mode", + ), + CheckConstraint( + "review_status IN (" + "'pending', 'completed', 'abstained', 'unavailable', 'stale', " + "'rejected', 'context_insufficient', 'judge_disagreement'" + ")", + name="ck_email_review_session_review_status", + ), + CheckConstraint( + "orchestration_mode IN ('route', 'conduct')", + name="ck_email_review_session_orchestration_mode", + ), + CheckConstraint( + "prompt_hash LIKE 'sha256:%' AND length(prompt_hash) = 71", + name="ck_email_review_session_prompt_hash", + ), + CheckConstraint( + "latency_bucket_ms >= 0 AND cost_bucket_micro_usd >= 0 " + "AND prompt_token_bucket >= 0 AND completion_token_bucket >= 0", + name="ck_email_review_session_nonnegative_buckets", + ), + CheckConstraint( + "evidence_expires_at > created_at", + name="ck_email_review_session_retention_window", + ), + Index( + "ix_email_review_session_owner_scope", + "owner_user_id", + "owner_organization_id", + "created_at", + ), + Index( + "ix_email_review_session_expiry_status", + "evidence_expires_at", + "review_status", + ), + Index( + "ix_email_review_session_source_email", + "source_email_id", + ), + ) + + review_session_id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=_new_uuid, + nullable=False, + ) + owner_user_id: Mapped[str] = mapped_column(String(255), nullable=False) + owner_organization_id: Mapped[str] = mapped_column(String(255), nullable=False) + source_email_id: Mapped[int] = mapped_column(Integer, nullable=False) + revision_algorithm: Mapped[str] = mapped_column(String(16), nullable=False) + revision_digest: Mapped[str] = mapped_column(String(64), nullable=False) + revision_entity_tag: Mapped[str] = mapped_column(String(96), nullable=False) + projection_name: Mapped[str] = mapped_column(String(128), nullable=False) + projection_version: Mapped[int] = mapped_column(Integer, nullable=False) + review_mode: Mapped[str] = mapped_column(String(32), nullable=False) + language_profile: Mapped[str] = mapped_column(String(64), nullable=False) + review_status: Mapped[str] = mapped_column(String(32), nullable=False) + workflow_identifier: Mapped[str] = mapped_column(String(128), nullable=False) + workflow_version: Mapped[str] = mapped_column(String(128), nullable=False) + model_profile_id: Mapped[str] = mapped_column(String(128), nullable=False) + rubric_version: Mapped[str] = mapped_column(String(128), nullable=False) + judge_policy_version: Mapped[str] = mapped_column(String(128), nullable=False) + orchestration_mode: Mapped[str] = mapped_column(String(32), nullable=False) + prompt_hash: Mapped[str] = mapped_column(String(71), nullable=False) + latency_bucket_ms: Mapped[int] = mapped_column(Integer, nullable=False) + cost_bucket_micro_usd: Mapped[int] = mapped_column(Integer, nullable=False) + prompt_token_bucket: Mapped[int] = mapped_column(Integer, nullable=False) + completion_token_bucket: Mapped[int] = mapped_column(Integer, nullable=False) + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=_utc_now, + nullable=False, + ) + evidence_expires_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) + + source_email_record: Mapped[Email] = relationship( + Email, + passive_deletes=True, + ) + writing_diagnostic_records: Mapped[list[WritingDiagnosticRecord]] = relationship( + "WritingDiagnosticRecord", + back_populates="review_session_record", + cascade="all, delete-orphan", + passive_deletes=True, + ) + + def to_evidence_dict(self) -> dict[str, Any]: + """Serialize only privacy-minimized operational evidence.""" + return { + "review_session_id": self.review_session_id, + "revision_algorithm": self.revision_algorithm, + "revision_digest": self.revision_digest, + "revision_entity_tag": self.revision_entity_tag, + "projection_name": self.projection_name, + "projection_version": self.projection_version, + "review_mode": self.review_mode, + "language_profile": self.language_profile, + "review_status": self.review_status, + "workflow_identifier": self.workflow_identifier, + "workflow_version": self.workflow_version, + "model_profile_id": self.model_profile_id, + "rubric_version": self.rubric_version, + "judge_policy_version": self.judge_policy_version, + "orchestration_mode": self.orchestration_mode, + "prompt_hash": self.prompt_hash, + "latency_bucket_ms": self.latency_bucket_ms, + "cost_bucket_micro_usd": self.cost_bucket_micro_usd, + "prompt_token_bucket": self.prompt_token_bucket, + "completion_token_bucket": self.completion_token_bucket, + "created_at": self.created_at, + "evidence_expires_at": self.evidence_expires_at, + } + + def __repr__(self) -> str: + """Return a log-safe representation containing no authored content.""" + return ( + "EmailReviewSession(" + f"review_session_id={self.review_session_id!r}, " + f"review_status={self.review_status!r})" + ) + + +class WritingDiagnosticRecord(Base): + """Hash-only evidence for one candidate diagnostic and Judge decision.""" + + __tablename__ = "writing_diagnostic_record" + __table_args__ = ( + PrimaryKeyConstraint( + "diagnostic_record_id", + name="pk_writing_diagnostic_record", + ), + ForeignKeyConstraint( + ["review_session_id"], + ["email_review_session.review_session_id"], + name="fk_writing_diagnostic_record_review_session", + ondelete="CASCADE", + ), + UniqueConstraint( + "review_session_id", + "diagnostic_identifier", + name="uq_writing_diagnostic_record_session_identifier", + ), + CheckConstraint( + "selector_start >= 0 AND selector_end > selector_start", + name="ck_writing_diagnostic_record_selector_order", + ), + CheckConstraint( + "diagnostic_priority IN ('critical', 'important', 'advisory')", + name="ck_writing_diagnostic_record_priority_code", + ), + CheckConstraint( + "judge_score >= 0 AND judge_score <= 1", + name="ck_writing_diagnostic_record_judge_score", + ), + CheckConstraint( + "admission_status IN ('admitted', 'rejected', 'abstained')", + name="ck_writing_diagnostic_record_admission_status", + ), + CheckConstraint( + "candidate_hash LIKE 'sha256:%' AND length(candidate_hash) = 71 " + "AND explanation_hash LIKE 'sha256:%' " + "AND length(explanation_hash) = 71 " + "AND (replacement_hash IS NULL OR (" + "replacement_hash LIKE 'sha256:%' " + "AND length(replacement_hash) = 71))", + name="ck_writing_diagnostic_record_hash_shapes", + ), + Index( + "ix_writing_diagnostic_record_session_status", + "review_session_id", + "admission_status", + ), + ) + + diagnostic_record_id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=_new_uuid, + nullable=False, + ) + review_session_id: Mapped[str] = mapped_column(String(36), nullable=False) + diagnostic_identifier: Mapped[str] = mapped_column(String(128), nullable=False) + diagnostic_category: Mapped[str] = mapped_column(String(128), nullable=False) + diagnostic_priority: Mapped[str] = mapped_column(String(32), nullable=False) + selector_start: Mapped[int] = mapped_column(Integer, nullable=False) + selector_end: Mapped[int] = mapped_column(Integer, nullable=False) + candidate_hash: Mapped[str] = mapped_column(String(71), nullable=False) + replacement_hash: Mapped[str | None] = mapped_column(String(71), nullable=True) + explanation_hash: Mapped[str] = mapped_column(String(71), nullable=False) + criterion_categories_json: Mapped[list[str]] = mapped_column( + JSON, + nullable=False, + ) + judge_score: Mapped[float] = mapped_column(Float, nullable=False) + admission_status: Mapped[str] = mapped_column(String(32), nullable=False) + admission_reason_code: Mapped[str] = mapped_column(String(128), nullable=False) + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=_utc_now, + nullable=False, + ) + + review_session_record: Mapped[EmailReviewSession] = relationship( + EmailReviewSession, + back_populates="writing_diagnostic_records", + ) + diagnostic_feedback_events: Mapped[list[DiagnosticFeedbackEvent]] = relationship( + "DiagnosticFeedbackEvent", + back_populates="diagnostic_record_entry", + cascade="all, delete-orphan", + passive_deletes=True, + ) + + def to_evidence_dict(self) -> dict[str, Any]: + """Serialize hashes, selectors, categories, and admission evidence only.""" + return { + "diagnostic_record_id": self.diagnostic_record_id, + "review_session_id": self.review_session_id, + "diagnostic_identifier": self.diagnostic_identifier, + "diagnostic_category": self.diagnostic_category, + "diagnostic_priority": self.diagnostic_priority, + "selector_start": self.selector_start, + "selector_end": self.selector_end, + "candidate_hash": self.candidate_hash, + "replacement_hash": self.replacement_hash, + "explanation_hash": self.explanation_hash, + "criterion_categories_json": self.criterion_categories_json, + "judge_score": self.judge_score, + "admission_status": self.admission_status, + "admission_reason_code": self.admission_reason_code, + "created_at": self.created_at, + } + + def __repr__(self) -> str: + """Return a log-safe representation containing only opaque identifiers.""" + return ( + "WritingDiagnosticRecord(" + f"diagnostic_record_id={self.diagnostic_record_id!r}, " + f"diagnostic_identifier={self.diagnostic_identifier!r}, " + f"admission_status={self.admission_status!r})" + ) + + +class DiagnosticFeedbackEvent(Base): + """Idempotent user feedback linked to one admitted diagnostic record.""" + + __tablename__ = "diagnostic_feedback_event" + __table_args__ = ( + PrimaryKeyConstraint( + "feedback_event_id", + name="pk_diagnostic_feedback_event", + ), + ForeignKeyConstraint( + ["diagnostic_record_id"], + ["writing_diagnostic_record.diagnostic_record_id"], + name="fk_diagnostic_feedback_event_diagnostic_record", + ondelete="CASCADE", + ), + UniqueConstraint( + "owner_user_id", + "owner_organization_id", + "idempotency_key", + name="uq_diagnostic_feedback_event_owner_idempotency", + ), + CheckConstraint( + "feedback_action IN (" + "'applied', 'ignored', 'dismissed', 'explanation_requested'" + ")", + name="ck_diagnostic_feedback_event_action_code", + ), + CheckConstraint( + "feedback_action != 'applied' OR resulting_revision_digest IS NOT NULL", + name="ck_diagnostic_feedback_event_apply_revision", + ), + CheckConstraint( + "length(reviewed_revision_digest) = 64 " + "AND (resulting_revision_digest IS NULL " + "OR length(resulting_revision_digest) = 64)", + name="ck_diagnostic_feedback_event_revision_digests", + ), + CheckConstraint( + "length(idempotency_key) > 0", + name="ck_diagnostic_feedback_event_idempotency_key", + ), + Index( + "ix_diagnostic_feedback_event_owner_scope", + "owner_user_id", + "owner_organization_id", + "event_time", + ), + Index( + "ix_diagnostic_feedback_event_diagnostic_record", + "diagnostic_record_id", + "event_time", + ), + ) + + feedback_event_id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=_new_uuid, + nullable=False, + ) + diagnostic_record_id: Mapped[str] = mapped_column(String(36), nullable=False) + owner_user_id: Mapped[str] = mapped_column(String(255), nullable=False) + owner_organization_id: Mapped[str] = mapped_column(String(255), nullable=False) + feedback_action: Mapped[str] = mapped_column(String(32), nullable=False) + reviewed_revision_digest: Mapped[str] = mapped_column(String(64), nullable=False) + resulting_revision_digest: Mapped[str | None] = mapped_column( + String(64), + nullable=True, + ) + conflict_reason_code: Mapped[str | None] = mapped_column( + String(128), + nullable=True, + ) + stale_reason_code: Mapped[str | None] = mapped_column( + String(128), + nullable=True, + ) + event_time: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=_utc_now, + nullable=False, + ) + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + + diagnostic_record_entry: Mapped[WritingDiagnosticRecord] = relationship( + WritingDiagnosticRecord, + back_populates="diagnostic_feedback_events", + ) + + def to_evidence_dict(self) -> dict[str, Any]: + """Serialize action, revision, and conflict codes without authored text.""" + return { + "feedback_event_id": self.feedback_event_id, + "diagnostic_record_id": self.diagnostic_record_id, + "feedback_action": self.feedback_action, + "reviewed_revision_digest": self.reviewed_revision_digest, + "resulting_revision_digest": self.resulting_revision_digest, + "conflict_reason_code": self.conflict_reason_code, + "stale_reason_code": self.stale_reason_code, + "event_time": self.event_time, + "idempotency_key": self.idempotency_key, + } + + def __repr__(self) -> str: + """Return a log-safe representation containing no replacement content.""" + return ( + "DiagnosticFeedbackEvent(" + f"feedback_event_id={self.feedback_event_id!r}, " + f"feedback_action={self.feedback_action!r})" + ) diff --git a/backend/tests/test_email_writing_migration.py b/backend/tests/test_email_writing_migration.py new file mode 100644 index 000000000..8c44984e7 --- /dev/null +++ b/backend/tests/test_email_writing_migration.py @@ -0,0 +1,191 @@ +"""Executable migration contracts for email-writing review evidence.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType, SimpleNamespace + +from sqlalchemy import create_engine, inspect +from sqlalchemy.dialects import postgresql +from sqlalchemy.schema import CreateIndex, CreateTable + +from db.email_writing_evidence import ( + DiagnosticFeedbackEvent, + EmailReviewSession, + WritingDiagnosticRecord, +) + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +MIGRATION_PATH = ( + BACKEND_ROOT + / "alembic" + / "versions" + / "20260812_0001_add_email_writing_review_evidence.py" +) +NEW_TABLE_NAMES = ( + "email_review_session", + "writing_diagnostic_record", + "diagnostic_feedback_event", +) +FORBIDDEN_PLAINTEXT_NAMES = ( + "source_body", + "draft_text", + "replacement_text", + "explanation_text", + "prompt_text", + "raw_output", + "provider_token", + "orchestration_trace", +) + + +def _load_migration() -> ModuleType: + spec = importlib.util.spec_from_file_location( + "email_writing_review_evidence_migration", + MIGRATION_PATH, + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_alembic_environment_registers_review_evidence_metadata() -> None: + """Autogenerate sees the modular evidence models without editing the legacy file.""" + environment_source = (BACKEND_ROOT / "alembic" / "env.py").read_text( + encoding="utf-8" + ) + assert "email_writing_evidence" in environment_source + assert ( + "target_metadata = EmailReviewSession.__table__.metadata" + in environment_source + ) + + +def test_migration_revision_and_metadata_match_orm_contract() -> None: + """The revision graph and table definitions remain synchronized with ORM code.""" + module = _load_migration() + assert module.revision == "20260812_email_writing_evidence" + assert module.down_revision == "0017_merge_newsdom_carddav_heads" + + migration_metadata = module._review_evidence_metadata() + orm_tables = { + EmailReviewSession.__table__.name: EmailReviewSession.__table__, + WritingDiagnosticRecord.__table__.name: WritingDiagnosticRecord.__table__, + DiagnosticFeedbackEvent.__table__.name: DiagnosticFeedbackEvent.__table__, + } + for table_name in NEW_TABLE_NAMES: + assert set(migration_metadata.tables[table_name].columns.keys()) == set( + orm_tables[table_name].columns.keys() + ) + assert { + constraint.name + for constraint in migration_metadata.tables[table_name].constraints + } == {constraint.name for constraint in orm_tables[table_name].constraints} + assert { + index.name for index in migration_metadata.tables[table_name].indexes + } == {index.name for index in orm_tables[table_name].indexes} + + +def test_sqlite_upgrade_downgrade_is_idempotent_and_preserves_unrelated_objects( + monkeypatch, +) -> None: + """SQLite receives all objects, and downgrade removes only this revision's DDL.""" + module = _load_migration() + engine = create_engine("sqlite:///:memory:") + try: + with engine.begin() as connection: + connection.exec_driver_sql("PRAGMA foreign_keys = ON") + connection.exec_driver_sql( + "CREATE TABLE email_records (id INTEGER PRIMARY KEY)" + ) + connection.exec_driver_sql( + "CREATE TABLE unrelated_audit_record " + "(audit_record_id INTEGER PRIMARY KEY)" + ) + monkeypatch.setattr( + module, + "op", + SimpleNamespace(get_bind=lambda: connection), + ) + + module.upgrade() + module.upgrade() + database_inspector = inspect(connection) + assert set(NEW_TABLE_NAMES).issubset(database_inspector.get_table_names()) + assert "unrelated_audit_record" in database_inspector.get_table_names() + assert "email_records" in database_inspector.get_table_names() + + assert { + index["name"] + for index in database_inspector.get_indexes("email_review_session") + } >= { + "ix_email_review_session_owner_scope", + "ix_email_review_session_expiry_status", + "ix_email_review_session_source_email", + } + assert { + constraint["name"] + for constraint in database_inspector.get_check_constraints( + "writing_diagnostic_record" + ) + } >= { + "ck_writing_diagnostic_record_selector_order", + "ck_writing_diagnostic_record_judge_score", + "ck_writing_diagnostic_record_admission_status", + } + + module.downgrade() + module.downgrade() + remaining_tables = set(inspect(connection).get_table_names()) + assert remaining_tables.isdisjoint(NEW_TABLE_NAMES) + assert "unrelated_audit_record" in remaining_tables + assert "email_records" in remaining_tables + finally: + engine.dispose() + + +def test_postgresql_ddl_compiles_with_named_constraints_and_indexes() -> None: + """The same metadata emits PostgreSQL-compatible named DDL without raw content.""" + module = _load_migration() + metadata = module._review_evidence_metadata() + rendered_statements: list[str] = [] + for table_name in NEW_TABLE_NAMES: + table = metadata.tables[table_name] + rendered_statements.append( + str(CreateTable(table).compile(dialect=postgresql.dialect())) + ) + rendered_statements.extend( + str(CreateIndex(index).compile(dialect=postgresql.dialect())) + for index in table.indexes + ) + + rendered_ddl = "\n".join(rendered_statements).lower() + for table_name in NEW_TABLE_NAMES: + assert table_name in rendered_ddl + for required_fragment in ( + "foreign key", + "on delete cascade", + "check", + "unique", + "criterion_categories_json json", + "ix_email_review_session_expiry_status", + "uq_writing_diagnostic_record_session_identifier", + "uq_diagnostic_feedback_event_owner_idempotency", + ): + assert required_fragment in rendered_ddl + for forbidden_name in FORBIDDEN_PLAINTEXT_NAMES: + assert forbidden_name not in rendered_ddl + + +def test_migration_source_contains_no_plaintext_evidence_fields() -> None: + """Static review cannot regress into storing mail, draft, or provider plaintext.""" + source = MIGRATION_PATH.read_text(encoding="utf-8").lower() + for forbidden_name in FORBIDDEN_PLAINTEXT_NAMES: + assert forbidden_name not in source + assert "source_email_id" in source + assert "candidate_hash" in source + assert "replacement_hash" in source + assert "explanation_hash" in source + assert "evidence_expires_at" in source diff --git a/backend/tests/test_email_writing_models.py b/backend/tests/test_email_writing_models.py new file mode 100644 index 000000000..8a1a5090a --- /dev/null +++ b/backend/tests/test_email_writing_models.py @@ -0,0 +1,335 @@ +"""Model-level contracts for privacy-minimized email-writing evidence.""" + +from __future__ import annotations + +import datetime +import json +import re + +import pytest +from sqlalchemy import create_engine, inspect +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from db.email_writing_evidence import ( + DiagnosticFeedbackEvent, + EmailReviewSession, + WritingDiagnosticRecord, +) + +UTC = datetime.timezone.utc +REVISION_DIGEST = "7c" * 32 +PROMPT_HASH = "sha256:" + "ab" * 32 +CANDIDATE_HASH = "sha256:" + "cd" * 32 +REPLACEMENT_HASH = "sha256:" + "de" * 32 +EXPLANATION_HASH = "sha256:" + "ef" * 32 +TWO_WORD_SNAKE_CASE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$") +NEW_MODEL_TYPES = ( + EmailReviewSession, + WritingDiagnosticRecord, + DiagnosticFeedbackEvent, +) +FORBIDDEN_CONTENT_NAMES = { + "source_body", + "source_email_body", + "source_text", + "draft_body", + "draft_text", + "replacement_text", + "explanation_text", + "prompt_text", + "raw_output", + "provider_token", + "orchestration_trace", + "complete_trace", +} + + +def _now() -> datetime.datetime: + return datetime.datetime(2026, 8, 12, 15, 0, tzinfo=UTC) + + +def _review_session(**overrides: object) -> EmailReviewSession: + values: dict[str, object] = { + "owner_user_id": "user_alpha", + "owner_organization_id": "organization_alpha", + "source_email_id": 1, + "revision_algorithm": "SHA-256", + "revision_digest": REVISION_DIGEST, + "revision_entity_tag": f'"sha256-{REVISION_DIGEST}"', + "projection_name": "inkspan-prosemirror-text", + "projection_version": 1, + "review_mode": "deep", + "language_profile": "ko-KR", + "review_status": "completed", + "workflow_identifier": "email_writing_review", + "workflow_version": "1", + "model_profile_id": "review_profile_v1", + "rubric_version": "email_writing_rubric_v1", + "judge_policy_version": "evaluation_only_v1", + "orchestration_mode": "conduct", + "prompt_hash": PROMPT_HASH, + "latency_bucket_ms": 2_000, + "cost_bucket_micro_usd": 5_000, + "prompt_token_bucket": 2_000, + "completion_token_bucket": 500, + "created_at": _now(), + "evidence_expires_at": _now() + datetime.timedelta(days=30), + } + values.update(overrides) + return EmailReviewSession(**values) + + +def _diagnostic_record(**overrides: object) -> WritingDiagnosticRecord: + values: dict[str, object] = { + "diagnostic_identifier": "diagnostic_alpha", + "diagnostic_category": "clarity", + "diagnostic_priority": "important", + "selector_start": 0, + "selector_end": 5, + "candidate_hash": CANDIDATE_HASH, + "replacement_hash": REPLACEMENT_HASH, + "explanation_hash": EXPLANATION_HASH, + "criterion_categories_json": ["clarity", "actionability"], + "judge_score": 0.92, + "admission_status": "admitted", + "admission_reason_code": "judge_supported", + "created_at": _now(), + } + values.update(overrides) + return WritingDiagnosticRecord(**values) + + +def _feedback_event(**overrides: object) -> DiagnosticFeedbackEvent: + values: dict[str, object] = { + "owner_user_id": "user_alpha", + "owner_organization_id": "organization_alpha", + "feedback_action": "applied", + "reviewed_revision_digest": REVISION_DIGEST, + "resulting_revision_digest": "8d" * 32, + "conflict_reason_code": None, + "stale_reason_code": None, + "event_time": _now() + datetime.timedelta(minutes=1), + "idempotency_key": "feedback_action_alpha", + } + values.update(overrides) + return DiagnosticFeedbackEvent(**values) + + +@pytest.fixture +def evidence_session() -> Session: + engine = create_engine("sqlite:///:memory:") + try: + with engine.begin() as connection: + connection.exec_driver_sql("PRAGMA foreign_keys = ON") + connection.exec_driver_sql( + "CREATE TABLE email_records (id INTEGER PRIMARY KEY)" + ) + connection.exec_driver_sql("INSERT INTO email_records (id) VALUES (1)") + for model_type in NEW_MODEL_TYPES: + model_type.__table__.create(connection) + with Session(engine) as session: + yield session + finally: + engine.dispose() + + +def test_new_database_objects_use_named_two_word_snake_case() -> None: + """Every new table, column, index, constraint, and relationship is explicit.""" + for model_type in NEW_MODEL_TYPES: + table = model_type.__table__ + assert TWO_WORD_SNAKE_CASE.fullmatch(table.name) + assert all(TWO_WORD_SNAKE_CASE.fullmatch(column.name) for column in table.columns) + assert table.constraints + assert all( + constraint.name is not None + and TWO_WORD_SNAKE_CASE.fullmatch(constraint.name) + for constraint in table.constraints + ) + assert all( + index.name is not None and TWO_WORD_SNAKE_CASE.fullmatch(index.name) + for index in table.indexes + ) + assert all( + TWO_WORD_SNAKE_CASE.fullmatch(relationship.key) + for relationship in model_type.__mapper__.relationships + ) + + +def test_evidence_schema_contains_no_raw_authored_or_provider_content() -> None: + """The persistence boundary cannot accidentally accept sensitive plaintext.""" + all_column_names = { + column.name + for model_type in NEW_MODEL_TYPES + for column in model_type.__table__.columns + } + assert all_column_names.isdisjoint(FORBIDDEN_CONTENT_NAMES) + for forbidden_name in FORBIDDEN_CONTENT_NAMES: + with pytest.raises(TypeError, match=forbidden_name): + _review_session(**{forbidden_name: "SECRET_AUTHORED_CONTENT"}) + + +def test_review_evidence_round_trip_and_safe_serialization( + evidence_session: Session, +) -> None: + """A complete evidence graph round-trips without storing authored content.""" + review_session = _review_session() + diagnostic_record = _diagnostic_record() + feedback_event = _feedback_event() + diagnostic_record.diagnostic_feedback_events.append(feedback_event) + review_session.writing_diagnostic_records.append(diagnostic_record) + evidence_session.add(review_session) + evidence_session.commit() + + loaded = evidence_session.query(EmailReviewSession).one() + loaded_diagnostic = loaded.writing_diagnostic_records[0] + loaded_feedback = loaded_diagnostic.diagnostic_feedback_events[0] + assert loaded.review_session_id + assert loaded_diagnostic.diagnostic_identifier == "diagnostic_alpha" + assert loaded_feedback.feedback_action == "applied" + + serialized = json.dumps( + [ + loaded.to_evidence_dict(), + loaded_diagnostic.to_evidence_dict(), + loaded_feedback.to_evidence_dict(), + ], + sort_keys=True, + default=str, + ) + rendered_log = "\n".join( + [repr(loaded), repr(loaded_diagnostic), repr(loaded_feedback)] + ) + for forbidden_value in ( + "SECRET_AUTHORED_CONTENT", + "full draft body", + "provider bearer token", + "complete orchestration trace", + ): + assert forbidden_value not in serialized + assert forbidden_value not in rendered_log + assert "review_session_id" in serialized + assert "prompt_hash" in serialized + assert '"source_email_id"' not in serialized + assert '"owner_user_id"' not in serialized + assert '"owner_organization_id"' not in serialized + assert "candidate_hash" in serialized + assert "feedback_action" in serialized + + +def test_review_session_retention_and_owner_indexes_are_queryable( + evidence_session: Session, +) -> None: + """Operators can locate expired evidence without reading email content.""" + evidence_session.add(_review_session(review_status="abstained")) + evidence_session.commit() + + database_inspector = inspect(evidence_session.get_bind()) + index_names = { + index["name"] + for index in database_inspector.get_indexes("email_review_session") + } + assert "ix_email_review_session_owner_scope" in index_names + assert "ix_email_review_session_expiry_status" in index_names + assert "ix_email_review_session_source_email" in index_names + + +def test_unique_diagnostic_identifier_per_review_session( + evidence_session: Session, +) -> None: + """A model response cannot duplicate one opaque diagnostic identifier.""" + review_session = _review_session() + review_session.writing_diagnostic_records.extend( + [_diagnostic_record(), _diagnostic_record()] + ) + evidence_session.add(review_session) + + with pytest.raises(IntegrityError): + evidence_session.commit() + evidence_session.rollback() + + +def test_feedback_idempotency_key_is_unique_within_owner_scope( + evidence_session: Session, +) -> None: + """Retries cannot persist the same user action twice.""" + first_review = _review_session() + first_diagnostic = _diagnostic_record(diagnostic_identifier="diagnostic_one") + first_diagnostic.diagnostic_feedback_events.append(_feedback_event()) + first_review.writing_diagnostic_records.append(first_diagnostic) + evidence_session.add(first_review) + evidence_session.commit() + + second_review = _review_session() + second_diagnostic = _diagnostic_record(diagnostic_identifier="diagnostic_two") + second_diagnostic.diagnostic_feedback_events.append(_feedback_event()) + second_review.writing_diagnostic_records.append(second_diagnostic) + evidence_session.add(second_review) + + with pytest.raises(IntegrityError): + evidence_session.commit() + evidence_session.rollback() + + +@pytest.mark.parametrize( + ("factory", "overrides"), + [ + (_review_session, {"review_mode": "keyword_mode"}), + (_review_session, {"latency_bucket_ms": -1}), + (_review_session, {"cost_bucket_micro_usd": -1}), + ( + _review_session, + {"evidence_expires_at": _now() - datetime.timedelta(seconds=1)}, + ), + (_diagnostic_record, {"selector_start": -1}), + (_diagnostic_record, {"selector_end": 0}), + (_diagnostic_record, {"judge_score": 1.01}), + (_diagnostic_record, {"admission_status": "automatically_trusted"}), + (_feedback_event, {"feedback_action": "send_email"}), + (_feedback_event, {"resulting_revision_digest": None}), + ], +) +def test_database_checks_reject_invalid_evidence( + evidence_session: Session, + factory, + overrides: dict[str, object], +) -> None: + """Invalid enum, range, selector, retention, and Apply states fail closed.""" + review_session = _review_session() + diagnostic_record = _diagnostic_record() + feedback_event = _feedback_event() + + candidate = factory(**overrides) + if isinstance(candidate, EmailReviewSession): + review_session = candidate + elif isinstance(candidate, WritingDiagnosticRecord): + diagnostic_record = candidate + else: + feedback_event = candidate + + diagnostic_record.diagnostic_feedback_events.append(feedback_event) + review_session.writing_diagnostic_records.append(diagnostic_record) + evidence_session.add(review_session) + + with pytest.raises(IntegrityError): + evidence_session.commit() + evidence_session.rollback() + + +def test_deleting_review_session_cascades_minimized_evidence( + evidence_session: Session, +) -> None: + """Retention deletion removes dependent hashes and feedback in one operation.""" + review_session = _review_session() + diagnostic_record = _diagnostic_record() + diagnostic_record.diagnostic_feedback_events.append(_feedback_event()) + review_session.writing_diagnostic_records.append(diagnostic_record) + evidence_session.add(review_session) + evidence_session.commit() + + evidence_session.delete(review_session) + evidence_session.commit() + + assert evidence_session.query(EmailReviewSession).count() == 0 + assert evidence_session.query(WritingDiagnosticRecord).count() == 0 + assert evidence_session.query(DiagnosticFeedbackEvent).count() == 0