From 2b7c2f5aed975834ce7f4a826ff203ee163aa5d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:03:34 +0900 Subject: [PATCH 01/14] test(email-writing): define review evidence contracts --- .../workflows/email-writing-evidence-tdd.yml | 75 ++++ backend/tests/test_email_writing_migration.py | 177 ++++++++++ backend/tests/test_email_writing_models.py | 324 ++++++++++++++++++ 3 files changed, 576 insertions(+) create mode 100644 .github/workflows/email-writing-evidence-tdd.yml create mode 100644 backend/tests/test_email_writing_migration.py create mode 100644 backend/tests/test_email_writing_models.py diff --git a/.github/workflows/email-writing-evidence-tdd.yml b/.github/workflows/email-writing-evidence-tdd.yml new file mode 100644 index 000000000..14ad97764 --- /dev/null +++ b/.github/workflows/email-writing-evidence-tdd.yml @@ -0,0 +1,75 @@ +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 + run: | + cd backend + python -m pytest -q \ + tests/test_email_writing_models.py \ + tests/test_email_writing_migration.py + - name: Verify migration statement and branch coverage + run: | + cd backend + python -m coverage erase + 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 + 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/models.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/tests/test_email_writing_migration.py b/backend/tests/test_email_writing_migration.py new file mode 100644 index 000000000..2f39b3003 --- /dev/null +++ b/backend/tests/test_email_writing_migration.py @@ -0,0 +1,177 @@ +"""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.models 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_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:") + 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 + 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..de10e4300 --- /dev/null +++ b/backend/tests/test_email_writing_models.py @@ -0,0 +1,324 @@ +"""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.models 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:") + 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 + 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() + assert loaded.review_session_id + assert loaded.writing_diagnostic_records[0].diagnostic_identifier == ( + "diagnostic_alpha" + ) + assert ( + loaded.writing_diagnostic_records[0] + .diagnostic_feedback_events[0] + .feedback_action + == "applied" + ) + + serialized = json.dumps(loaded.to_evidence_dict(), sort_keys=True, default=str) + rendered_log = repr(loaded) + 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" 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 From c458e2432cefe0ec6772adcfa27d01b35cc0e97c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:12:03 +0900 Subject: [PATCH 02/14] feat(email-writing): persist privacy-minimized review evidence --- .../workflows/email-writing-evidence-tdd.yml | 3 +- backend/alembic/env.py | 1 + ..._0001_add_email_writing_review_evidence.py | 279 +++++++++++ backend/db/email_writing_evidence.py | 439 ++++++++++++++++++ backend/tests/test_email_writing_migration.py | 11 +- backend/tests/test_email_writing_models.py | 29 +- 6 files changed, 749 insertions(+), 13 deletions(-) create mode 100644 backend/alembic/versions/20260812_0001_add_email_writing_review_evidence.py create mode 100644 backend/db/email_writing_evidence.py diff --git a/.github/workflows/email-writing-evidence-tdd.yml b/.github/workflows/email-writing-evidence-tdd.yml index 14ad97764..e2bbd8446 100644 --- a/.github/workflows/email-writing-evidence-tdd.yml +++ b/.github/workflows/email-writing-evidence-tdd.yml @@ -69,7 +69,8 @@ jobs: run: | cd backend python -m ruff check \ - db/models.py \ + 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..8d3620a92 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config from core.config import settings +from db import email_writing_evidence as _email_writing_evidence from db.models import Base config = context.config 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..d7d456c79 --- /dev/null +++ b/backend/db/email_writing_evidence.py @@ -0,0 +1,439 @@ +"""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, + "owner_user_id": self.owner_user_id, + "owner_organization_id": self.owner_organization_id, + "source_email_id": self.source_email_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, + "owner_user_id": self.owner_user_id, + "owner_organization_id": self.owner_organization_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 index 2f39b3003..cb4b7e675 100644 --- a/backend/tests/test_email_writing_migration.py +++ b/backend/tests/test_email_writing_migration.py @@ -10,7 +10,7 @@ from sqlalchemy.dialects import postgresql from sqlalchemy.schema import CreateIndex, CreateTable -from db.models import ( +from db.email_writing_evidence import ( DiagnosticFeedbackEvent, EmailReviewSession, WritingDiagnosticRecord, @@ -51,6 +51,15 @@ def _load_migration() -> ModuleType: 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 = Base.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() diff --git a/backend/tests/test_email_writing_models.py b/backend/tests/test_email_writing_models.py index de10e4300..adf558da8 100644 --- a/backend/tests/test_email_writing_models.py +++ b/backend/tests/test_email_writing_models.py @@ -11,7 +11,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from db.models import ( +from db.email_writing_evidence import ( DiagnosticFeedbackEvent, EmailReviewSession, WritingDiagnosticRecord, @@ -180,19 +180,24 @@ def test_review_evidence_round_trip_and_safe_serialization( 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.writing_diagnostic_records[0].diagnostic_identifier == ( - "diagnostic_alpha" + 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, ) - assert ( - loaded.writing_diagnostic_records[0] - .diagnostic_feedback_events[0] - .feedback_action - == "applied" + rendered_log = "\n".join( + [repr(loaded), repr(loaded_diagnostic), repr(loaded_feedback)] ) - - serialized = json.dumps(loaded.to_evidence_dict(), sort_keys=True, default=str) - rendered_log = repr(loaded) for forbidden_value in ( "SECRET_AUTHORED_CONTENT", "full draft body", @@ -204,6 +209,8 @@ def test_review_evidence_round_trip_and_safe_serialization( assert "review_session_id" in serialized assert "prompt_hash" in serialized assert "source_email_id" in serialized + assert "candidate_hash" in serialized + assert "feedback_action" in serialized def test_review_session_retention_and_owner_indexes_are_queryable( From 001da508739ff1b49e3d857e593fb27cd4339269 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:16:14 +0900 Subject: [PATCH 03/14] fix(email-writing): register evidence metadata explicitly --- backend/alembic/env.py | 5 ++--- backend/tests/test_email_writing_migration.py | 5 ++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 8d3620a92..e8319725f 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -7,15 +7,14 @@ from sqlalchemy.ext.asyncio import async_engine_from_config from core.config import settings -from db import email_writing_evidence as _email_writing_evidence -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/tests/test_email_writing_migration.py b/backend/tests/test_email_writing_migration.py index cb4b7e675..5aa6b2246 100644 --- a/backend/tests/test_email_writing_migration.py +++ b/backend/tests/test_email_writing_migration.py @@ -57,7 +57,10 @@ def test_alembic_environment_registers_review_evidence_metadata() -> None: encoding="utf-8" ) assert "email_writing_evidence" in environment_source - assert "target_metadata = Base.metadata" in environment_source + assert ( + "target_metadata = EmailReviewSession.__table__.metadata" + in environment_source + ) def test_migration_revision_and_metadata_match_orm_contract() -> None: From 764f8e9d5a2a00a6732f9b936d6110d2e194e0ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:55:35 +0900 Subject: [PATCH 04/14] ci(email-writing): add one-shot evidence review repair --- .../scripts/address_email_evidence_review.py | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 .github/scripts/address_email_evidence_review.py diff --git a/.github/scripts/address_email_evidence_review.py b/.github/scripts/address_email_evidence_review.py new file mode 100644 index 000000000..44e03d753 --- /dev/null +++ b/.github/scripts/address_email_evidence_review.py @@ -0,0 +1,273 @@ +"""Apply bounded CodeRabbit fixes for email-writing review evidence PR #1328.""" + +from __future__ import annotations + +from pathlib import Path + + +def replace_once(path: Path, old: str, new: str, label: str) -> None: + """Replace one exact reviewed anchor or fail closed on branch drift.""" + source = path.read_text(encoding="utf-8") + occurrence_count = source.count(old) + if occurrence_count != 1: + raise SystemExit( + f"{label} anchor mismatch: expected 1 occurrence, found {occurrence_count}" + ) + path.write_text(source.replace(old, new), encoding="utf-8") + + +def update_evidence_serializer() -> None: + """Remove tenant and sequential email identifiers from public evidence output.""" + path = Path("backend/db/email_writing_evidence.py") + old = ''' return { + "review_session_id": self.review_session_id, + "owner_user_id": self.owner_user_id, + "owner_organization_id": self.owner_organization_id, + "source_email_id": self.source_email_id, + "revision_algorithm": self.revision_algorithm,''' + new = ''' return { + "review_session_id": self.review_session_id, + "revision_algorithm": self.revision_algorithm,''' + replace_once(path, old, new, "privacy-minimized serializer") + + +def update_model_tests() -> None: + """Guarantee engine cleanup and assert public serialization omits identifiers.""" + path = Path("backend/tests/test_email_writing_models.py") + old_fixture = '''@pytest.fixture +def evidence_session() -> Session: + engine = create_engine("sqlite:///:memory:") + 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 + engine.dispose() +''' + new_fixture = '''@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() +''' + replace_once(path, old_fixture, new_fixture, "model-test engine cleanup") + + old_assertions = ''' assert "review_session_id" in serialized + assert "prompt_hash" in serialized + assert "source_email_id" in serialized + assert "candidate_hash" in serialized + assert "feedback_action" in serialized +''' + new_assertions = ''' 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 +''' + replace_once(path, old_assertions, new_assertions, "privacy serialization assertions") + + +def update_migration_test_cleanup() -> None: + """Dispose the migration-test engine even when setup or assertions fail.""" + path = Path("backend/tests/test_email_writing_migration.py") + old = ''' engine = create_engine("sqlite:///:memory:") + 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 + engine.dispose() +''' + new = ''' 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() +''' + replace_once(path, old, new, "migration-test engine cleanup") + + +def update_evidence_workflow() -> None: + """Run both test invocations under safety settings and reject bad output.""" + path = Path(".github/workflows/email-writing-evidence-tdd.yml") + old = ''' - name: Run privacy-minimized model and migration tests + run: | + cd backend + python -m pytest -q \\ + tests/test_email_writing_models.py \\ + tests/test_email_writing_migration.py + - name: Verify migration statement and branch coverage + run: | + cd backend + python -m coverage erase + 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 + python -m coverage report --show-missing --fail-under=100 \\ + alembic/versions/20260812_0001_add_email_writing_review_evidence.py +''' + new = ''' - 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 +''' + replace_once(path, old, new, "evidence workflow safety settings") + + +def main() -> None: + """Apply all currently valid review fixes in one bounded mutation.""" + update_evidence_serializer() + update_model_tests() + update_migration_test_cleanup() + update_evidence_workflow() + + +if __name__ == "__main__": + main() From 9cec4490adef0757ee2bbd947768ab24156ef9f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:56:06 +0900 Subject: [PATCH 05/14] ci(email-writing): run one-shot evidence review repair --- .../address-email-evidence-review.yml | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 .github/workflows/address-email-evidence-review.yml diff --git a/.github/workflows/address-email-evidence-review.yml b/.github/workflows/address-email-evidence-review.yml new file mode 100644 index 000000000..6fe423e26 --- /dev/null +++ b/.github/workflows/address-email-evidence-review.yml @@ -0,0 +1,143 @@ +name: Address Email Evidence Review + +on: + push: + branches: + - feat/llm-email-writing-review-evidence-task3 + paths: + - .github/workflows/address-email-evidence-review.yml + +permissions: + contents: write + +concurrency: + group: address-email-evidence-review + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + repair: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feat/llm-email-writing-review-evidence-task3 + fetch-depth: 0 + - 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: Apply verified review fixes + run: python .github/scripts/address_email_evidence_review.py + - name: Run repaired tests under safety settings + 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 coverage under safety settings + 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 repaired scope + 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 + - name: Validate exact patch scope + run: | + set -euo pipefail + git diff --check + mapfile -t changed_files < <(git diff --name-only | LC_ALL=C sort) + expected_files=( + .github/workflows/email-writing-evidence-tdd.yml + backend/db/email_writing_evidence.py + backend/tests/test_email_writing_migration.py + backend/tests/test_email_writing_models.py + ) + if ! diff -u \ + <(printf '%s\n' "${expected_files[@]}" | LC_ALL=C sort) \ + <(printf '%s\n' "${changed_files[@]}"); then + echo "::error::Evidence review repair changed an unexpected file." + exit 1 + fi + - name: Commit verified review repair and remove one-shot files + env: + BRANCH_NAME: feat/llm-email-writing-review-evidence-task3 + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + .github/workflows/email-writing-evidence-tdd.yml \ + backend/db/email_writing_evidence.py \ + backend/tests/test_email_writing_migration.py \ + backend/tests/test_email_writing_models.py + git rm \ + .github/scripts/address_email_evidence_review.py \ + .github/workflows/address-email-evidence-review.yml + git commit -m "fix(email-writing): address review evidence findings" + git push origin "HEAD:${BRANCH_NAME}" From 3522691f0b72a18145c8a608cd8e926f5432a59f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 01:04:57 +0900 Subject: [PATCH 06/14] ci(email-writing): remove feedback tenant identifiers --- .../scripts/address_email_evidence_review.py | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/scripts/address_email_evidence_review.py b/.github/scripts/address_email_evidence_review.py index 44e03d753..6dabd5e42 100644 --- a/.github/scripts/address_email_evidence_review.py +++ b/.github/scripts/address_email_evidence_review.py @@ -19,16 +19,38 @@ def replace_once(path: Path, old: str, new: str, label: str) -> None: def update_evidence_serializer() -> None: """Remove tenant and sequential email identifiers from public evidence output.""" path = Path("backend/db/email_writing_evidence.py") - old = ''' return { + old_session = ''' return { "review_session_id": self.review_session_id, "owner_user_id": self.owner_user_id, "owner_organization_id": self.owner_organization_id, "source_email_id": self.source_email_id, "revision_algorithm": self.revision_algorithm,''' - new = ''' return { + new_session = ''' return { "review_session_id": self.review_session_id, "revision_algorithm": self.revision_algorithm,''' - replace_once(path, old, new, "privacy-minimized serializer") + replace_once( + path, + old_session, + new_session, + "privacy-minimized session serializer", + ) + + old_feedback = ''' return { + "feedback_event_id": self.feedback_event_id, + "diagnostic_record_id": self.diagnostic_record_id, + "owner_user_id": self.owner_user_id, + "owner_organization_id": self.owner_organization_id, + "feedback_action": self.feedback_action,''' + new_feedback = ''' return { + "feedback_event_id": self.feedback_event_id, + "diagnostic_record_id": self.diagnostic_record_id, + "feedback_action": self.feedback_action,''' + replace_once( + path, + old_feedback, + new_feedback, + "privacy-minimized feedback serializer", + ) def update_model_tests() -> None: From 2da53097da1b9b7a03adc8bdb85455f2749aed79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 01:05:46 +0900 Subject: [PATCH 07/14] ci(email-writing): rerun corrected evidence repair --- .github/workflows/address-email-evidence-review.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/address-email-evidence-review.yml b/.github/workflows/address-email-evidence-review.yml index 6fe423e26..24be34f6d 100644 --- a/.github/workflows/address-email-evidence-review.yml +++ b/.github/workflows/address-email-evidence-review.yml @@ -5,6 +5,7 @@ on: branches: - feat/llm-email-writing-review-evidence-task3 paths: + - .github/scripts/address_email_evidence_review.py - .github/workflows/address-email-evidence-review.yml permissions: @@ -136,7 +137,7 @@ jobs: backend/db/email_writing_evidence.py \ backend/tests/test_email_writing_migration.py \ backend/tests/test_email_writing_models.py - git rm \ + git rm -f \ .github/scripts/address_email_evidence_review.py \ .github/workflows/address-email-evidence-review.yml git commit -m "fix(email-writing): address review evidence findings" From 2c91c4409594400cde8545d2da3befaebcbaedbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 07:58:25 +0900 Subject: [PATCH 08/14] fix(email-writing): harden evidence tests under safety settings --- .../workflows/email-writing-evidence-tdd.yml | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/.github/workflows/email-writing-evidence-tdd.yml b/.github/workflows/email-writing-evidence-tdd.yml index e2bbd8446..bfda34e17 100644 --- a/.github/workflows/email-writing-evidence-tdd.yml +++ b/.github/workflows/email-writing-evidence-tdd.yml @@ -51,18 +51,48 @@ jobs: -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 + 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 + -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 From 7c28cc3302e474e7e64279e39a628a399f1184e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 07:59:05 +0900 Subject: [PATCH 09/14] ci(email-writing): split backend repair from workflow mutation --- .../scripts/address_email_evidence_review.py | 73 +------------------ 1 file changed, 2 insertions(+), 71 deletions(-) diff --git a/.github/scripts/address_email_evidence_review.py b/.github/scripts/address_email_evidence_review.py index 6dabd5e42..7e97926fd 100644 --- a/.github/scripts/address_email_evidence_review.py +++ b/.github/scripts/address_email_evidence_review.py @@ -1,4 +1,4 @@ -"""Apply bounded CodeRabbit fixes for email-writing review evidence PR #1328.""" +"""Apply bounded backend review fixes for email-writing evidence PR #1328.""" from __future__ import annotations @@ -215,80 +215,11 @@ def update_migration_test_cleanup() -> None: replace_once(path, old, new, "migration-test engine cleanup") -def update_evidence_workflow() -> None: - """Run both test invocations under safety settings and reject bad output.""" - path = Path(".github/workflows/email-writing-evidence-tdd.yml") - old = ''' - name: Run privacy-minimized model and migration tests - run: | - cd backend - python -m pytest -q \\ - tests/test_email_writing_models.py \\ - tests/test_email_writing_migration.py - - name: Verify migration statement and branch coverage - run: | - cd backend - python -m coverage erase - 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 - python -m coverage report --show-missing --fail-under=100 \\ - alembic/versions/20260812_0001_add_email_writing_review_evidence.py -''' - new = ''' - 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 -''' - replace_once(path, old, new, "evidence workflow safety settings") - - def main() -> None: - """Apply all currently valid review fixes in one bounded mutation.""" + """Apply all currently valid backend review fixes in one bounded mutation.""" update_evidence_serializer() update_model_tests() update_migration_test_cleanup() - update_evidence_workflow() if __name__ == "__main__": From b688d0a39457d18948a6ed1641032bae00db742d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 07:59:50 +0900 Subject: [PATCH 10/14] ci(email-writing): materialize backend review repair without workflow push --- .github/workflows/address-email-evidence-review.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/address-email-evidence-review.yml b/.github/workflows/address-email-evidence-review.yml index 24be34f6d..14dd51969 100644 --- a/.github/workflows/address-email-evidence-review.yml +++ b/.github/workflows/address-email-evidence-review.yml @@ -114,7 +114,6 @@ jobs: git diff --check mapfile -t changed_files < <(git diff --name-only | LC_ALL=C sort) expected_files=( - .github/workflows/email-writing-evidence-tdd.yml backend/db/email_writing_evidence.py backend/tests/test_email_writing_migration.py backend/tests/test_email_writing_models.py @@ -125,7 +124,7 @@ jobs: echo "::error::Evidence review repair changed an unexpected file." exit 1 fi - - name: Commit verified review repair and remove one-shot files + - name: Commit verified backend review repair env: BRANCH_NAME: feat/llm-email-writing-review-evidence-task3 run: | @@ -133,12 +132,9 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add \ - .github/workflows/email-writing-evidence-tdd.yml \ backend/db/email_writing_evidence.py \ backend/tests/test_email_writing_migration.py \ backend/tests/test_email_writing_models.py - git rm -f \ - .github/scripts/address_email_evidence_review.py \ - .github/workflows/address-email-evidence-review.yml + git rm -f .github/scripts/address_email_evidence_review.py git commit -m "fix(email-writing): address review evidence findings" git push origin "HEAD:${BRANCH_NAME}" From 96cd7f85ac4353f38e27461e11cea29a9df8db80 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:00:50 +0000 Subject: [PATCH 11/14] fix(email-writing): address review evidence findings --- .../scripts/address_email_evidence_review.py | 226 ------------------ backend/db/email_writing_evidence.py | 5 - backend/tests/test_email_writing_migration.py | 96 ++++---- backend/tests/test_email_writing_models.py | 28 ++- 4 files changed, 65 insertions(+), 290 deletions(-) delete mode 100644 .github/scripts/address_email_evidence_review.py diff --git a/.github/scripts/address_email_evidence_review.py b/.github/scripts/address_email_evidence_review.py deleted file mode 100644 index 7e97926fd..000000000 --- a/.github/scripts/address_email_evidence_review.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Apply bounded backend review fixes for email-writing evidence PR #1328.""" - -from __future__ import annotations - -from pathlib import Path - - -def replace_once(path: Path, old: str, new: str, label: str) -> None: - """Replace one exact reviewed anchor or fail closed on branch drift.""" - source = path.read_text(encoding="utf-8") - occurrence_count = source.count(old) - if occurrence_count != 1: - raise SystemExit( - f"{label} anchor mismatch: expected 1 occurrence, found {occurrence_count}" - ) - path.write_text(source.replace(old, new), encoding="utf-8") - - -def update_evidence_serializer() -> None: - """Remove tenant and sequential email identifiers from public evidence output.""" - path = Path("backend/db/email_writing_evidence.py") - old_session = ''' return { - "review_session_id": self.review_session_id, - "owner_user_id": self.owner_user_id, - "owner_organization_id": self.owner_organization_id, - "source_email_id": self.source_email_id, - "revision_algorithm": self.revision_algorithm,''' - new_session = ''' return { - "review_session_id": self.review_session_id, - "revision_algorithm": self.revision_algorithm,''' - replace_once( - path, - old_session, - new_session, - "privacy-minimized session serializer", - ) - - old_feedback = ''' return { - "feedback_event_id": self.feedback_event_id, - "diagnostic_record_id": self.diagnostic_record_id, - "owner_user_id": self.owner_user_id, - "owner_organization_id": self.owner_organization_id, - "feedback_action": self.feedback_action,''' - new_feedback = ''' return { - "feedback_event_id": self.feedback_event_id, - "diagnostic_record_id": self.diagnostic_record_id, - "feedback_action": self.feedback_action,''' - replace_once( - path, - old_feedback, - new_feedback, - "privacy-minimized feedback serializer", - ) - - -def update_model_tests() -> None: - """Guarantee engine cleanup and assert public serialization omits identifiers.""" - path = Path("backend/tests/test_email_writing_models.py") - old_fixture = '''@pytest.fixture -def evidence_session() -> Session: - engine = create_engine("sqlite:///:memory:") - 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 - engine.dispose() -''' - new_fixture = '''@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() -''' - replace_once(path, old_fixture, new_fixture, "model-test engine cleanup") - - old_assertions = ''' assert "review_session_id" in serialized - assert "prompt_hash" in serialized - assert "source_email_id" in serialized - assert "candidate_hash" in serialized - assert "feedback_action" in serialized -''' - new_assertions = ''' 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 -''' - replace_once(path, old_assertions, new_assertions, "privacy serialization assertions") - - -def update_migration_test_cleanup() -> None: - """Dispose the migration-test engine even when setup or assertions fail.""" - path = Path("backend/tests/test_email_writing_migration.py") - old = ''' engine = create_engine("sqlite:///:memory:") - 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 - engine.dispose() -''' - new = ''' 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() -''' - replace_once(path, old, new, "migration-test engine cleanup") - - -def main() -> None: - """Apply all currently valid backend review fixes in one bounded mutation.""" - update_evidence_serializer() - update_model_tests() - update_migration_test_cleanup() - - -if __name__ == "__main__": - main() diff --git a/backend/db/email_writing_evidence.py b/backend/db/email_writing_evidence.py index d7d456c79..5e39a502c 100644 --- a/backend/db/email_writing_evidence.py +++ b/backend/db/email_writing_evidence.py @@ -169,9 +169,6 @@ def to_evidence_dict(self) -> dict[str, Any]: """Serialize only privacy-minimized operational evidence.""" return { "review_session_id": self.review_session_id, - "owner_user_id": self.owner_user_id, - "owner_organization_id": self.owner_organization_id, - "source_email_id": self.source_email_id, "revision_algorithm": self.revision_algorithm, "revision_digest": self.revision_digest, "revision_entity_tag": self.revision_entity_tag, @@ -419,8 +416,6 @@ def to_evidence_dict(self) -> dict[str, Any]: return { "feedback_event_id": self.feedback_event_id, "diagnostic_record_id": self.diagnostic_record_id, - "owner_user_id": self.owner_user_id, - "owner_organization_id": self.owner_organization_id, "feedback_action": self.feedback_action, "reviewed_revision_digest": self.reviewed_revision_digest, "resulting_revision_digest": self.resulting_revision_digest, diff --git a/backend/tests/test_email_writing_migration.py b/backend/tests/test_email_writing_migration.py index 5aa6b2246..8c44984e7 100644 --- a/backend/tests/test_email_writing_migration.py +++ b/backend/tests/test_email_writing_migration.py @@ -94,54 +94,56 @@ def test_sqlite_upgrade_downgrade_is_idempotent_and_preserves_unrelated_objects( """SQLite receives all objects, and downgrade removes only this revision's DDL.""" module = _load_migration() engine = create_engine("sqlite:///:memory:") - 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" + 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)" ) - } >= { - "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 - engine.dispose() + 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: diff --git a/backend/tests/test_email_writing_models.py b/backend/tests/test_email_writing_models.py index adf558da8..8a1a5090a 100644 --- a/backend/tests/test_email_writing_models.py +++ b/backend/tests/test_email_writing_models.py @@ -119,17 +119,19 @@ def _feedback_event(**overrides: object) -> DiagnosticFeedbackEvent: @pytest.fixture def evidence_session() -> Session: engine = create_engine("sqlite:///:memory:") - 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 - engine.dispose() + 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: @@ -208,7 +210,9 @@ def test_review_evidence_round_trip_and_safe_serialization( assert forbidden_value not in rendered_log assert "review_session_id" in serialized assert "prompt_hash" in serialized - assert "source_email_id" 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 From 4a182c4e203636271df4e8b6511e58c41b9cf218 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:02:23 +0900 Subject: [PATCH 12/14] ci(email-writing): remove completed one-shot repair workflow --- .../address-email-evidence-review.yml | 140 ------------------ 1 file changed, 140 deletions(-) delete mode 100644 .github/workflows/address-email-evidence-review.yml diff --git a/.github/workflows/address-email-evidence-review.yml b/.github/workflows/address-email-evidence-review.yml deleted file mode 100644 index 14dd51969..000000000 --- a/.github/workflows/address-email-evidence-review.yml +++ /dev/null @@ -1,140 +0,0 @@ -name: Address Email Evidence Review - -on: - push: - branches: - - feat/llm-email-writing-review-evidence-task3 - paths: - - .github/scripts/address_email_evidence_review.py - - .github/workflows/address-email-evidence-review.yml - -permissions: - contents: write - -concurrency: - group: address-email-evidence-review - cancel-in-progress: false - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - repair: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: feat/llm-email-writing-review-evidence-task3 - fetch-depth: 0 - - 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: Apply verified review fixes - run: python .github/scripts/address_email_evidence_review.py - - name: Run repaired tests under safety settings - 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 coverage under safety settings - 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 repaired scope - 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 - - name: Validate exact patch scope - run: | - set -euo pipefail - git diff --check - mapfile -t changed_files < <(git diff --name-only | LC_ALL=C sort) - expected_files=( - backend/db/email_writing_evidence.py - backend/tests/test_email_writing_migration.py - backend/tests/test_email_writing_models.py - ) - if ! diff -u \ - <(printf '%s\n' "${expected_files[@]}" | LC_ALL=C sort) \ - <(printf '%s\n' "${changed_files[@]}"); then - echo "::error::Evidence review repair changed an unexpected file." - exit 1 - fi - - name: Commit verified backend review repair - env: - BRANCH_NAME: feat/llm-email-writing-review-evidence-task3 - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - backend/db/email_writing_evidence.py \ - backend/tests/test_email_writing_migration.py \ - backend/tests/test_email_writing_models.py - git rm -f .github/scripts/address_email_evidence_review.py - git commit -m "fix(email-writing): address review evidence findings" - git push origin "HEAD:${BRANCH_NAME}" From 9d08e149643f2aa6d2b1761591325b1f16aad734 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:51:26 +0900 Subject: [PATCH 13/14] ci(email-writing): refresh Task 3 on current contracts parent --- .../refresh-email-writing-evidence-parent.yml | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .github/workflows/refresh-email-writing-evidence-parent.yml diff --git a/.github/workflows/refresh-email-writing-evidence-parent.yml b/.github/workflows/refresh-email-writing-evidence-parent.yml new file mode 100644 index 000000000..2a7235494 --- /dev/null +++ b/.github/workflows/refresh-email-writing-evidence-parent.yml @@ -0,0 +1,68 @@ +name: Refresh Email Writing Evidence Parent + +on: + push: + branches: + - feat/llm-email-writing-review-evidence-task3 + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: refresh-email-writing-evidence-parent + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONWARNINGS: error + DISABLE_BACKGROUND_WORKERS: "1" + +jobs: + refresh: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feat/llm-email-writing-review-evidence-task3 + persist-credentials: true + fetch-depth: 0 + - name: Merge the current Task 2 parent + run: | + set -euo pipefail + git fetch origin feat/llm-email-writing-contracts-task2 + git config user.name "CWL Email Writing Task 3 Refresh" + git config user.email "actions@users.noreply.github.com" + git merge --no-edit --no-ff origin/feat/llm-email-writing-contracts-task2 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: backend/requirements-hashes.txt + - name: Install hash-locked backend dependencies + run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt + - name: Verify privacy-minimized evidence contracts + run: | + cd backend + python -m pytest -q \ + tests/test_email_writing_models.py \ + tests/test_email_writing_migration.py + 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 + python -m compileall -q \ + db/email_writing_evidence.py \ + alembic/env.py \ + alembic/versions/20260812_0001_add_email_writing_review_evidence.py + - name: Remove one-shot workflow and publish refresh + run: | + set -euo pipefail + rm .github/workflows/refresh-email-writing-evidence-parent.yml + git diff --check + git add .github/workflows/refresh-email-writing-evidence-parent.yml + git commit -m "ci(email-writing): remove Task 3 parent refresh workflow" + git push origin HEAD:feat/llm-email-writing-review-evidence-task3 From 6ab000efd8ad262e3f51462ae5cfddd57d849c53 Mon Sep 17 00:00:00 2001 From: CWL Email Writing Task 3 Refresh Date: Sat, 15 Aug 2026 11:52:17 +0000 Subject: [PATCH 14/14] ci(email-writing): remove Task 3 parent refresh workflow --- .../refresh-email-writing-evidence-parent.yml | 68 ------------------- 1 file changed, 68 deletions(-) delete mode 100644 .github/workflows/refresh-email-writing-evidence-parent.yml diff --git a/.github/workflows/refresh-email-writing-evidence-parent.yml b/.github/workflows/refresh-email-writing-evidence-parent.yml deleted file mode 100644 index 2a7235494..000000000 --- a/.github/workflows/refresh-email-writing-evidence-parent.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Refresh Email Writing Evidence Parent - -on: - push: - branches: - - feat/llm-email-writing-review-evidence-task3 - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: refresh-email-writing-evidence-parent - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - PYTHONWARNINGS: error - DISABLE_BACKGROUND_WORKERS: "1" - -jobs: - refresh: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: feat/llm-email-writing-review-evidence-task3 - persist-credentials: true - fetch-depth: 0 - - name: Merge the current Task 2 parent - run: | - set -euo pipefail - git fetch origin feat/llm-email-writing-contracts-task2 - git config user.name "CWL Email Writing Task 3 Refresh" - git config user.email "actions@users.noreply.github.com" - git merge --no-edit --no-ff origin/feat/llm-email-writing-contracts-task2 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: backend/requirements-hashes.txt - - name: Install hash-locked backend dependencies - run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - - name: Verify privacy-minimized evidence contracts - run: | - cd backend - python -m pytest -q \ - tests/test_email_writing_models.py \ - tests/test_email_writing_migration.py - 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 - python -m compileall -q \ - db/email_writing_evidence.py \ - alembic/env.py \ - alembic/versions/20260812_0001_add_email_writing_review_evidence.py - - name: Remove one-shot workflow and publish refresh - run: | - set -euo pipefail - rm .github/workflows/refresh-email-writing-evidence-parent.yml - git diff --check - git add .github/workflows/refresh-email-writing-evidence-parent.yml - git commit -m "ci(email-writing): remove Task 3 parent refresh workflow" - git push origin HEAD:feat/llm-email-writing-review-evidence-task3