fix(email): enforce shared send throttling - #1417
Conversation
|
Warning Review limit reachedNext included review available in 59 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (24)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@opencode-agent @cwl-noema-review please independently review exact current head |
|
Please perform an independent review for exact current head 3d08f5f. Review only the current diff and current-head checks; do not dismiss reviews, bypass branch protection, update the base, or merge. |
| \nPlease independently review exact current head for PR #1417. all current checks are terminal with no failures; review the shared send-throttling diff and concurrency/security behavior at this exact SHA. Publish normal structured review evidence; do not reuse stale reviews, dismiss, bypass, or merge. |
|
@opencode-agent @cwl-noema-review Review-only request for exact current head 3d08f5f. I traced every email send endpoint caller into the shared PostgreSQL advisory-lock bucket and verified the focused rate-limiter tests (64 passed, 1 skipped in prior local evidence), with current hosted checks green. Please independently review this exact SHA only; no bypass or self-approval. |
There was a problem hiding this comment.
Pull request overview
OpenCode cannot approve yet because required coverage evidence did not pass.
Review outcome
1. HIGH .github/workflows/opencode-review.yml:1 - Coverage evidence did not prove required test/docstring evidence
-
Problem: The required coverage-evidence job result was
failure, so OpenCode cannot establish approval sufficiency for this head. -
Root cause: Automated approval is only valid when the same-head coverage-evidence job proves supported repository test suites passed and configured docstring gates passed or were advisory, or reports not applicable because no supported source files or package manifests exist. Missing, failed, skipped, unavailable, or unsupported-tooling test evidence is a blocker.
-
Fix: Install or configure the repository test/docstring evidence tooling when source files or package manifests exist, rerun the current-head coverage-evidence job, and approve only after it reports
successwith required evidence or explicit no-source not-applicable evidence. -
Regression test: Keep the approval branch checking
needs.coverage-evidence.result == successbefore posting APPROVE, and publish REQUEST_CHANGES when coverage-evidence blocker states such as cancelled, skipped, failed, unsupported-tooling, or below-100 evidence are present. -
Result: REQUEST_CHANGES
-
Reason: coverage-evidence result was
failure, so required test/docstring evidence was not proven for current head3d08f5fe829e5111d6bd3e641e5272ad6ad74ccb. -
Head SHA:
3d08f5fe829e5111d6bd3e641e5272ad6ad74ccb -
Workflow run: 32255702926
-
Workflow attempt: 1
Coverage evidence
Coverage evidence job did not run or did not publish coverage evidence.
Changed-File Evidence Map
flowchart LR
PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
Evidence --> S1["Backend (6 files)"]
S1 --> I1["API and service runtime"]
I1 --> R1["Review risk: Backend (6 files)"]
R1 --> V1["backend tests"]
OpenCode Review Overview
Pull request overviewOpenCode cannot approve yet because required coverage evidence did not pass. Review outcome1. HIGH .github/workflows/opencode-review.yml:1 - Coverage evidence did not prove required test/docstring evidence
Coverage evidenceCoverage evidence job did not run or did not publish coverage evidence. Changed-File Evidence Mapflowchart LR
PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
Evidence --> S1["Backend (6 files)"]
S1 --> I1["API and service runtime"]
I1 --> R1["Review risk: Backend (6 files)"]
R1 --> V1["backend tests"]
|
|
PR governance metadata gate is not ready for
|
|
Current-head review request for PR #1417. HEAD: 3d08f5fe829e5111d6bd3e641e5272ad6ad6 The shared email-send limiter was audited at the exact HEAD: every send caller reaches the PostgreSQL advisory-lock bucket, unavailable shared state fails closed, scope keys bind organization and user, and audit records contain only a one-way scope hash. Focused tests: 64 passed, 1 skipped. Full backend: 1777 passed, 32 skipped. Ruff and diff checks pass. Please review this exact HEAD with current GitHub Checks and provide structured adversarial evidence. Do not reuse the older coverage-only review. |
…1381) * fix(db): make fresh-database schema bootstrap work end-to-end The retired 'emails' table (replaced by 'email_records' during the email model reconciliation) was still referenced by fresh-DB setup, breaking both 'alembic upgrade head' and bootstrap_db against a clean database: - schema_backfill_sql() created a dead 'ix_emails_owner_date ON emails' index (used by migration 0001 and bootstrap_db) -> UndefinedTableError. - migration 0011_email_read_state did 'ALTER TABLE emails ADD COLUMN is_read' unconditionally; guard it on the table existing (matching the has_table/ has_column pattern used by later revisions) since email_records already carries is_read from the model metadata. - give email_records.is_read a server_default so create_all/bootstrap_db match the migration intent and raw inserts that omit is_read (postgres smoke seeds) don't hit a NOT NULL violation. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com> * fix(email-import): avoid NUL byte in Postgres advisory-lock key The owner import quota advisory lock built its owner key as f'{user_id}\x00{organization_id}' and passed it to hashtext() as a text bind param. PostgreSQL text cannot encode NUL (0x00), so every email import 500'd on real Postgres with CharacterNotInRepertoireError (mocked/SQLite unit tests skip the advisory-lock path, hiding it). Derive a NUL-free sha256 digest instead and update the tests to assert the NUL-free contract. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com> * chore(env): add Cloud Agent dev environment (backend + frontend + Postgres/pgvector) Repo-managed .cursor/environment.json plus idempotent install/start scripts: - install.sh: system packages (postgresql-16 + pgvector, python venv/build tools), backend venv + pinned requirements, frontend pnpm@11.5.3 deps. - start.sh: bring up the Postgres cluster, generate a per-VM dev .env with random secrets on first boot, ensure the app DB + pgvector extension, and apply alembic migrations. - terminals run the backend (start_backend.py) and frontend (next dev). Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com> * fix(env): keep Cloud Agent Postgres secrets off the psql command line Reject empty DATABASE_URL role secrets and apply ALTER USER through dollar-quoted psql stdin. Install hashed requirements so the baked environment matches the CI supply-chain contract. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com> * fix(db): guard email_records.is_read on the alembic path Keep 0011 as a retired-emails no-op downgrade and add 0018 so existing email_records tables get NOT NULL DEFAULT true without interpolated DDL. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com> * docs: record Cloud Agent env contract and NUL advisory-lock anti-pattern Pin the import quota lock key to an independent SHA-256 digest and point operators at the next boot/import action. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com> * test(cloud-agent): reject unpinned pip self-upgrade * fix(cloud-agent): remove unpinned pip self-upgrade * security(cloud-agent): document fixed-argv subprocess boundary * fix(db): stack read-state migration after send buckets --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com> Co-authored-by: Seongho Bae <me@seonghobae.me> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
|
The only OpenCode blocker is stale coverage evidence for prior head |
|
Exact current HEAD |
|
Current-head review request for PR #1417. Exact head: The live review threads are resolved/outdated for this head. Please regenerate independent review and required metadata evidence against this exact head. Any new finding will be handled before normal protected merge; no bypass or force operation is requested. @OpenCode review current head 69fb72d. @coderabbitai review |
|
|
Stale review: cited a coverage-evidence/required-check failure on an earlier commit; current head has been verified (gh pr checks) to pass coverage-evidence and all other non-metadata-gate required checks, with no current-head review from this reviewer. Dismissing as superseded per AGENTS.md stale-review guidance.
|
Caution Review failedAn error occurred during the review process. Please try again later. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Exact-head maintenance evidence
|
| def _next_bucket_state( | ||
| bucket: EmailSendRateBucket | None, | ||
| now: datetime.datetime, | ||
| ) -> tuple[bool, int, datetime.datetime, datetime.datetime]: | ||
| """Calculate the next state without storing individual attempts.""" | ||
| expires_at = now + datetime.timedelta(seconds=SEND_RATE_LIMIT_WINDOW_SECONDS) | ||
| if bucket is None or now >= bucket.expires_at: | ||
| return True, 1, now, expires_at | ||
| if bucket.attempt_count >= SEND_RATE_LIMIT_MAX_ATTEMPTS: | ||
| return False, bucket.attempt_count, bucket.window_started_at, bucket.expires_at | ||
| return ( | ||
| True, | ||
| bucket.attempt_count + 1, | ||
| bucket.window_started_at, | ||
| bucket.expires_at, | ||
| ) |
There was a problem hiding this comment.
📝 Info: Sliding window replaced by fixed window
The removed limiter enforced a true 60s sliding window via per-attempt timestamps. _next_bucket_state in email_send_rate_limiter.py uses a fixed window anchored at the first attempt, so a scope can send up to ~2x the cap across a boundary (10 near t=59, 10 more at t=61). Standard fixed-window tradeoff, flagged only because it weakens the guarantee the prior code gave.
Was this helpful? React with 👍 or 👎 to provide feedback.
| await session.commit() | ||
| return decision |
There was a problem hiding this comment.
📝 Info: Rate limiter commits the shared request session mid-request
enforce_send_email_rate_limit calls session.commit() on the same db session the endpoint uses (email_send_rate_limiter.py). In the current /api/emails/send path this is safe: get_db does not auto-commit, expire_on_commit=False, and all tenant_config fields (including smtp_password) are read into locals before the limiter runs. However, because this is a shared service function, any future caller that has uncommitted work in the session before invoking it would have that work silently committed as a side effect. Worth keeping in mind if this helper is reused elsewhere.
Was this helpful? React with 👍 or 👎 to provide feedback.
| session.add( | ||
| _audit_event( | ||
| auth_context, | ||
| scope_hash=scope_hash, | ||
| decision=decision, | ||
| ) | ||
| ) | ||
| await session.commit() |
There was a problem hiding this comment.
📝 Info: Rate limiter writes an audit row + bucket update on every blocked attempt
enforce_send_email_rate_limit (email_send_rate_limiter.py) performs an advisory-lock acquisition, a SELECT ... FOR UPDATE, a bucket UPDATE, and a SecurityAuditEvent INSERT followed by commit() for EVERY call, including quota-exhausted (denied) attempts. This means a caller who is already over quota still forces two writes + a commit per request, so the throttle does not shed database load and security_audit_events grows by one row per send attempt with no cleanup path. The ix_email_send_rate_buckets_expires_at index also suggests an intended expiry-sweep that is never implemented (buckets are reused per-scope so they don't grow unbounded, but the index is currently unused). This appears to be an intentional audit-trail design rather than a correctness bug, but the write amplification on a hot path is worth confirming.
Was this helpful? React with 👍 or 👎 to provide feedback.
| try: | ||
| await session.execute( | ||
| select(func.pg_advisory_xact_lock(bindparam("lock_key"))), | ||
| {"lock_key": _lock_key(scope_hash)}, | ||
| ) | ||
| result = await session.execute( | ||
| select(EmailSendRateBucket) | ||
| .where(EmailSendRateBucket.bucket_scope_hash == scope_hash) | ||
| .with_for_update() | ||
| ) | ||
| bucket = result.scalar_one_or_none() | ||
| allowed, attempt_count, window_started_at, expires_at = _next_bucket_state( | ||
| bucket, observed_at | ||
| ) | ||
| if bucket is None: | ||
| session.add( | ||
| EmailSendRateBucket( | ||
| bucket_scope_hash=scope_hash, | ||
| window_started_at=window_started_at, | ||
| attempt_count=attempt_count, | ||
| expires_at=expires_at, | ||
| created_at=observed_at, | ||
| updated_at=observed_at, | ||
| ) | ||
| ) |
There was a problem hiding this comment.
📝 Info: Duplicate-insert race relies on READ COMMITTED
enforce_send_email_rate_limit serializes per scope via pg_advisory_xact_lock, then SELECTs the bucket. A blocked worker sees the winner's committed row only under READ COMMITTED (the default). Under REPEATABLE READ it would miss the row and hit a PK violation on bucket_scope_hash, failing closed with 503. Safe at default isolation.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if not _session_uses_postgresql(session): | ||
| raise EmailSendRateLimitUnavailable |
There was a problem hiding this comment.
📝 Info: Send now fails closed (503) on any non-PostgreSQL session
The previous process-local throttle worked on any database/session; the new limiter raises EmailSendRateLimitUnavailable whenever _session_uses_postgresql is false (email_send_rate_limiter.py), which the endpoint maps to HTTP 503 (emails.py). Any deployment or local/dev environment backed by SQLite (or a session whose bind cannot be introspected) can no longer send email at all. This matches the stated 'fail closed when shared state is unavailable' intent, but it is a behavioral change for non-Postgres environments; the existing send tests only pass because they mock enforce_send_email_rate_limit or return before reaching it.
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
/api/emails/sendthrottle with a PostgreSQL-backed per-scope bucketCloses #1379
Verification
uv run --project backend --group dev pytest -q backend/tests/test_email_send_rate_limiter.py backend/tests/test_emails_api.py backend/tests/test_alembic_migrations.pyuv run --project backend --group dev ruff check backend/services/email_send_rate_limiter.py backend/db/models.py backend/api/emails.py backend/alembic/versions/0018_email_send_rate_buckets.py backend/tests/test_email_send_rate_limiter.py backend/tests/test_emails_api.py