{
  "pr": 840,
  "title": "An older subscribe could undo a newer unsubscribe",
  "run": {
    "pr": 840,
    "title": "Spec: marketing-email-consent-foundation",
    "run_id": "marketing-email-consent-foundation-20260518T170925956246",
    "mode": "spec",
    "created": "2026-05-18T17:09:26.010348+00:00",
    "merged": "2026-05-18T18:29:41+00:00",
    "agent": "claude",
    "chain_runs": 1,
    "has_implement_event": true,
    "elapsed_hours": 1.3374971255555557,
    "implementation_to_merge_hours": 1.070352186388889,
    "recorded_phase_hours": 1.3374454924999999,
    "churn": 858,
    "files": 4,
    "direct_signals": 0,
    "broad_signals": 0,
    "review_rejected": true,
    "review_rejections": 1,
    "gates_rejected": true
  },
  "merge_sha": "839ff86c01cc85c794c662145ffa540b75a0faed",
  "selected_review": {
    "attempt_number": 2,
    "findings": [
      {
        "body": "`record_email_consent_event` inserts the audit event and then unconditionally upserts `user_email_preferences` with that event. Because there is no per-user/topic lock before the event insert, two concurrent calls for the same user/topic can interleave as: older opt-in inserts, newer opt-out inserts and updates the preference, then the older opt-in resumes and overwrites the preference. That violates the spec's latest-preference/send-guard and opt-out-override requirements. Lock the user/topic before inserting, or make the preference update conditional on the inserted event being newer than the current `last_event_id`/timestamp, and add a concurrency regression test.",
        "confidence": 0.86,
        "end_line": 1466,
        "file": "storage/repository.py",
        "id": "F1",
        "severity": "P1",
        "start_line": 1424,
        "title": "Concurrent consent writes can leave the preference pointing at an older event"
      }
    ],
    "reviewed_at": "2026-05-18T18:02:00Z",
    "reviewed_base_sha": "cc6812d681521cc24187098b0055af5f74148005",
    "reviewed_head_sha": "8e6b0073392f60ba37365154c5745325e110d3ca",
    "reviewer_agent": "codex",
    "reviewer_role": "independent_pr_reviewer",
    "source_check_name": "review-decision-gate",
    "source_check_url": "private repository PR #840",
    "status": "request_changes",
    "summary": "Found a blocking consent-state race in the new repository helper. I attempted the targeted pytest run, but it could not start because this sandbox has no usable writable temporary directory."
  },
  "final_approval": {
    "attempt_number": 3,
    "findings": [],
    "reviewed_at": "2026-05-18T11:18:00-07:00",
    "reviewed_base_sha": "5968a62eb945b30d7a37778ad23505adda0f05df",
    "reviewed_head_sha": "7430872ec91e4d2e9a52827f96d01258a63c19cc",
    "reviewer_agent": "codex",
    "reviewer_role": "independent_reviewer",
    "source_check_name": "review-decision-gate",
    "source_check_url": "private repository PR #840",
    "status": "approved",
    "summary": "No blocking issues found. The PR adds the consent event/preference migrations, transactional repository helpers, send guard, export/deletion handling, and focused backend tests matching the implementation spec. I could not execute the new pytest file because this read-only sandbox has no usable temp directory, but the diff/spec review completed."
  },
  "merged_excerpts": [
    {
      "file": "storage/repository.py",
      "start_line": 1393,
      "end_line": 1482,
      "language": "python",
      "text": "def record_email_consent_event(\n    conn: psycopg.Connection,\n    *,\n    user_id: int,\n    email: str,\n    topic: str,\n    status: str,\n    consent_text: str,\n    consent_text_version: str,\n    source: str,\n    channel: str = EMAIL_CONSENT_CHANNEL_EMAIL,\n    ip_address: str | None = None,\n    user_agent: str | None = None,\n    metadata: dict[str, object] | None = None,\n) -> EmailConsentEvent:\n    \"\"\"Append a consent event and update the user's current preference atomically.\n\n    Always records the event (preserving the audit trail) and conditionally upserts\n    the matching row in ``user_email_preferences`` so it reflects the newest event.\n    The preference update only applies when the inserted event's id is greater than\n    the existing ``last_event_id`` \u2014 this lets concurrent writes interleave safely\n    (the BIGSERIAL id assigned at insert time defines event ordering), so a slower\n    transaction cannot resurrect an older preference state after a newer one\n    committed.\n    \"\"\"\n    if status not in (EMAIL_CONSENT_STATUS_OPTED_IN, EMAIL_CONSENT_STATUS_OPTED_OUT):\n        raise ValueError(\n            f\"Unknown consent status {status!r}; expected \"\n            f\"{EMAIL_CONSENT_STATUS_OPTED_IN!r} or {EMAIL_CONSENT_STATUS_OPTED_OUT!r}.\"\n        )\n    if channel != EMAIL_CONSENT_CHANNEL_EMAIL:\n        raise ValueError(f\"Unsupported consent channel {channel!r}; expected 'email'.\")\n    normalized_email = _normalize_consent_email(email)\n    metadata_payload = Jsonb(metadata or {})\n\n    with conn.transaction():\n        event_row = conn.execute(\n            \"\"\"\n            INSERT INTO user_email_consent_events (\n                user_id, email, channel, topic, status,\n                consent_text_version, consent_text, source,\n                ip_address, user_agent, metadata\n            )\n            VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)\n            RETURNING id, user_id, email, channel, topic, status,\n                      consent_text_version, consent_text, source,\n                      host(ip_address) AS ip_address, user_agent,\n                      created_at, metadata\n            \"\"\",\n            (\n                user_id,\n                normalized_email,\n                channel,\n                topic,\n                status,\n                consent_text_version,\n                consent_text,\n                source,\n                ip_address,\n                user_agent,\n                metadata_payload,\n            ),\n        ).fetchone()\n        if event_row is None:\n            raise RuntimeError(\"Failed to record email consent event.\")\n\n        opted_in = status == EMAIL_CONSENT_STATUS_OPTED_IN\n        conn.execute(\n            \"\"\"\n            INSERT INTO user_email_preferences (\n                user_id, topic, opted_in, email, updated_at, last_event_id\n            )\n            VALUES (%s, %s, %s, %s, NOW(), %s)\n            ON CONFLICT (user_id, topic) DO UPDATE\n            SET opted_in = EXCLUDED.opted_in,\n                email = EXCLUDED.email,\n                updated_at = NOW(),\n                last_event_id = EXCLUDED.last_event_id\n            WHERE user_email_preferences.last_event_id IS NULL\n               OR user_email_preferences.last_event_id < EXCLUDED.last_event_id\n            \"\"\",\n            (\n                user_id,\n                topic,\n                opted_in,\n                normalized_email,\n                int(event_row[\"id\"]),\n            ),\n        )\n    return _row_to_email_consent_event(event_row)"
    },
    {
      "file": "tests/test_email_consent.py",
      "start_line": 309,
      "end_line": 392,
      "language": "python",
      "text": "def test_concurrent_writes_preserve_latest_event_state(clean_database):\n    \"\"\"An older transaction must not clobber a newer transaction's preference.\n\n    Simulates the race called out in review: two concurrent calls for the same\n    user/topic where the \"older\" transaction inserts its consent event first\n    (claiming the smaller BIGSERIAL id) but commits last. Without the\n    conditional preference update, the older transaction's upsert would\n    overwrite the already-committed newer preference and resurrect a stale\n    opt-in. The id assigned at INSERT time is the source of truth for event\n    ordering.\n    \"\"\"\n    user_conn = repository.get_connection(clean_database)\n    older_conn = repository.get_connection(clean_database)\n    newer_conn = repository.get_connection(clean_database)\n    try:\n        user_id = make_user(user_conn)\n        topic = repository.EMAIL_CONSENT_TOPIC_NEWSLETTER\n        normalized_email = \"user@example.com\"\n\n        with older_conn.transaction():\n            older_row = older_conn.execute(\n                \"\"\"\n                INSERT INTO user_email_consent_events (\n                    user_id, email, channel, topic, status,\n                    consent_text_version, consent_text, source, metadata\n                )\n                VALUES (%s, %s, 'email', %s, 'opted_in', 'v1', 'Subscribe.',\n                        'test', '{}'::jsonb)\n                RETURNING id\n                \"\"\",\n                (user_id, normalized_email, topic),\n            ).fetchone()\n            assert older_row is not None\n            older_event_id = int(older_row[\"id\"])\n\n            newer_event = repository.record_email_consent_event(\n                newer_conn,\n                user_id=user_id,\n                email=normalized_email,\n                topic=topic,\n                status=repository.EMAIL_CONSENT_STATUS_OPTED_OUT,\n                consent_text=\"Unsubscribe.\",\n                consent_text_version=\"v1\",\n                source=\"unsubscribe_link\",\n            )\n            assert newer_event.id > older_event_id\n\n            older_conn.execute(\n                \"\"\"\n                INSERT INTO user_email_preferences (\n                    user_id, topic, opted_in, email, updated_at, last_event_id\n                )\n                VALUES (%s, %s, TRUE, %s, NOW(), %s)\n                ON CONFLICT (user_id, topic) DO UPDATE\n                SET opted_in = EXCLUDED.opted_in,\n                    email = EXCLUDED.email,\n                    updated_at = NOW(),\n                    last_event_id = EXCLUDED.last_event_id\n                WHERE user_email_preferences.last_event_id IS NULL\n                   OR user_email_preferences.last_event_id < EXCLUDED.last_event_id\n                \"\"\",\n                (user_id, topic, normalized_email, older_event_id),\n            )\n\n        pref = repository.get_email_preference(user_conn, user_id=user_id, topic=topic)\n        assert pref is not None\n        assert pref.opted_in is False\n        assert pref.last_event_id == newer_event.id\n        assert not repository.can_send_marketing_email(\n            user_conn, user_id=user_id, topic=topic\n        )\n\n        statuses = [\n            event.status\n            for event in repository.list_email_consent_events(\n                user_conn, user_id=user_id\n            )\n        ]\n        assert repository.EMAIL_CONSENT_STATUS_OPTED_IN in statuses\n        assert repository.EMAIL_CONSENT_STATUS_OPTED_OUT in statuses\n    finally:\n        newer_conn.close()\n        older_conn.close()\n        user_conn.close()"
    }
  ],
  "verification": "Inspected recorded finding, final approval, gate histories and merged implementation/test. Historical tests were not rerun.",
  "gates": {
    "spec_id": "marketing-email-consent-foundation",
    "gates": {
      "e2e": {
        "attempts": 2,
        "history": [
          {
            "attempt": 1,
            "error_count": null,
            "failed_count": null,
            "failed_tests": null,
            "failure_fingerprint": "",
            "passed_count": null,
            "status": "passed",
            "timestamp": "2026-05-18T18:00:40.764622+00:00"
          },
          {
            "attempt": 2,
            "error_count": null,
            "failed_count": null,
            "failed_tests": null,
            "failure_fingerprint": "",
            "passed_count": null,
            "status": "passed",
            "timestamp": "2026-05-18T18:15:43.138302+00:00"
          }
        ],
        "last_command": "make e2e",
        "last_status": "passed"
      },
      "lint": {
        "attempts": 3,
        "history": [
          {
            "attempt": 1,
            "error_count": null,
            "failed_count": null,
            "failed_tests": null,
            "failure_fingerprint": "",
            "passed_count": null,
            "status": "passed",
            "timestamp": "2026-05-18T17:47:24.526140+00:00"
          },
          {
            "attempt": 2,
            "error_count": null,
            "failed_count": null,
            "failed_tests": null,
            "failure_fingerprint": "",
            "passed_count": null,
            "status": "passed",
            "timestamp": "2026-05-18T17:57:09.252825+00:00"
          },
          {
            "attempt": 3,
            "error_count": null,
            "failed_count": null,
            "failed_tests": null,
            "failure_fingerprint": "",
            "passed_count": null,
            "status": "passed",
            "timestamp": "2026-05-18T18:12:42.568053+00:00"
          }
        ],
        "last_command": "make lint",
        "last_status": "passed"
      },
      "test": {
        "attempts": 3,
        "history": [
          {
            "attempt": 1,
            "error_count": 0,
            "failed_count": 2,
            "failed_tests": [
              "tests/test_email_consent.py::test_recording_opt_in_flips_can_send",
              "tests/test_email_consent.py::test_export_includes_consent_events_and_preferences"
            ],
            "failure_fingerprint": "fd275904567ee72b",
            "passed_count": 3474,
            "status": "failed",
            "timestamp": "2026-05-18T17:47:24.520198+00:00"
          },
          {
            "attempt": 2,
            "error_count": 0,
            "failed_count": 0,
            "failed_tests": [],
            "failure_fingerprint": "",
            "passed_count": 3476,
            "status": "passed",
            "timestamp": "2026-05-18T17:57:09.250385+00:00"
          },
          {
            "attempt": 3,
            "error_count": 0,
            "failed_count": 0,
            "failed_tests": [],
            "failure_fingerprint": "",
            "passed_count": 3477,
            "status": "passed",
            "timestamp": "2026-05-18T18:12:42.565482+00:00"
          }
        ],
        "last_command": "make test",
        "last_status": "passed"
      }
    }
  }
}
