From 508d3588ca319b21ee0bae0b65fba4c6b7405c87 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 06:58:52 +0000 Subject: [PATCH 01/10] =?UTF-8?q?feat(tenancy):=20H2=20=E2=80=94=20routes/?= =?UTF-8?q?notes=20converted=20to=20tenant=5Fsession=20(slice)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 61 of the package's 94 get_db() sites move to the tenant-bound seam: every user-identity request handler now runs `async with _tenant_session() as db:` (commit-on-clean-exit; explicit db.commit() lines deleted), with the alias exported from routes/notes/core.py exactly as routes/projects does it. The 33 sites that stay on get_db() are each marked in place: - H4 background consumers: pipeline.run_transcription, summaries. generate_notes/enqueue_summary, dispatch's auto_dispatch/_dispatch/_mark/ _audit/_dispatch_document, copilot's _persist/_deep_context, meeting_bot's _refresh_bot/_poll_bot/_ingest_recording, live_session.end, live_speakers.apply_live_names, glossary.glossary_prompt, speaker_id.infer_speaker_names — all reachable from spawned tasks or the poller where no ambient tenant exists; each names the row to derive from. - H4/H6 service identity: live.live_wanted (meeting-bot worker calls /live/wanted under MEETING_BOT_TOKEN; system identity binds no tenant). - Dual-use settings/copilot_agenda/copilot_context helpers consumed by the copilot orchestrator task stay unbound with the same markers. Tests: _install_db in test_notes_owner_scoping.py now patches the module's _tenant_session (asynccontextmanager, commit-on-clean-exit) alongside _get_db, so both seam shapes stay hermetically testable per module. H2_BASELINE_ELSEWHERE banked 494 → 433. Verified against a real scratch Postgres 16 per the handover's warning: unbound → TenantUnbound; bound → create/list/patch/read commit under the GUC (set_config('app.tenant_id', …)); patch_meeting's read-after-write holds in the single wrapper transaction. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../gateway/gateway/routes/notes/actions.py | 13 +++----- .../gateway/routes/notes/agenda_progress.py | 4 +-- .../gateway/gateway/routes/notes/copilot.py | 10 ++++-- .../gateway/routes/notes/copilot_agenda.py | 20 ++++++----- .../gateway/routes/notes/copilot_context.py | 11 ++++--- .../gateway/gateway/routes/notes/core.py | 11 +++++++ .../gateway/gateway/routes/notes/dispatch.py | 17 ++++++++-- .../gateway/gateway/routes/notes/events.py | 4 +-- .../gateway/gateway/routes/notes/glossary.py | 13 ++++---- .../gateway/gateway/routes/notes/live.py | 7 ++-- .../gateway/routes/notes/live_session.py | 20 +++++------ .../gateway/routes/notes/live_speakers.py | 3 ++ .../gateway/routes/notes/live_transcript.py | 4 +-- .../gateway/routes/notes/meeting_bot.py | 33 ++++++++++--------- .../gateway/gateway/routes/notes/meetings.py | 24 +++++--------- .../gateway/gateway/routes/notes/pipeline.py | 5 +++ .../gateway/gateway/routes/notes/qa.py | 4 +-- .../gateway/routes/notes/recordings.py | 21 +++++------- .../gateway/gateway/routes/notes/settings.py | 11 +++++-- .../gateway/gateway/routes/notes/share.py | 4 +-- .../gateway/routes/notes/speaker_id.py | 8 +++-- .../gateway/gateway/routes/notes/summaries.py | 18 ++++++---- tests/unit/test_db_engine_seam.py | 5 ++- tests/unit/test_notes_owner_scoping.py | 21 +++++++++++- 24 files changed, 181 insertions(+), 110 deletions(-) diff --git a/apps/services/gateway/gateway/routes/notes/actions.py b/apps/services/gateway/gateway/routes/notes/actions.py index 432a00340..f734802af 100644 --- a/apps/services/gateway/gateway/routes/notes/actions.py +++ b/apps/services/gateway/gateway/routes/notes/actions.py @@ -17,8 +17,8 @@ from fastapi import Depends, HTTPException from gateway.routes.notes.core import ( OWNED_MEETING_PREDICATE, - _get_db, _log, + _tenant_session, load_owned_meeting, router, ) @@ -106,7 +106,7 @@ async def approve_action( colleague's ``description`` copied into its title — an exfiltration with a durable row to show for it, not just a nuisance edit. """ - async with await _get_db() as db: + async with _tenant_session() as db: action = await _load_action(db, action_id, user.email) if action.resulting_task_id: # idempotent return ApproveResponse( @@ -132,7 +132,6 @@ async def approve_action( "p": json.dumps({"task_id": task_id, "meeting_id": str(action.meeting_id)}), }, ) - await db.commit() _log.info("notes.action_approved", action_id=action_id, task_id=task_id) return ApproveResponse(action_id=action_id, status="created", resulting_task_id=task_id) @@ -144,7 +143,7 @@ async def reject_action( ) -> ApproveResponse: """Dismiss a draft action item. Owner only — the item is the owner's triage queue, and a rejection is not reversible through this API.""" - async with await _get_db() as db: + async with _tenant_session() as db: action = await _load_action(db, action_id, user.email) if action.resulting_task_id: raise HTTPException( @@ -154,7 +153,6 @@ async def reject_action( text("UPDATE action_item SET status='rejected' WHERE id=:id"), {"id": action_id}, ) - await db.commit() return ApproveResponse(action_id=action_id, status="rejected") @@ -186,7 +184,7 @@ async def approve_all( # Deferred import: dispatch imports helpers from this module. from gateway.routes.notes import dispatch as notes_dispatch - async with await _get_db() as db: + async with _tenant_session() as db: meeting = await load_owned_meeting( db, meeting_id, user.email, columns="m.id, m.title, m.owner_email, m.attendees, " @@ -211,7 +209,7 @@ async def approve_all( if error is None and ref is not None: created.append(str(action.id)) if created: - async with await _get_db() as db: + async with _tenant_session() as db: await db.execute( text( "INSERT INTO audit_event (actor, action, target, payload) VALUES " @@ -223,6 +221,5 @@ async def approve_all( "p": json.dumps({"count": len(created), "min_confidence": body.min_confidence}), }, ) - await db.commit() _log.info("notes.actions_bulk_approved", meeting_id=meeting_id, count=len(created)) return BulkApproveResponse(created=created) diff --git a/apps/services/gateway/gateway/routes/notes/agenda_progress.py b/apps/services/gateway/gateway/routes/notes/agenda_progress.py index 49cf64856..c8e281e7a 100644 --- a/apps/services/gateway/gateway/routes/notes/agenda_progress.py +++ b/apps/services/gateway/gateway/routes/notes/agenda_progress.py @@ -30,7 +30,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends from gateway.routes.notes.copilot_agenda import get_agenda, item_covered -from gateway.routes.notes.core import _get_db, _log, router +from gateway.routes.notes.core import _log, _tenant_session, router from sqlalchemy import text #: Words of recent speech kept per meeting. ~2000 words is roughly fifteen @@ -109,7 +109,7 @@ async def _stored_transcript(meeting_id: str) -> str: """The finished meeting's transcript — the fallback source of coverage once the live state is gone.""" try: - async with await _get_db() as db: + async with _tenant_session() as db: row = ( await db.execute( text( diff --git a/apps/services/gateway/gateway/routes/notes/copilot.py b/apps/services/gateway/gateway/routes/notes/copilot.py index 1202052f0..5317d9e3e 100644 --- a/apps/services/gateway/gateway/routes/notes/copilot.py +++ b/apps/services/gateway/gateway/routes/notes/copilot.py @@ -46,6 +46,7 @@ from gateway.routes.notes.core import ( _get_db, _log, + _tenant_session, load_owned_meeting, router, ) @@ -103,6 +104,9 @@ async def _persist(meeting_id: str, ev: dict) -> None: """Record what the agent said. Best-effort: an audit-trail write must never take down the copilot, let alone the meeting.""" try: + # H4: background consumer — called from the copilot orchestrator task + # (`_run`, spawned by `start()` via asyncio.create_task), which outlives + # the request scope; derive the tenant from the meeting row. async with await _get_db() as db: row = ( await db.execute( @@ -275,6 +279,8 @@ async def _deep_context(meeting_id: str) -> bool: start. ON for new sessions (migration 129) — the whole point of a briefed copilot — and still a per-session toggle for anyone who wants it quiet.""" try: + # H4: background consumer — called only from the `_run` orchestrator + # task; derive the tenant from the meeting/live_session row. async with await _get_db() as db: row = ( await db.execute( @@ -499,7 +505,7 @@ async def copilot_stream( ``refs.window`` carries up to 400 characters of what was just said — the live transcript underneath them. A 404 raised inside an already-started ``StreamingResponse`` would arrive as a broken stream, not a refusal.""" - async with await _get_db() as db: + async with _tenant_session() as db: await load_owned_meeting(db, meeting_id, user.email, columns="m.id") return StreamingResponse( _sse(meeting_id), @@ -521,7 +527,7 @@ async def copilot_events( Owner only — the same content as the stream, durable and without needing to be there while it happened.""" - async with await _get_db() as db: + async with _tenant_session() as db: await load_owned_meeting(db, meeting_id, user.email, columns="m.id") rows = ( await db.execute( diff --git a/apps/services/gateway/gateway/routes/notes/copilot_agenda.py b/apps/services/gateway/gateway/routes/notes/copilot_agenda.py index dedb84392..53d00e444 100644 --- a/apps/services/gateway/gateway/routes/notes/copilot_agenda.py +++ b/apps/services/gateway/gateway/routes/notes/copilot_agenda.py @@ -31,7 +31,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.notes.core import _get_db, _log, router +from gateway.routes.notes.core import _get_db, _log, _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -183,6 +183,9 @@ async def draft_agenda( # ── Storage ───────────────────────────────────────────────────────────────── async def get_agenda(meeting_id: str) -> list[dict]: + # H4: shared helper also consumed by the copilot orchestrator background + # task (`copilot._run`), which has no ambient tenant; derive it from the + # meeting row before moving this off `_get_db`. try: async with await _get_db() as db: row = ( @@ -201,7 +204,7 @@ async def get_instructions(owner_email: str) -> str: if not owner_email: return "" try: - async with await _get_db() as db: + async with _tenant_session() as db: row = ( await db.execute( text("SELECT instructions FROM copilot_config WHERE owner_email = :e"), @@ -215,6 +218,8 @@ async def get_instructions(owner_email: str) -> str: async def instructions_for_meeting(meeting_id: str) -> str: """Standing instructions of whoever owns this meeting.""" + # H4: copilot-oriented helper (no request caller today); a background + # caller has no ambient tenant — derive it from the meeting row. try: async with await _get_db() as db: row = ( @@ -258,7 +263,7 @@ async def write_agenda( ) -> dict: """Set the agenda directly (hand-edited, or accepted from the chat).""" items = normalize_agenda(body.agenda) - async with await _get_db() as db: + async with _tenant_session() as db: res = await db.execute( text( "UPDATE meeting SET agenda = CAST(:a AS JSONB) " @@ -268,7 +273,6 @@ async def write_agenda( ) if res.rowcount == 0: raise HTTPException(status_code=404, detail="meeting not found") - await db.commit() # The copilot's cached background embeds the agenda — rebuild it. from gateway.routes.notes import copilot_context @@ -289,7 +293,7 @@ async def chat_agenda( if not message: raise HTTPException(status_code=400, detail="empty message") - async with await _get_db() as db: + async with _tenant_session() as db: row = ( await db.execute( text( @@ -308,7 +312,7 @@ async def chat_agenda( instructions=await get_instructions(getattr(user, "email", "") or ""), brief=(row.copilot_brief or ""), ) - async with await _get_db() as db: + async with _tenant_session() as db: await db.execute( text( "UPDATE meeting SET agenda = CAST(:a AS JSONB) " @@ -316,7 +320,6 @@ async def chat_agenda( ), {"a": json.dumps(agenda), "id": meeting_id}, ) - await db.commit() from gateway.routes.notes import copilot_context copilot_context.forget(meeting_id) @@ -341,7 +344,7 @@ async def write_instructions( if not email: raise HTTPException(status_code=400, detail="no user") value = (body.instructions or "").strip()[:_MAX_INSTRUCTIONS] - async with await _get_db() as db: + async with _tenant_session() as db: await db.execute( text( "INSERT INTO copilot_config (owner_email, instructions) " @@ -350,5 +353,4 @@ async def write_instructions( ), {"e": email, "i": value or None}, ) - await db.commit() return {"instructions": value} diff --git a/apps/services/gateway/gateway/routes/notes/copilot_context.py b/apps/services/gateway/gateway/routes/notes/copilot_context.py index a0e877b22..3751835aa 100644 --- a/apps/services/gateway/gateway/routes/notes/copilot_context.py +++ b/apps/services/gateway/gateway/routes/notes/copilot_context.py @@ -36,7 +36,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.notes.core import _get_db, _log, router +from gateway.routes.notes.core import _get_db, _log, _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -149,6 +149,9 @@ def summarise_past(title: str | None, when: Any, summary: str | None) -> str: # ── Layers 1 + 2: brief and our own records (no external dependency) ──────── async def _local_context(meeting_id: str) -> ContextPack: + # H4: shared helper also consumed by the copilot orchestrator background + # task (`copilot._run` via `get()`), which has no ambient tenant; derive + # it from the meeting row before moving this off `_get_db`. pack = ContextPack() async with await _get_db() as db: m = ( @@ -342,14 +345,13 @@ async def set_brief( FOR). Editable before or during; invalidates the cached pack so a mid-meeting edit takes effect on the next suggestion.""" brief = _clip(body.brief or "", _MAX_BRIEF_CHARS) - async with await _get_db() as db: + async with _tenant_session() as db: res = await db.execute( text("UPDATE meeting SET copilot_brief = :b WHERE id = CAST(:id AS UUID)"), {"b": brief or None, "id": meeting_id}, ) if res.rowcount == 0: raise HTTPException(status_code=404, detail="meeting not found") - await db.commit() forget(meeting_id) return {"brief": brief} @@ -385,7 +387,7 @@ async def set_deep_context( """Opt this session into asking the business agents (CRM, tasks) for background. Off by default because the fan-out spends tokens before the meeting starts — worth it for a customer call, not for a standup.""" - async with await _get_db() as db: + async with _tenant_session() as db: res = await db.execute( text( "UPDATE live_session SET deep_context = :e, updated_at = now() " @@ -395,6 +397,5 @@ async def set_deep_context( ) if res.rowcount == 0: raise HTTPException(status_code=404, detail="no live session") - await db.commit() forget(meeting_id) # rebuild with (or without) the agent lookups return {"deep_context": body.enabled} diff --git a/apps/services/gateway/gateway/routes/notes/core.py b/apps/services/gateway/gateway/routes/notes/core.py index 470c9ec23..f7d4bd604 100644 --- a/apps/services/gateway/gateway/routes/notes/core.py +++ b/apps/services/gateway/gateway/routes/notes/core.py @@ -22,6 +22,17 @@ # The shared gateway engine (BO-10) — see the DB section below. from gateway.db import get_db as _get_db # noqa: F401 from gateway.db import get_session_factory as _get_session_factory # noqa: F401 + +# The shared seam (BO-10 → MT-1c/H2). `_tenant_session` IS +# `acb_common.db.tenant_session`, aliased per-package for the same reason +# `_get_db` was: every submodule imports it from here BY NAME, which is the +# seam the hermetic tests patch per module. The tenant comes from the request +# context — bound once in `_with_resolved_access` — so no call site passes +# one (H2). A call outside a bound request raises `TenantUnbound` rather than +# defaulting: fail closed, never "the usual org". `_get_db` above remains for +# the sites H2 cannot reach from a request: background jobs/pollers and the +# meeting-bot worker's service-identity paths (H4 threads their tenant). +from gateway.db import tenant_session as _tenant_session # noqa: F401 from pydantic import BaseModel from sqlalchemy import text from acb_auth import require_feature_router diff --git a/apps/services/gateway/gateway/routes/notes/dispatch.py b/apps/services/gateway/gateway/routes/notes/dispatch.py index 967130da5..d7dd08606 100644 --- a/apps/services/gateway/gateway/routes/notes/dispatch.py +++ b/apps/services/gateway/gateway/routes/notes/dispatch.py @@ -44,7 +44,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException from gateway.routes.notes.actions import ApproveResponse, _create_task_from_action -from gateway.routes.notes.core import _get_db, _log, router +from gateway.routes.notes.core import _get_db, _log, _tenant_session, router from sqlalchemy import text #: Auto-dispatch only fires at or above this confidence — the same bar the @@ -364,6 +364,9 @@ async def _dispatch_document(action, meeting, owner_email: str) -> str: workspace (blob store + disk). Returns ``artifact:/``.""" from gateway.routes.notes.summaries import _llm_json + # H4: reached from the background summary pipeline (`generate_notes` → + # `auto_dispatch`) and the meeting-bot poller — no ambient tenant; derive + # it from the meeting/action_item row. async with await _get_db() as db: excerpts = await _segment_texts(db, action.segment_ids) @@ -421,6 +424,9 @@ async def _dispatch_document(action, meeting, owner_email: str) -> str: async def _mark(action_id: str, *, ref: str | None, task_id: str | None, error: str | None) -> None: + # H4: reached from the background summary pipeline (`generate_notes` → + # `auto_dispatch`) and the meeting-bot poller — no ambient tenant; derive + # it from the meeting/action_item row. async with await _get_db() as db: if error is not None: await db.execute( @@ -445,6 +451,8 @@ async def _mark(action_id: str, *, ref: str | None, task_id: str | None, async def _audit(actor: str, action_id: str, meeting_id: str, kind: str, ref: str | None, error: str | None) -> None: with contextlib.suppress(Exception): + # H4: reached from the background summary pipeline and the meeting-bot + # poller — no ambient tenant; derive it from the meeting/action row. async with await _get_db() as db: await db.execute( text( @@ -488,6 +496,8 @@ async def _dispatch(action, meeting, actor: str) -> tuple[str | None, str | None try: if kind == "task": + # H4: reached from the background summary pipeline and the + # meeting-bot poller — no ambient tenant; derive from the row. async with await _get_db() as db: task_id = await _create_task_from_action(db, owner, action) await db.commit() @@ -538,6 +548,9 @@ async def auto_dispatch(meeting_id: str, triggered_by: str) -> dict: """ from gateway.routes.notes import settings as notes_settings + # H4: background entry point — called from `generate_notes` (a spawned + # task) and the meeting-bot ingest; no ambient tenant. Derive it from the + # meeting row (`triggered_by`/`owner_email`) when H4 threads it through. async with await _get_db() as db: meeting = await _load_meeting(db, meeting_id) if meeting is None: @@ -600,7 +613,7 @@ async def dispatch_action( idempotent early return, which would otherwise hand a stranger the ref of a colleague's already-sent email. """ - async with await _get_db() as db: + async with _tenant_session() as db: action = ( await db.execute( text(_ACTION_COLS + "WHERE id = :id"), {"id": action_id} diff --git a/apps/services/gateway/gateway/routes/notes/events.py b/apps/services/gateway/gateway/routes/notes/events.py index 8b1e5b9e8..0774cb99d 100644 --- a/apps/services/gateway/gateway/routes/notes/events.py +++ b/apps/services/gateway/gateway/routes/notes/events.py @@ -16,7 +16,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends from fastapi.responses import StreamingResponse -from gateway.routes.notes.core import _get_db, _log, router +from gateway.routes.notes.core import _log, _tenant_session, router from sqlalchemy import text _POLL_S = 1.5 @@ -24,7 +24,7 @@ async def _snapshot(meeting_id: str) -> dict | None: - async with await _get_db() as db: + async with _tenant_session() as db: m = ( await db.execute( text( diff --git a/apps/services/gateway/gateway/routes/notes/glossary.py b/apps/services/gateway/gateway/routes/notes/glossary.py index 12f368c71..856866e3e 100644 --- a/apps/services/gateway/gateway/routes/notes/glossary.py +++ b/apps/services/gateway/gateway/routes/notes/glossary.py @@ -10,7 +10,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.notes.core import _get_db, _log, router +from gateway.routes.notes.core import _get_db, _log, _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -33,7 +33,7 @@ class AddTermRequest(BaseModel): async def list_glossary( user: UserContext = Depends(get_current_user), ) -> list[GlossaryTerm]: - async with await _get_db() as db: + async with _tenant_session() as db: rows = ( await db.execute( text( @@ -56,7 +56,7 @@ async def add_term( raise HTTPException(status_code=400, detail="empty term") if len(term) > 120: raise HTTPException(status_code=400, detail="term too long") - async with await _get_db() as db: + async with _tenant_session() as db: # Idempotent per (user, case-folded term): return the existing row. row = ( await db.execute( @@ -68,7 +68,6 @@ async def add_term( {"u": user.email or "anonymous", "t": term}, ) ).fetchone() - await db.commit() _log.info("notes.glossary_add", user=user.email, term=term) return GlossaryTerm(id=str(row.id), term=row.term) @@ -78,12 +77,11 @@ async def delete_term( term_id: str, user: UserContext = Depends(get_current_user), ) -> None: - async with await _get_db() as db: + async with _tenant_session() as db: await db.execute( text("DELETE FROM notes_glossary WHERE id=:id AND user_id=:u"), {"id": term_id, "u": user.email or "anonymous"}, ) - await db.commit() def format_glossary_prompt(terms: list[str]) -> str: @@ -106,6 +104,9 @@ async def glossary_prompt(user_id: str) -> str: if not user_id: return "" try: + # H4: called from the background transcription pipeline + # (`pipeline.run_transcription`, a spawned task) — no ambient tenant; + # derive it from the meeting row's owner. async with await _get_db() as db: rows = ( await db.execute( diff --git a/apps/services/gateway/gateway/routes/notes/live.py b/apps/services/gateway/gateway/routes/notes/live.py index ba0fbd648..c3cc2c3c6 100644 --- a/apps/services/gateway/gateway/routes/notes/live.py +++ b/apps/services/gateway/gateway/routes/notes/live.py @@ -32,7 +32,7 @@ import httpx from acb_auth import UserContext, get_current_user from fastapi import Depends, Header, HTTPException -from gateway.routes.notes.core import _get_db, _log, load_owned_meeting, router +from gateway.routes.notes.core import _log, _tenant_session, load_owned_meeting, router from pydantic import BaseModel _DG_API = "https://api.deepgram.com/v1" @@ -223,6 +223,9 @@ async def live_wanted(meeting_id: str) -> tuple[bool, str]: from sqlalchemy import text as _text settings, _ = await load_for_meeting(meeting_id) + # H4/H6: service-identity route — `read_live_wanted` is called by the + # meeting-bot worker (MEETING_BOT_TOKEN, `system`-shaped identity, no + # ambient tenant); derive the tenant from the live_session/meeting row. async with await _get_db() as db: row = ( await db.execute( @@ -300,7 +303,7 @@ async def live_token( presence oracle over a colleague's calendar. The parameter stays optional — a token minted with no meeting names nothing to scope.""" if meeting_id: - async with await _get_db() as db: + async with _tenant_session() as db: await load_owned_meeting( db, meeting_id, user.email, columns="m.id" ) diff --git a/apps/services/gateway/gateway/routes/notes/live_session.py b/apps/services/gateway/gateway/routes/notes/live_session.py index 7873c6b8d..54ce41566 100644 --- a/apps/services/gateway/gateway/routes/notes/live_session.py +++ b/apps/services/gateway/gateway/routes/notes/live_session.py @@ -28,7 +28,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.notes.core import _get_db, _iso, _log, router +from gateway.routes.notes.core import _get_db, _iso, _log, _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -72,7 +72,7 @@ async def begin(meeting_id: str, source: str, owner_email: str | None) -> None: refreshes the existing row rather than forking presence (enforced by the partial unique index). Never raises.""" try: - async with await _get_db() as db: + async with _tenant_session() as db: await db.execute( text( """ @@ -87,7 +87,6 @@ async def begin(meeting_id: str, source: str, owner_email: str | None) -> None: ), {"m": meeting_id, "s": source, "o": owner_email}, ) - await db.commit() _log.info("notes.live_session_begin", meeting_id=meeting_id, source=source) except Exception as exc: # Presence is additive — never let it break a recording or a bot join. @@ -113,7 +112,7 @@ async def _apply_prepared_copilot(meeting_id: str) -> None: from gateway.routes.notes import copilot from gateway.routes.notes.settings import copilot_should_run, load_for_meeting - async with await _get_db() as db: + async with _tenant_session() as db: row = ( await db.execute( text( @@ -127,7 +126,7 @@ async def _apply_prepared_copilot(meeting_id: str) -> None: settings, _ = await load_for_meeting(meeting_id) if not copilot_should_run(per_meeting, settings.copilot_default_on): return - async with await _get_db() as db: + async with _tenant_session() as db: await db.execute( text( "UPDATE live_session SET copilot_enabled = TRUE, " @@ -136,7 +135,6 @@ async def _apply_prepared_copilot(meeting_id: str) -> None: ), {"m": meeting_id}, ) - await db.commit() copilot.start(meeting_id) _log.info("notes.copilot_autostarted", meeting_id=meeting_id) except Exception as exc: @@ -148,6 +146,9 @@ async def _apply_prepared_copilot(meeting_id: str) -> None: async def end(meeting_id: str) -> None: """Mark a meeting no longer live (no-op if it wasn't). Never raises.""" + # H4: also called from background paths (the transcription pipeline task + # and the meeting-bot poller) — no ambient tenant there; derive it from + # the live_session/meeting row. try: async with await _get_db() as db: await db.execute( @@ -173,7 +174,7 @@ async def list_live_sessions( _user: UserContext = Depends(get_current_user), ) -> list[LiveSessionModel]: """Meetings being captured right now — drives the global "live now" dock.""" - async with await _get_db() as db: + async with _tenant_session() as db: rows = ( await db.execute( text( @@ -194,7 +195,7 @@ async def get_live_session( """This meeting's live session, if any — how the console reattaches after a refresh. Returns null (200) rather than 404 when nothing is live, so the UI can poll it without treating "not live" as an error.""" - async with await _get_db() as db: + async with _tenant_session() as db: row = ( await db.execute( text( @@ -230,7 +231,7 @@ async def set_copilot( mode = (body.mode or "").strip() or None if mode is not None and mode not in _MODES: raise HTTPException(status_code=400, detail=f"mode must be one of {_MODES}") - async with await _get_db() as db: + async with _tenant_session() as db: row = ( await db.execute( text( @@ -244,7 +245,6 @@ async def set_copilot( ).fetchone() if row is None: raise HTTPException(status_code=404, detail="no live session for this meeting") - await db.commit() # Start/stop the orchestrator to match. Turning it OFF cancels the task, # which unsubscribes it from the transcript bus — spend stops immediately, # which is the whole point of the toggle being cheap to hit. diff --git a/apps/services/gateway/gateway/routes/notes/live_speakers.py b/apps/services/gateway/gateway/routes/notes/live_speakers.py index be86a8d2f..26e1b2348 100644 --- a/apps/services/gateway/gateway/routes/notes/live_speakers.py +++ b/apps/services/gateway/gateway/routes/notes/live_speakers.py @@ -384,6 +384,9 @@ async def apply_live_names(meeting_id: str, segments: list) -> dict[str, str]: from gateway.routes.notes.core import _get_db from gateway.routes.notes.speaker_id import merge_inferred + # H4: called from the background transcription pipeline + # (`pipeline.run_transcription`) — no ambient tenant; derive it from + # the meeting row. async with await _get_db() as db: row = ( await db.execute( diff --git a/apps/services/gateway/gateway/routes/notes/live_transcript.py b/apps/services/gateway/gateway/routes/notes/live_transcript.py index e53de6c64..ad96429e5 100644 --- a/apps/services/gateway/gateway/routes/notes/live_transcript.py +++ b/apps/services/gateway/gateway/routes/notes/live_transcript.py @@ -39,7 +39,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, Header, HTTPException from fastapi.responses import StreamingResponse -from gateway.routes.notes.core import _get_db, _log, router +from gateway.routes.notes.core import _log, _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -267,7 +267,7 @@ async def say_into_meeting( text_ = (body.text or "").strip() if not text_: raise HTTPException(status_code=400, detail="empty text") - async with await _get_db() as db: + async with _tenant_session() as db: row = ( await db.execute( text( diff --git a/apps/services/gateway/gateway/routes/notes/meeting_bot.py b/apps/services/gateway/gateway/routes/notes/meeting_bot.py index 03c5a1a85..51f0f75c5 100644 --- a/apps/services/gateway/gateway/routes/notes/meeting_bot.py +++ b/apps/services/gateway/gateway/routes/notes/meeting_bot.py @@ -45,6 +45,7 @@ OWNED_MEETING_PREDICATE, _get_db, _log, + _tenant_session, media_dir, router, ) @@ -486,6 +487,8 @@ async def _ingest_recording( dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(audio) + # H4: reached from `_refresh_bot` (background poller / poll-on-read) — no + # ambient tenant; derive it from the meeting_bot row (`requested_by`). async with await _get_db() as db: await db.execute( text( @@ -527,6 +530,9 @@ async def _refresh_bot(bot_row_id: str) -> None: if provider is None: return try: + # H4: dual-entry — the background poller (`_poll_bot`) reaches this + # with no ambient tenant, so every `_get_db` block in this function + # stays unbound until H4 derives the tenant from the meeting_bot row. async with await _get_db() as db: row = ( await db.execute( @@ -638,6 +644,7 @@ async def _poll_bot(bot_row_id: str) -> None: # ~4h ceiling at 15s cadence — long enough for any real meeting. for _ in range(960): await _refresh_bot(bot_row_id) + # H4: background poller loop — no ambient tenant; see `_refresh_bot`. async with await _get_db() as db: row = ( await db.execute( @@ -733,7 +740,7 @@ async def bot_join( bot_name = (body.bot_name or "").strip() or default_bot_name() title = (body.title or "").strip() or None - async with await _get_db() as db: + async with _tenant_session() as db: if body.meeting_id: # Attaching to a prepared meeting: keep its agenda/brief/attendees # and just mark it recording. @@ -794,7 +801,6 @@ async def bot_join( "name": bot_name, "by": user.email}, ) ).fetchone() - await db.commit() bot_row_id = str(bot_row.id) # Where the worker posts live transcript segments for this meeting (enables @@ -829,20 +835,18 @@ async def bot_join( else: message = "Couldn't dispatch the notetaker to that meeting. Check the link." error_text = f"join failed: {str(exc)[:400]}" - async with await _get_db() as db: + async with _tenant_session() as db: await _set_bot(db, bot_row_id, status="failed", error=error_text) await db.execute(text("UPDATE meeting SET status='failed' WHERE id=:id"), {"id": meeting_id}) - await db.commit() _log.warning("notes.bot_join_failed", meeting_id=meeting_id, busy=busy, error=str(exc)[:200]) raise HTTPException( status_code=409 if busy else 502, detail=message ) from None - async with await _get_db() as db: + async with _tenant_session() as db: await _set_bot(db, bot_row_id, provider_bot_id=provider_bot_id, status="joining") - await db.commit() # Register presence — a bot meeting shows as "live now" in Command Center # from the moment it's dispatched, not just once audio starts flowing. from gateway.routes.notes import live_session @@ -882,7 +886,7 @@ async def list_active_bots( """Active notetaker bots (for the live surface). Poll-on-read: refresh each from the provider so status advances (and completed calls ingest) even without the in-process poller.""" - async with await _get_db() as db: + async with _tenant_session() as db: rows = ( await db.execute( text(_ACTIVE_SQL).bindparams(bindparam("active", expanding=True)), @@ -892,7 +896,7 @@ async def list_active_bots( ids = [str(r.id) for r in rows] if ids: await asyncio.gather(*(_refresh_bot(i) for i in ids), return_exceptions=True) - async with await _get_db() as db: + async with _tenant_session() as db: rows = ( await db.execute( text(_ACTIVE_SQL).bindparams(bindparam("active", expanding=True)), @@ -907,7 +911,7 @@ async def get_meeting_bot( meeting_id: str, _user: UserContext = Depends(get_current_user), ) -> MeetingBotModel | None: - async with await _get_db() as db: + async with _tenant_session() as db: r = ( await db.execute( text("SELECT b.*, m.title AS meeting_title FROM meeting_bot b " @@ -920,7 +924,7 @@ async def get_meeting_bot( return None if r.status in ACTIVE_STATUSES: await _refresh_bot(str(r.id)) - async with await _get_db() as db: + async with _tenant_session() as db: r = ( await db.execute( text("SELECT b.*, m.title AS meeting_title FROM meeting_bot b " @@ -942,7 +946,7 @@ async def meeting_bot_diagnostics( status code can't express — a sign-in wall, a device dialog covering the green room and a host who never clicked Admit all end as "didn't join". This returns the page the bot actually saw.""" - async with await _get_db() as db: + async with _tenant_session() as db: r = ( await db.execute( text("SELECT id, provider, provider_bot_id, status, error " @@ -971,7 +975,7 @@ async def meeting_bot_screenshot( ) -> Response: """The green room exactly as the bot saw it (PNG). 404 when no screenshot was captured (only failure paths snapshot the page).""" - async with await _get_db() as db: + async with _tenant_session() as db: r = ( await db.execute( text("SELECT provider_bot_id FROM meeting_bot " @@ -997,7 +1001,7 @@ async def stop_meeting_bot( ) -> dict: """Remove the notetaker from the call. Any audio captured so far is still processed by the provider and ingested when ready.""" - async with await _get_db() as db: + async with _tenant_session() as db: r = ( await db.execute( text("SELECT id, provider_bot_id, status FROM meeting_bot " @@ -1013,9 +1017,8 @@ async def stop_meeting_bot( await provider.leave(r.provider_bot_id) except Exception as exc: _log.warning("notes.bot_stop_failed", meeting_id=meeting_id, error=str(exc)[:200]) - async with await _get_db() as db: + async with _tenant_session() as db: await _set_bot(db, str(r.id), status="processing") - await db.commit() # Kick one refresh so ingest happens promptly once the recording finalizes. _spawn(_poll_bot(str(r.id))) return {"ok": True, "status": "processing"} diff --git a/apps/services/gateway/gateway/routes/notes/meetings.py b/apps/services/gateway/gateway/routes/notes/meetings.py index 12e687940..fdc7f62fa 100644 --- a/apps/services/gateway/gateway/routes/notes/meetings.py +++ b/apps/services/gateway/gateway/routes/notes/meetings.py @@ -37,8 +37,8 @@ MeetingDetail, MeetingListItem, PatchMeetingRequest, - _get_db, _log, + _tenant_session, load_owned_meeting, media_dir, router, @@ -92,7 +92,7 @@ async def list_meetings( """The caller's own meeting library. Never anybody else's — the search box searches transcript text, so an unscoped list was a full-text search over every recorded conversation in the company.""" - async with await _get_db() as db: + async with _tenant_session() as db: rows = ( await db.execute( text(_LIST_SQL), @@ -111,7 +111,7 @@ async def create_meeting( body: CreateMeetingRequest, user: UserContext = Depends(get_current_user), ) -> MeetingListItem: - async with await _get_db() as db: + async with _tenant_session() as db: row = ( await db.execute( text( @@ -134,7 +134,6 @@ async def create_meeting( }, ) ).fetchone() - await db.commit() _log.info("notes.meeting_created", meeting_id=str(row.id), user=user.email) return row_to_list_item(row) @@ -164,7 +163,7 @@ async def get_meeting( meeting_id: str, user: UserContext = Depends(get_current_user), ) -> MeetingDetail: - async with await _get_db() as db: + async with _tenant_session() as db: m = await _load_meeting(db, meeting_id, user) recs = ( await db.execute( @@ -225,13 +224,12 @@ async def put_scratch( user: UserContext = Depends(get_current_user), ) -> dict: """Save the user's rough notes — merged into generation as emphasis signals.""" - async with await _get_db() as db: + async with _tenant_session() as db: await _load_meeting(db, meeting_id, user) await db.execute( text("UPDATE meeting SET scratch_notes = :s WHERE id = :id"), {"s": body.scratch_notes or None, "id": meeting_id}, ) - await db.commit() return {"ok": True} @@ -251,13 +249,12 @@ async def put_attendees( clean = [ a for a in body.attendees if (a.name.strip() or a.email.strip()) ] - async with await _get_db() as db: + async with _tenant_session() as db: await _load_meeting(db, meeting_id, user) await db.execute( text("UPDATE meeting SET attendees = CAST(:a AS JSONB) WHERE id = :id"), {"a": _json.dumps([a.model_dump() for a in clean]), "id": meeting_id}, ) - await db.commit() return clean @@ -282,7 +279,7 @@ async def put_speakers( for k, v in body.names.items() if str(k).strip() and isinstance(v, str) and v.strip() } - async with await _get_db() as db: + async with _tenant_session() as db: await _load_meeting(db, meeting_id, user) await db.execute( text( @@ -290,7 +287,6 @@ async def put_speakers( ), {"n": _json.dumps(clean), "id": meeting_id}, ) - await db.commit() _log.info("notes.speakers_named", meeting_id=meeting_id, count=len(clean)) return clean @@ -301,7 +297,7 @@ async def patch_meeting( body: PatchMeetingRequest, user: UserContext = Depends(get_current_user), ) -> MeetingListItem: - async with await _get_db() as db: + async with _tenant_session() as db: await _load_meeting(db, meeting_id, user) await db.execute( text( @@ -323,7 +319,6 @@ async def patch_meeting( "copilot": body.copilot_enabled, }, ) - await db.commit() row = await _load_meeting(db, meeting_id, user) return row_to_list_item(row) @@ -338,7 +333,7 @@ async def delete_meeting( Irreversible and the reason the owner check above is not optional: a colleague's recording, transcript, notes and action items all go with it. """ - async with await _get_db() as db: + async with _tenant_session() as db: await _load_meeting(db, meeting_id, user) paths = ( await db.execute( @@ -354,7 +349,6 @@ async def delete_meeting( ), {"actor": user.email or "unknown", "target": f"meeting:{meeting_id}"}, ) - await db.commit() root = media_dir().resolve() for p in paths: try: diff --git a/apps/services/gateway/gateway/routes/notes/pipeline.py b/apps/services/gateway/gateway/routes/notes/pipeline.py index 447723714..294017090 100644 --- a/apps/services/gateway/gateway/routes/notes/pipeline.py +++ b/apps/services/gateway/gateway/routes/notes/pipeline.py @@ -38,6 +38,11 @@ async def run_transcription( forgetting it must be a ``TypeError``, not an anonymous send. """ try: + # H4: background job — `run_transcription` runs as a spawned asyncio + # task (upload/complete/retranscribe handlers and the meeting-bot + # ingest all `_spawn` it) with no ambient tenant. Every `_get_db` + # block in this function stays unbound until H4 derives the tenant + # from the meeting row / threads it through the task arguments. async with await _get_db() as db: rec = ( await db.execute( diff --git a/apps/services/gateway/gateway/routes/notes/qa.py b/apps/services/gateway/gateway/routes/notes/qa.py index 2c3c649c1..d12625ef5 100644 --- a/apps/services/gateway/gateway/routes/notes/qa.py +++ b/apps/services/gateway/gateway/routes/notes/qa.py @@ -13,7 +13,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.notes.core import _get_db, _log, load_owned_meeting, router +from gateway.routes.notes.core import _log, _tenant_session, load_owned_meeting, router from gateway.routes.notes.summaries import _PASS_CHARS, _llm_json, _model, _tag from pydantic import BaseModel from sqlalchemy import text @@ -96,7 +96,7 @@ async def ask_meeting( if not question: raise HTTPException(status_code=400, detail="empty question") - async with await _get_db() as db: + async with _tenant_session() as db: mrow = await load_owned_meeting( db, meeting_id, user.email, columns="m.speaker_names" ) diff --git a/apps/services/gateway/gateway/routes/notes/recordings.py b/apps/services/gateway/gateway/routes/notes/recordings.py index 7b18fa613..33d82afca 100644 --- a/apps/services/gateway/gateway/routes/notes/recordings.py +++ b/apps/services/gateway/gateway/routes/notes/recordings.py @@ -16,8 +16,8 @@ from fastapi.responses import FileResponse from gateway.routes.notes.core import ( OWNED_MEETING_PREDICATE, - _get_db, _log, + _tenant_session, load_owned_meeting, media_dir, router, @@ -102,7 +102,7 @@ async def upload_recording( dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(content) - async with await _get_db() as db: + async with _tenant_session() as db: try: await load_owned_meeting(db, meeting_id, user.email, columns="m.id") except HTTPException: @@ -137,7 +137,6 @@ async def upload_recording( text("UPDATE meeting SET status = 'processing' WHERE id = :id"), {"id": meeting_id}, ) - await db.commit() run_id = str(run_row.id) _log.info( @@ -187,7 +186,7 @@ async def start_recording( dest.parent.mkdir(parents=True, exist_ok=True) dest.touch() - async with await _get_db() as db: + async with _tenant_session() as db: try: await load_owned_meeting(db, meeting_id, user.email, columns="m.id") except HTTPException: @@ -206,7 +205,6 @@ async def start_recording( text("UPDATE meeting SET status='recording' WHERE id=:id"), {"id": meeting_id}, ) - await db.commit() _REC_SEQ[recording_id] = -1 # Register presence so this shows up as "live now" across Command Center and # the copilot console can attach to it. Additive + fail-safe. @@ -229,7 +227,7 @@ async def _recording_path(recording_id: str, meeting_id: str, owner_email: str | that belongs to a colleague must be indistinguishable from one that does not exist. """ - async with await _get_db() as db: + async with _tenant_session() as db: row = ( await db.execute( text( @@ -306,15 +304,14 @@ async def complete_recording( size = path.stat().st_size if path.exists() else 0 _REC_SEQ.pop(recording_id, None) if size == 0: - async with await _get_db() as db: + async with _tenant_session() as db: await db.execute( text("UPDATE meeting SET status='failed' WHERE id=:id"), {"id": meeting_id}, ) - await db.commit() raise HTTPException(status_code=400, detail="no audio was recorded") - async with await _get_db() as db: + async with _tenant_session() as db: await db.execute( text( "UPDATE meeting_recording SET byte_size=:size, duration_s=:dur " @@ -335,7 +332,6 @@ async def complete_recording( text("UPDATE meeting SET status='processing', end_at=now() WHERE id=:id"), {"id": meeting_id}, ) - await db.commit() run_id = str(run_row.id) _log.info( @@ -363,7 +359,7 @@ async def retranscribe( mailbox. Unscoped, it was a way for any member holding ``feature:notes`` to reach both of those on a colleague's meeting. """ - async with await _get_db() as db: + async with _tenant_session() as db: await load_owned_meeting(db, meeting_id, user.email, columns="m.id") rows = ( await db.execute( @@ -394,7 +390,6 @@ async def retranscribe( text("UPDATE meeting SET status='processing' WHERE id=:id"), {"id": meeting_id}, ) - await db.commit() run_id = str(run_row.id) _log.info("notes.retranscribe", meeting_id=meeting_id, recording_id=recording_id) @@ -414,7 +409,7 @@ async def get_audio( Owner only: this is the raw recording — the verbatim audio of a conversation nobody chose to publish, and the one asset the transcript is only a lossy rendering of.""" - async with await _get_db() as db: + async with _tenant_session() as db: await load_owned_meeting(db, meeting_id, user.email, columns="m.id") rows = ( await db.execute( diff --git a/apps/services/gateway/gateway/routes/notes/settings.py b/apps/services/gateway/gateway/routes/notes/settings.py index 941cc9b52..b7589e316 100644 --- a/apps/services/gateway/gateway/routes/notes/settings.py +++ b/apps/services/gateway/gateway/routes/notes/settings.py @@ -27,7 +27,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.notes.core import _get_db, _log, router +from gateway.routes.notes.core import _get_db, _log, _tenant_session, router from gateway.routes.notes.templates import TEMPLATES from pydantic import BaseModel from sqlalchemy import text @@ -176,6 +176,9 @@ async def load(owner_email: str) -> NotesSettings: if not owner_email: return NotesSettings() try: + # H4: shared helper also consumed by background paths (`auto_dispatch` + # from the summary pipeline task, `load_for_meeting` below) — no + # ambient tenant there; derive it from the owner's app_user/meeting row. async with await _get_db() as db: row = ( await db.execute( @@ -191,6 +194,9 @@ async def load(owner_email: str) -> NotesSettings: async def load_for_meeting(meeting_id: str) -> tuple[NotesSettings, str | None]: """Settings of whoever owns this meeting, plus its template key.""" + # H4: consumed by the copilot orchestrator task and the meeting-bot + # worker's `/live/wanted` (service identity) — no ambient tenant; derive + # it from the meeting row. try: async with await _get_db() as db: row = ( @@ -249,7 +255,7 @@ async def put_settings( for k, v in (body.template_instructions or {}).items() if k in TEMPLATES and (v or "").strip() } - async with await _get_db() as db: + async with _tenant_session() as db: await db.execute( text( "INSERT INTO copilot_config (owner_email, instructions, " @@ -285,6 +291,5 @@ async def put_settings( "add": body.auto_dispatch_docs, }, ) - await db.commit() _log.info("notes.settings_saved", overrides=len(overrides)) return {"settings": (await load(email)).model_dump()} diff --git a/apps/services/gateway/gateway/routes/notes/share.py b/apps/services/gateway/gateway/routes/notes/share.py index 3ef0eb9a9..ef44a0376 100644 --- a/apps/services/gateway/gateway/routes/notes/share.py +++ b/apps/services/gateway/gateway/routes/notes/share.py @@ -11,7 +11,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.notes.core import _get_db, _log, load_owned_meeting, router +from gateway.routes.notes.core import _log, _tenant_session, load_owned_meeting, router from pydantic import BaseModel @@ -34,7 +34,7 @@ async def draft_followup_email( through ``/email/send`` under whichever account they pick. So the thing to scope is the read, and the read is the whole of a colleague's notes plus their attendee list, rendered as prose.""" - async with await _get_db() as db: + async with _tenant_session() as db: m = await load_owned_meeting( db, meeting_id, user.email, columns="m.title, m.summary_md, m.attendees", diff --git a/apps/services/gateway/gateway/routes/notes/speaker_id.py b/apps/services/gateway/gateway/routes/notes/speaker_id.py index fb36992fe..1194fdd14 100644 --- a/apps/services/gateway/gateway/routes/notes/speaker_id.py +++ b/apps/services/gateway/gateway/routes/notes/speaker_id.py @@ -29,7 +29,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.notes.core import _get_db, _log, router +from gateway.routes.notes.core import _get_db, _log, _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -180,6 +180,9 @@ async def infer_speaker_names( Returns the full merged map. Never raises: on any problem the existing map is returned unchanged.""" + # H4: shared helper also consumed by the background transcription pipeline + # (`pipeline.run_transcription`, a spawned task) — no ambient tenant + # there; derive it from the meeting row. try: async with await _get_db() as db: m = ( @@ -216,6 +219,7 @@ async def infer_speaker_names( if not applied: return merged + # H4: see above — same background pipeline reach. async with await _get_db() as db: await db.execute( text( @@ -257,7 +261,7 @@ async def identify_speakers( Non-destructive: names already set (by the user or a prior run) are kept; only still-anonymous speakers can be filled.""" - async with await _get_db() as db: + async with _tenant_session() as db: m = ( await db.execute( text("SELECT speaker_names FROM meeting WHERE id = :id"), diff --git a/apps/services/gateway/gateway/routes/notes/summaries.py b/apps/services/gateway/gateway/routes/notes/summaries.py index 89b5e0923..7a9b88a8d 100644 --- a/apps/services/gateway/gateway/routes/notes/summaries.py +++ b/apps/services/gateway/gateway/routes/notes/summaries.py @@ -16,7 +16,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.notes.core import _get_db, _log, load_owned_meeting, router +from gateway.routes.notes.core import _get_db, _log, _tenant_session, load_owned_meeting, router from gateway.routes.notes.templates import ( build_system_prompt, get_template, @@ -218,6 +218,10 @@ async def generate_notes(meeting_id: str, run_id: str, triggered_by: str) -> Non the row it is about. """ try: + # H4: background job (spawned via `enqueue_summary`) — no ambient + # tenant; derive it from the meeting row / `triggered_by` when H4 + # threads an explicit tenant through. Applies to every `_get_db` + # block in this function. async with await _get_db() as db: m = ( await db.execute( @@ -405,6 +409,9 @@ async def enqueue_summary(meeting_id: str, triggered_by: str) -> str: recording). It has no default so that a new caller has to answer the question rather than inherit somebody else's authority. """ + # H4: shared entry point also called from the background transcription + # pipeline (`pipeline.run_transcription`) — no ambient tenant there; + # derive it from the meeting row. async with await _get_db() as db: row = ( await db.execute( @@ -475,7 +482,7 @@ async def summarize( also refused at the dispatch seam — this is the endpoint half of the same fix, not a substitute for it. """ - async with await _get_db() as db: + async with _tenant_session() as db: m = await load_owned_meeting( db, meeting_id, user.email, columns="m.status" ) @@ -493,7 +500,7 @@ async def get_note( meeting_id: str, _user: UserContext = Depends(get_current_user), ) -> NoteDoc: - async with await _get_db() as db: + async with _tenant_session() as db: row = ( await db.execute( text("SELECT * FROM meeting_note WHERE meeting_id=:id"), @@ -517,7 +524,7 @@ async def put_note( body: PutNoteRequest, user: UserContext = Depends(get_current_user), ) -> NoteDoc: - async with await _get_db() as db: + async with _tenant_session() as db: exists = ( await db.execute( text("SELECT 1 FROM meeting WHERE id=:id"), {"id": meeting_id} @@ -540,7 +547,6 @@ async def put_note( "by": user.email or "user", }, ) - await db.commit() return await get_note(meeting_id, _user=user) @@ -549,7 +555,7 @@ async def list_actions( meeting_id: str, _user: UserContext = Depends(get_current_user), ) -> list[ActionItemModel]: - async with await _get_db() as db: + async with _tenant_session() as db: rows = ( await db.execute( text( diff --git a/tests/unit/test_db_engine_seam.py b/tests/unit/test_db_engine_seam.py index 77e51ba35..00ade4b78 100644 --- a/tests/unit/test_db_engine_seam.py +++ b/tests/unit/test_db_engine_seam.py @@ -311,7 +311,10 @@ def test_acb_auth_shares_the_pool() -> None: #: The unconverted remainder OUTSIDE routes/projects at the time the Projects #: slice landed (2026-08-10). Lower it as packages convert; never raise it. -H2_BASELINE_ELSEWHERE = 494 +#: 494 → 433: routes/notes converted (61 handler sites → `_tenant_session`; +#: 33 remain there — background pipeline/poller/copilot-task sites and the +#: meeting-bot worker's service-identity paths, each marked `# H4` in place). +H2_BASELINE_ELSEWHERE = 433 def _get_db_sites() -> dict[str, int]: diff --git a/tests/unit/test_notes_owner_scoping.py b/tests/unit/test_notes_owner_scoping.py index 65bb5ca3c..734feccdd 100644 --- a/tests/unit/test_notes_owner_scoping.py +++ b/tests/unit/test_notes_owner_scoping.py @@ -29,6 +29,7 @@ import inspect import io +from contextlib import asynccontextmanager from types import SimpleNamespace import pytest @@ -228,10 +229,28 @@ async def __aexit__(self, *exc) -> bool: def _install_db(monkeypatch, module, db: _FakeDb) -> _FakeDb: + """Point a notes module's DB seam(s) at ``db``. + + H2 note: converted request handlers acquire sessions through the module's + ``_tenant_session`` alias (an ``asynccontextmanager`` over the shared + tenant-bound seam), while background/service paths still use ``_get_db``. + Both are patched when present — a module mid-conversion has both — and the + fake context manager mirrors the real wrapper's commit-on-clean-exit so + one-transaction contracts stay observable. GUC plumbing is out of scope + here; ``test_tenant_session.py`` owns that. + """ async def _get_db(): return db - monkeypatch.setattr(module, "_get_db", _get_db) + @asynccontextmanager + async def _tenant_session(organization_id: str | None = None): + yield db + await db.commit() + + if hasattr(module, "_tenant_session"): + monkeypatch.setattr(module, "_tenant_session", _tenant_session) + if hasattr(module, "_get_db"): + monkeypatch.setattr(module, "_get_db", _get_db) return db From 2ba83b73e025f4d2f0bd25e379e3ad5e3c70677f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 07:01:17 +0000 Subject: [PATCH 02/10] =?UTF-8?q?feat(tenancy):=20H2=20=E2=80=94=20routes/?= =?UTF-8?q?whatsapp=20converted=20to=20tenant=5Fsession=20(slice)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 52 get_db() sites classified before touching any (this package is ingestion-heavy, and a wrong CONVERT is a runtime 500): - 38 request-handler sites (Depends(get_current_user), tenant bound centrally by _with_resolved_access) are now `async with _tenant_session() as db:` — the per-package alias in whatsapp/core.py IS acb_common.db.tenant_session, mirroring projects/core.py; identity asserted by the existing seam test. Explicit commits removed (the wrapper commits on clean exit; a mid-block commit would end the transaction and drop the GUC), the bootstrap/update commit-then-reread shapes now read their own writes in the one transaction, and the saved-replies inner excepts keep their 409 mapping inside the block with the wrapper owning rollback. - 7 service-identity sites STAY on _get_db with `# H4/H6` markers: the Meta webhook pair (signature-authed, no member session) and the Go bridge's five shared-secret push routes (ingest/reclassify/labels/ avatars/paired — grep of whatsapp_bridge/gateway.go is the evidence). system:internal binds NO tenant, so tenant_session() there would 500 every inbound batch; H4/H6 derives the tenant from the wa_accounts row instead. bridge.py carries both seams deliberately: its /connect and /status routes are user-facing and converted. - 7 background-consumer sites STAY with `# H4` markers: the enrichment scheduler pair, the post-sync hooks (intent.process_new_messages, replyzero.classify_chats), the two scheduled enrichment passes (transcribe_pending, summarize_stale_groups) and the Action Broker broadcast handler — inheriting an ambient tenant is exactly what the runbook forbids for jobs. - Ratchets (test_db_engine_seam.py): H2_BASELINE_ELSEWHERE banked 494 → 456 (measured), and a new pin holds routes/whatsapp at exactly its named remainder file-by-file, so a new unbound handler fails the build and a retired site must shrink the dict. - Hermetic seams re-pointed: test_whatsapp_calls/_connect now patch _tenant_session with an @asynccontextmanager fake that commits on clean exit (the _projects_fakes.bind_db shape); the closed-is-True resource proxy became a committed==1 assertion. Verification: the 27 test_whatsapp_*.py files + test_db_engine_seam.py pass by name (335 tests); ruff F821/F601/F602/F502/F7/B006 clean on all touched files; live smoke on scratch Postgres 16 with the repo migrations applied — unbound converted handler raises TenantUnbound, bound GET /whatsapp/accounts answers through the real set_config('app.tenant_id',…) round-trip, converted DELETE commits via the wrapper, and the GUC does not leak to a fresh session. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../routes/whatsapp/automation/categories.py | 23 ++++------ .../routes/whatsapp/automation/commitments.py | 12 ++---- .../routes/whatsapp/automation/drafting.py | 13 ++---- .../routes/whatsapp/automation/groups.py | 15 +++---- .../routes/whatsapp/automation/intent.py | 3 ++ .../routes/whatsapp/automation/outbound.py | 15 ++++--- .../routes/whatsapp/automation/replyzero.py | 3 ++ .../routes/whatsapp/automation/rules.py | 7 +-- .../whatsapp/automation/transcription.py | 12 +++--- .../gateway/gateway/routes/whatsapp/core.py | 28 +++++++++--- .../gateway/gateway/routes/whatsapp/digest.py | 7 +-- .../gateway/gateway/routes/whatsapp/pulse.py | 7 +-- .../gateway/routes/whatsapp/scheduler.py | 5 +++ .../routes/whatsapp/transport/accounts.py | 21 +++------ .../routes/whatsapp/transport/bridge.py | 36 +++++++++++----- .../routes/whatsapp/transport/calls.py | 7 +-- .../routes/whatsapp/transport/capture.py | 8 +--- .../routes/whatsapp/transport/chats.py | 12 ++---- .../routes/whatsapp/transport/connect.py | 8 +--- .../routes/whatsapp/transport/context.py | 7 +-- .../routes/whatsapp/transport/labels.py | 7 +-- .../routes/whatsapp/transport/messages.py | 17 ++++---- .../whatsapp/transport/saved_replies.py | 32 +++++--------- .../gateway/routes/whatsapp/transport/send.py | 8 +--- .../routes/whatsapp/transport/snooze.py | 16 +++---- .../routes/whatsapp/transport/templates.py | 21 +++------ .../routes/whatsapp/transport/webhook.py | 7 +++ tests/unit/test_db_engine_seam.py | 43 ++++++++++++++++++- tests/unit/test_whatsapp_calls.py | 29 +++++++++---- tests/unit/test_whatsapp_connect.py | 26 ++++++++--- 30 files changed, 241 insertions(+), 214 deletions(-) diff --git a/apps/services/gateway/gateway/routes/whatsapp/automation/categories.py b/apps/services/gateway/gateway/routes/whatsapp/automation/categories.py index 514459416..1edbee197 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/automation/categories.py +++ b/apps/services/gateway/gateway/routes/whatsapp/automation/categories.py @@ -15,7 +15,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.whatsapp.core import _get_db, assert_account_owned, router +from gateway.routes.whatsapp.core import _tenant_session, assert_account_owned, router from pydantic import BaseModel from sqlalchemy import text @@ -102,16 +102,13 @@ async def list_categories( account_id: str, user: UserContext = Depends(get_current_user), ): """List an account's categories with their policies, in nav order.""" - db = await _get_db() - try: + async with _tenant_session() as db: await assert_account_owned(db, account_id, user.email or "anonymous") rows = (await db.execute( text(_SELECT + " WHERE account_id = :aid ORDER BY sort_order, name"), {"aid": account_id}, )).fetchall() return [_model(r) for r in rows] - finally: - await db.close() @router.post("/accounts/{account_id}/categories/bootstrap", @@ -120,8 +117,7 @@ async def bootstrap_categories( account_id: str, user: UserContext = Depends(get_current_user), ): """Seed the default category policy set (idempotent — existing names kept).""" - db = await _get_db() - try: + async with _tenant_session() as db: await assert_account_owned(db, account_id, user.email or "anonymous") for c in default_categories(): await db.execute( @@ -138,14 +134,13 @@ async def bootstrap_categories( "auto": c["auto_reply_policy"], "draft": c["draft_policy"], "esc": c["escalate_after_mins"], "sort": c["sort_order"]}, ) - await db.commit() + # Same transaction as the seeding INSERTs — it reads its own writes, + # and the wrapper commits everything together on clean exit. rows = (await db.execute( text(_SELECT + " WHERE account_id = :aid ORDER BY sort_order, name"), {"aid": account_id}, )).fetchall() return [_model(r) for r in rows] - finally: - await db.close() @router.patch("/categories/{category_id}", response_model=CategoryModel) @@ -155,8 +150,7 @@ async def update_category( ): """Change a category's policy (the founder tuning behaviour per category).""" _validate_policies(req.notify_policy, req.auto_reply_policy, req.draft_policy) - db = await _get_db() - try: + async with _tenant_session() as db: row = (await db.execute( text("""SELECT c.id FROM wa_categories c JOIN wa_accounts a ON a.id = c.account_id @@ -175,10 +169,9 @@ async def update_category( f"WHERE id = :cid"), fields, ) - await db.commit() + # Same transaction as the UPDATE, which sees its own write; the + # wrapper commits both together on clean exit. updated = (await db.execute( text(_SELECT + " WHERE id = :cid"), {"cid": category_id}, )).fetchone() return _model(updated) - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/automation/commitments.py b/apps/services/gateway/gateway/routes/whatsapp/automation/commitments.py index e4a4500d4..86c7f8b1d 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/automation/commitments.py +++ b/apps/services/gateway/gateway/routes/whatsapp/automation/commitments.py @@ -23,7 +23,7 @@ from acb_common import get_logger from fastapi import Depends, HTTPException from gateway.routes.whatsapp.automation.drafting import detect_language -from gateway.routes.whatsapp.core import _get_db, router +from gateway.routes.whatsapp.core import _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -136,8 +136,7 @@ async def list_commitments( user: UserContext = Depends(get_current_user), ): """List commitments for an account — ours (digest watch) or theirs (chase).""" - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = { "uid": user.email or "anonymous", "aid": account_id, "status": status, } @@ -163,8 +162,6 @@ async def list_commitments( ) for r in rows ] - finally: - await db.close() # ── waiting-on nudge drafts (W4.2) ──────────────────────────────────────────── @@ -310,8 +307,7 @@ async def draft_commitment_nudge( The result is a DRAFT the founder reviews and sends via the composer — this endpoint never sends, so it needs no 24h-window / template logic. """ - db = await _get_db() - try: + async with _tenant_session() as db: account_id = await _assert_theirs_commitment_owned( db, commitment_id, user.email or "anonymous") result = await draft_nudge(db, account_id, commitment_id) @@ -322,5 +318,3 @@ async def draft_commitment_nudge( chat_id, nudge_text, language = result return NudgeModel(commitment_id=commitment_id, chat_id=chat_id, nudge_text=nudge_text, language=language) - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/automation/drafting.py b/apps/services/gateway/gateway/routes/whatsapp/automation/drafting.py index dfcd5a6a6..d669dabe6 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/automation/drafting.py +++ b/apps/services/gateway/gateway/routes/whatsapp/automation/drafting.py @@ -24,7 +24,7 @@ from acb_auth import UserContext, get_current_user from acb_common import get_logger from fastapi import Depends, HTTPException -from gateway.routes.whatsapp.core import _get_db, assert_chat_owned, router +from gateway.routes.whatsapp.core import _tenant_session, assert_chat_owned, router from pydantic import BaseModel from sqlalchemy import text @@ -174,8 +174,7 @@ async def generate_draft( chat_id: str, user: UserContext = Depends(get_current_user), ): """Generate (and cache) an AI reply draft for a chat.""" - db = await _get_db() - try: + async with _tenant_session() as db: account_id = await assert_chat_owned(db, chat_id, user.email or "anonymous") draft = await draft_reply(db, account_id, chat_id) if draft is None: @@ -192,10 +191,7 @@ async def generate_draft( generated_at = now()"""), {"aid": account_id, "cid": chat_id, "text": draft, "lang": language}, ) - await db.commit() return DraftModel(chat_id=chat_id, draft_text=draft, language=language) - finally: - await db.close() @router.get("/chats/{chat_id}/draft", response_model=DraftModel | None) @@ -203,8 +199,7 @@ async def get_cached_draft( chat_id: str, user: UserContext = Depends(get_current_user), ): """Return the cached draft for a chat, or null if none has been generated.""" - db = await _get_db() - try: + async with _tenant_session() as db: await assert_chat_owned(db, chat_id, user.email or "anonymous") row = (await db.execute( text("""SELECT draft_text, language FROM wa_ai_drafts @@ -215,5 +210,3 @@ async def get_cached_draft( return None return DraftModel( chat_id=chat_id, draft_text=row.draft_text, language=row.language) - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/automation/groups.py b/apps/services/gateway/gateway/routes/whatsapp/automation/groups.py index d3eb7aba3..cb6b225d1 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/automation/groups.py +++ b/apps/services/gateway/gateway/routes/whatsapp/automation/groups.py @@ -22,7 +22,7 @@ from acb_common import get_logger from fastapi import Depends, HTTPException from gateway.routes.whatsapp.automation.replyzero import _account_wa_ids -from gateway.routes.whatsapp.core import _get_db, router +from gateway.routes.whatsapp.core import _get_db, _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -196,6 +196,8 @@ async def summarize_stale_groups(account_id: str) -> int: """Summarize group chats that have new activity since their last summary (or none yet). Bounded per pass. For a schedule/digest trigger, NOT the hot webhook path. Returns the number summarized. Own transaction.""" + # H4: background enrichment pass (scheduler.run_enrichment_cycle) — no + # request, no ambient tenant; H4 threads an explicit one per account. db = await _get_db() try: rows = (await db.execute( @@ -251,20 +253,16 @@ async def generate_group_summary( chat_id: str, user: UserContext = Depends(get_current_user), ): """Summarize one group on demand (and cache it).""" - db = await _get_db() - try: + async with _tenant_session() as db: account_id = await _assert_group_owned(db, chat_id, user.email or "anonymous") result = await summarize_group(db, account_id, chat_id) if result is None: raise HTTPException( status_code=422, detail="No summary — nothing to summarize") - await db.commit() return GroupSummaryModel(chat_id=chat_id, **{ k: result[k] for k in ("summary", "sentiment", "mentions_you", "key_points", "message_count") }) - finally: - await db.close() @router.get("/groups/summaries", response_model=list[GroupSummaryModel]) @@ -275,8 +273,7 @@ async def list_group_summaries( ): """List cached group summaries, newest first; ``needs_you`` filters to the ones the founder was addressed in.""" - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = {"uid": user.email or "anonymous"} scope = "s.account_id IN (SELECT id FROM wa_accounts WHERE user_id = :uid" if account_id: @@ -312,5 +309,3 @@ async def list_group_summaries( generated_at=r.generated_at.isoformat() if r.generated_at else None, )) return out - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/automation/intent.py b/apps/services/gateway/gateway/routes/whatsapp/automation/intent.py index 211bdbc55..241279e60 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/automation/intent.py +++ b/apps/services/gateway/gateway/routes/whatsapp/automation/intent.py @@ -114,6 +114,9 @@ async def process_new_messages(account_id: str) -> None: sender categorization + auto-answer execution layer on here later.""" from gateway.routes.whatsapp.automation.commitments import apply_commitments from gateway.routes.whatsapp.core import _get_db + # H4: post-sync hook — fired by the Meta webhook / bridge ingest (service + # identity, no member session), so there is NO ambient tenant to bind and + # none may be inherited; H4 threads an explicit one from the account row. db = await _get_db() try: classified = await apply_intents(db, account_id) diff --git a/apps/services/gateway/gateway/routes/whatsapp/automation/outbound.py b/apps/services/gateway/gateway/routes/whatsapp/automation/outbound.py index 3ee5160c4..2443f58e6 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/automation/outbound.py +++ b/apps/services/gateway/gateway/routes/whatsapp/automation/outbound.py @@ -22,7 +22,12 @@ from acb_auth import UserContext, get_current_user from acb_common import get_logger from fastapi import Depends, HTTPException -from gateway.routes.whatsapp.core import _get_db, _provider_for_account, router +from gateway.routes.whatsapp.core import ( + _get_db, + _provider_for_account, + _tenant_session, + router, +) from pydantic import BaseModel from sqlalchemy import text @@ -61,6 +66,9 @@ async def _wa_broadcast_handler(proposal: Any) -> dict[str, Any]: text_body = payload["text"] targets = payload.get("targets", []) # [{wa_chat_id, chat_id}] + # H4: Action Broker handler — runs at approval/apply time, outside the + # proposing request; no ambient tenant may be inherited. H4 threads an + # explicit tenant through the proposal payload (or the wa_accounts row). db = await _get_db() try: provider, _store, _row = await _provider_for_account(db, account_id) @@ -116,8 +124,7 @@ async def broadcast( if not req.chat_ids and not req.category: raise HTTPException(status_code=400, detail="chat_ids or category required") - db = await _get_db() - try: + async with _tenant_session() as db: # Resolve targets, owner-scoped. params: dict[str, Any] = { "uid": user.email or "anonymous", "aid": req.account_id, @@ -138,8 +145,6 @@ async def broadcast( targets = [{"chat_id": str(r.id), "wa_chat_id": r.wa_chat_id} for r in rows] if not targets: raise HTTPException(status_code=404, detail="no matching chats") - finally: - await db.close() # Always propose with SUGGEST authority → the broker holds it for a human. from action_broker.broker import ( diff --git a/apps/services/gateway/gateway/routes/whatsapp/automation/replyzero.py b/apps/services/gateway/gateway/routes/whatsapp/automation/replyzero.py index f2f1d86af..ba303783f 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/automation/replyzero.py +++ b/apps/services/gateway/gateway/routes/whatsapp/automation/replyzero.py @@ -136,6 +136,9 @@ async def classify_chats(account_id: str) -> None: still catches up rather than gating on new inbound. """ from gateway.routes.whatsapp.core import _get_db + # H4: post-sync hook — fired by the Meta webhook / bridge ingest / + # bridge reclassify (service identity, no member session), so there is NO + # ambient tenant and none may be inherited; H4 threads an explicit one. db = await _get_db() try: acc = (await db.execute( diff --git a/apps/services/gateway/gateway/routes/whatsapp/automation/rules.py b/apps/services/gateway/gateway/routes/whatsapp/automation/rules.py index e2b335ad1..4db49d8a3 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/automation/rules.py +++ b/apps/services/gateway/gateway/routes/whatsapp/automation/rules.py @@ -25,7 +25,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, Query -from gateway.routes.whatsapp.core import _get_db, router +from gateway.routes.whatsapp.core import _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -147,8 +147,7 @@ async def rules_preview( ): """Dry-run the auto-reply engine over recent needs-reply chats — what WOULD happen, no sends. Lets the founder see the automation before enabling it.""" - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = { "uid": user.email or "anonymous", "aid": account_id, "lim": limit, } @@ -200,5 +199,3 @@ async def rules_preview( summary[decision.action] = summary.get(decision.action, 0) + 1 return RulePreviewModel(items=items, summary=summary) - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/automation/transcription.py b/apps/services/gateway/gateway/routes/whatsapp/automation/transcription.py index cca8c11fb..12320ed23 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/automation/transcription.py +++ b/apps/services/gateway/gateway/routes/whatsapp/automation/transcription.py @@ -28,6 +28,7 @@ from gateway.routes.whatsapp.core import ( _get_db, _provider_for_account, + _tenant_session, router, ) from pydantic import BaseModel @@ -147,6 +148,8 @@ async def transcribe_pending(account_id: str) -> int: then re-run the classifiers once. A schedule/digest trigger, NOT the hot webhook path. Returns how many produced a transcript. Owns its transaction. """ + # H4: background enrichment pass (scheduler.run_enrichment_cycle) — no + # request, no ambient tenant; H4 threads an explicit one per account. db = await _get_db() try: rows = (await db.execute( @@ -200,18 +203,17 @@ async def transcribe_voice_note( message_id: str, user: UserContext = Depends(get_current_user), ): """Transcribe one voice note on demand and fold it into triage.""" - db = await _get_db() - try: + async with _tenant_session() as db: account_id = await _assert_message_owned( db, message_id, user.email or "anonymous") provider, _store, _acc = await _provider_for_account(db, account_id) transcript = await transcribe_message(db, account_id, message_id, provider) if transcript is None: + # The 422 rolls back the sentinel 'failed'/'skipped' stamp too; + # the scheduled pass re-attempts, exactly as the old + # commit-only-on-success shape behaved. raise HTTPException( status_code=422, detail="No transcript — not a voice note, or transcription failed") await _reclassify(db, account_id) - await db.commit() return TranscriptModel(message_id=message_id, transcript_text=transcript) - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/core.py b/apps/services/gateway/gateway/routes/whatsapp/core.py index 63f20961b..8beec75ad 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/core.py +++ b/apps/services/gateway/gateway/routes/whatsapp/core.py @@ -17,6 +17,23 @@ # The shared gateway engine (BO-10) — see the DB section below. from gateway.db import get_db as _get_db # noqa: F401 from gateway.db import get_session_factory as _get_session_factory # noqa: F401 + +# The tenant-bound seam (MT-1c/H2). `_tenant_session` IS +# `acb_common.db.tenant_session`, aliased per-package exactly as +# `routes/projects/core.py` does: every submodule imports it from here BY +# NAME, which is the one seam the hermetic tests patch per module. The tenant +# comes from the request context — bound centrally in `_with_resolved_access` +# — so no call site passes one. A call outside a bound request raises +# `TenantUnbound` rather than defaulting: fail closed, never "the usual org". +# +# ⚠️ NOT every site in this package uses it. This surface is ingestion-heavy: +# the Meta webhook, the whatsmeow bridge's five push routes, the post-sync +# hooks and the enrichment scheduler all run with NO ambient tenant (Meta and +# the Go bridge authenticate with their own secrets, not a member session; +# `system:internal` binds nothing). Those stay on `_get_db` with an H4/H6 +# marker at each site until an explicit tenant is threaded through — deriving +# it ambiently there is exactly what the H2 runbook forbids. +from gateway.db import tenant_session as _tenant_session # noqa: F401 from pydantic import BaseModel from acb_auth import require_feature_router @@ -119,11 +136,12 @@ class WhatsAppMessageModel(BaseModel): # ── DB (the one shared gateway engine — gateway/db.py, BO-10) ──────────────── # # This package used to build its own engine here with its own 5+10 pool. It now -# has none: `_get_db` / `_get_session_factory` at the top of this module are -# re-exports of the shared seam. The private names are kept so that every -# `from .core import _get_db` in this package — and every test that -# monkeypatches `_get_db` on the sibling module it is imported into — keeps -# working unchanged. +# has none: `_get_db` / `_get_session_factory` / `_tenant_session` at the top +# of this module are re-exports of the shared seam. The private names are kept +# so that every `from .core import _tenant_session` (or `_get_db`, on the +# service-identity/background sites H4 owns) in this package — and every test +# that monkeypatches the seam on the sibling module it is imported into — +# keeps working unchanged. # ── provider adapter ────────────────────────────────────────────────────────── diff --git a/apps/services/gateway/gateway/routes/whatsapp/digest.py b/apps/services/gateway/gateway/routes/whatsapp/digest.py index 3df606cd9..822d0ce45 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/digest.py +++ b/apps/services/gateway/gateway/routes/whatsapp/digest.py @@ -12,7 +12,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends -from gateway.routes.whatsapp.core import _get_db, router +from gateway.routes.whatsapp.core import _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -82,8 +82,7 @@ async def digest( user: UserContext = Depends(get_current_user), ): """The WhatsApp section of the morning brief for the user's number(s).""" - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = {"uid": user.email or "anonymous"} scope = "IN (SELECT id FROM wa_accounts WHERE user_id = :uid" if account_id: @@ -187,5 +186,3 @@ async def digest( waiting_on=waiting_on, waiting_on_count=waiting_on_count, ) - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/pulse.py b/apps/services/gateway/gateway/routes/whatsapp/pulse.py index fd9b43d9e..e0544b60e 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/pulse.py +++ b/apps/services/gateway/gateway/routes/whatsapp/pulse.py @@ -13,7 +13,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, Query -from gateway.routes.whatsapp.core import _get_db, router +from gateway.routes.whatsapp.core import _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -93,8 +93,7 @@ async def pulse( ): """WhatsApp health over the last ``days``: reply speed, who's waited longest, inbound load by intent, and the busiest chats.""" - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = {"uid": user.email or "anonymous", "days": days} scope = "IN (SELECT id FROM wa_accounts WHERE user_id = :uid" if account_id: @@ -197,5 +196,3 @@ async def pulse( by_intent=by_intent, busiest=busiest, ) - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/scheduler.py b/apps/services/gateway/gateway/routes/whatsapp/scheduler.py index 98562a04b..268a36755 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/scheduler.py +++ b/apps/services/gateway/gateway/routes/whatsapp/scheduler.py @@ -54,6 +54,9 @@ def resolve_interval(raw: str | int | None) -> int: async def _live_account_ids() -> list[str]: """Every account not in a hard error state — the sweep set.""" from gateway.routes.whatsapp.core import _get_db + # H4: background loop (asyncio.create_task, no request) — deliberately + # cross-tenant: it enumerates every account so each per-account pass can + # later run under that account's own explicit tenant. db = await _get_db() try: rows = (await db.execute( @@ -69,6 +72,8 @@ async def _embed_account(account_id: str) -> int: No-op (0) when ``whatsapp_semantic_search_enabled`` is off (W10).""" from gateway.routes.whatsapp.core import _get_db from whatsapp_ingestion.wa_embeddings import embed_pending_messages + # H4: background loop — no request, no ambient tenant; H4 threads an + # explicit per-account tenant through the sweep. db = await _get_db() try: return await embed_pending_messages(db, account_id) diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/accounts.py b/apps/services/gateway/gateway/routes/whatsapp/transport/accounts.py index e591a7c32..ca19d4725 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/accounts.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/accounts.py @@ -17,7 +17,7 @@ from fastapi import Depends, HTTPException from gateway.routes.whatsapp.core import ( WhatsAppAccountModel, - _get_db, + _tenant_session, router, ) from pydantic import BaseModel @@ -55,8 +55,7 @@ def _account_model(row: Any) -> WhatsAppAccountModel: @router.get("/accounts", response_model=list[WhatsAppAccountModel]) async def list_accounts(user: UserContext = Depends(get_current_user)): """List the WhatsApp Business numbers connected by the current user.""" - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text("""SELECT id, phone_number, phone_number_id, waba_id, display_name, avatar_color, sync_status, sync_error, @@ -67,8 +66,6 @@ async def list_accounts(user: UserContext = Depends(get_current_user)): {"uid": user.email or "anonymous"}, )).fetchall() return [_account_model(r) for r in rows] - finally: - await db.close() async def persist_account( @@ -137,8 +134,7 @@ async def create_account( req: CreateAccountRequest, user: UserContext = Depends(get_current_user), ): """Register a WhatsApp Business number (the manual / guided-wizard path).""" - db = await _get_db() - try: + async with _tenant_session() as db: row = await persist_account( db, user_id=user.email or "anonymous", phone_number=req.phone_number, phone_number_id=req.phone_number_id, @@ -146,10 +142,7 @@ async def create_account( credentials=req.credentials, webhook_verify_token=req.webhook_verify_token, ) - await db.commit() return _account_model(row) - finally: - await db.close() @router.delete("/accounts/{account_id}", status_code=204) @@ -159,14 +152,12 @@ async def delete_account( """Disconnect a number. The message archive is kept (rows cascade only if the account row is removed) — we remove the account, which cascades its data; the UI copy makes that explicit.""" - db = await _get_db() - try: + async with _tenant_session() as db: result = await db.execute( text("DELETE FROM wa_accounts WHERE id = :id AND user_id = :uid"), {"id": account_id, "uid": user.email or "anonymous"}, ) - await db.commit() + # A miss deleted nothing, so the 404's rollback discards an empty + # transaction — same outcome as the old commit-then-404 ordering. if result.rowcount == 0: raise HTTPException(status_code=404, detail="Account not found") - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/bridge.py b/apps/services/gateway/gateway/routes/whatsapp/transport/bridge.py index fdcdcf3c0..6da23e951 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/bridge.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/bridge.py @@ -35,7 +35,20 @@ from acb_auth import UserContext, get_current_user from acb_common import get_logger from fastapi import Depends, Request, Response -from gateway.routes.whatsapp.core import _get_db, fire_post_sync_hooks, router + +# Two seams, deliberately (H2): `_tenant_session` serves the two USER routes +# (`/bridge/connect`, `/bridge/status` — session-authenticated, tenant bound +# centrally). The five PUSH routes the Go bridge calls (`/bridge/ingest`, +# `/bridge/reclassify`, `/bridge/labels`, `/bridge/avatars`, `/bridge/paired`) +# authenticate by X-Bridge-Secret with NO member session, so no ambient tenant +# exists to bind — they stay on the unbound `_get_db` (H4/H6 markers at each +# site) until an explicit tenant is derived from the wa_accounts row. +from gateway.routes.whatsapp.core import ( + _get_db, + _tenant_session, + fire_post_sync_hooks, + router, +) from pydantic import BaseModel from sqlalchemy import text from whatsapp_ingestion.providers.base import ( @@ -297,6 +310,8 @@ async def bridge_ingest(request: Request): if not account_id: return Response(status_code=200, content="ok") # nothing to route + # H4/H6: service-identity route — the Go bridge authenticates by shared + # secret, no ambient tenant; derive it from the wa_accounts row it names. db = await _get_db() try: owned = (await db.execute( @@ -342,6 +357,8 @@ async def bridge_reclassify(request: Request): if not account_id: return Response(status_code=400, content="account_id required") + # H4/H6: service-identity route — no ambient tenant; derive from the + # wa_accounts row the payload names. db = await _get_db() try: owned = (await db.execute( @@ -378,6 +395,8 @@ async def bridge_labels(request: Request): if not account_id: return Response(status_code=200, content="ok") + # H4/H6: service-identity route — no ambient tenant; derive from the + # wa_accounts row the payload names. db = await _get_db() try: owned = (await db.execute( @@ -412,6 +431,8 @@ async def bridge_avatars(request: Request): if not account_id: return Response(status_code=200, content="ok") + # H4/H6: service-identity route — no ambient tenant; derive from the + # wa_accounts row the payload names. db = await _get_db() try: owned = (await db.execute( @@ -444,6 +465,8 @@ async def bridge_paired(request: Request): account_id = str(payload.get("session") or payload.get("account_id") or "").strip() if not account_id: return Response(status_code=400, content="session required") + # H4/H6: service-identity route — no ambient tenant; derive from the + # wa_accounts row the payload names. db = await _get_db() try: await db.execute( @@ -483,8 +506,7 @@ async def bridge_connect(user: UserContext = Depends(get_current_user)): from acb_llm.key_store import get_key_store encrypted = get_key_store().encrypt(json.dumps(creds)) - db = await _get_db() - try: + async with _tenant_session() as db: is_first = (await db.execute( text("SELECT COUNT(*) FROM wa_accounts WHERE user_id = :uid"), {"uid": uid}, @@ -498,9 +520,6 @@ async def bridge_connect(user: UserContext = Depends(get_current_user)): {"id": account_id, "uid": uid, "pnid": f"bridge-{account_id}", "creds": encrypted, "is_default": is_first}, ) - await db.commit() - finally: - await db.close() qr, reachable = await _bridge_start_session(account_id) return BridgeConnectModel( @@ -512,16 +531,13 @@ async def bridge_status( account_id: str, user: UserContext = Depends(get_current_user), ): """Poll pairing status + the current QR for a pairing account.""" - db = await _get_db() - try: + async with _tenant_session() as db: row = (await db.execute( text("""SELECT sync_status FROM wa_accounts WHERE id = :aid AND user_id = :uid AND provider = 'whatsmeow'"""), {"aid": account_id, "uid": user.email or "anonymous"}, )).fetchone() - finally: - await db.close() if not row: return BridgeConnectModel(account_id=account_id, status="unknown") if row.sync_status == "live": diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/calls.py b/apps/services/gateway/gateway/routes/whatsapp/transport/calls.py index f5fad89cb..9a4202cb3 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/calls.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/calls.py @@ -27,7 +27,7 @@ from acb_auth import UserContext, get_current_user from acb_common import get_logger from fastapi import Depends, HTTPException, Request, Response -from gateway.routes.whatsapp.core import _get_db, router +from gateway.routes.whatsapp.core import _tenant_session, router from gateway.routes.whatsapp.transport.bridge import ( _bridge_headers, _bridge_url, @@ -96,15 +96,12 @@ async def _assert_owns_account(account_id: str, user: UserContext) -> None: to be enforced here.""" if not account_id: raise HTTPException(status_code=400, detail="account_id required") - db = await _get_db() - try: + async with _tenant_session() as db: row = (await db.execute( text("""SELECT sync_status FROM wa_accounts WHERE id = :aid AND user_id = :uid AND provider = 'whatsmeow'"""), {"aid": account_id, "uid": user.email or "anonymous"}, )).fetchone() - finally: - await db.close() if not row: raise HTTPException( status_code=404, diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/capture.py b/apps/services/gateway/gateway/routes/whatsapp/transport/capture.py index 2adda6a2c..3f43c326b 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/capture.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/capture.py @@ -16,7 +16,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.whatsapp.core import _get_db, router +from gateway.routes.whatsapp.core import _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -50,8 +50,7 @@ async def capture_task( ): """Capture a WhatsApp message as a GTD inbox item (idempotent per message).""" uid = user.email or "anonymous" - db = await _get_db() - try: + async with _tenant_session() as db: # Owner check THROUGH the account, and pull the fields we tag the origin # with in one query. msg = (await db.execute( @@ -108,7 +107,4 @@ async def capture_task( "notes": (msg.body_text or "")[:500] or None, "origin": json.dumps(origin)}, ) - await db.commit() return CaptureTaskResponse(item_id=item_id, title=title, created=True) - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/chats.py b/apps/services/gateway/gateway/routes/whatsapp/transport/chats.py index 9cd1e85c3..d6928c7a5 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/chats.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/chats.py @@ -11,7 +11,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, Query -from gateway.routes.whatsapp.core import WhatsAppChatModel, _get_db, router +from gateway.routes.whatsapp.core import WhatsAppChatModel, _tenant_session, router from sqlalchemy import text # The triage streams shown in the nav, in order. Status streams map to @@ -88,8 +88,7 @@ async def list_streams( user: UserContext = Depends(get_current_user), ): """Return the nav stream counts for the account(s) the user owns.""" - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = {"uid": user.email or "anonymous"} scope = "c.account_id IN (SELECT id FROM wa_accounts WHERE user_id = :uid" if account_id: @@ -116,8 +115,6 @@ async def list_streams( "all": int(row.all or 0), "snoozed": int(row.snoozed or 0), } - finally: - await db.close() @router.get("/chats", response_model=list[WhatsAppChatModel]) @@ -130,8 +127,7 @@ async def list_chats( user: UserContext = Depends(get_current_user), ): """List conversations, newest first, optionally scoped to a stream/category/label.""" - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = {"uid": user.email or "anonymous", "limit": limit} where = ["c.account_id IN (SELECT id FROM wa_accounts WHERE user_id = :uid"] if account_id: @@ -186,5 +182,3 @@ async def list_chats( _chat_model(r, labels_by_chat.get((str(r.account_id), r.wa_chat_id))) for r in rows ] - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/connect.py b/apps/services/gateway/gateway/routes/whatsapp/transport/connect.py index c0973ee78..5140b0930 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/connect.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/connect.py @@ -23,7 +23,7 @@ from acb_auth import UserContext, get_current_user from acb_common import get_logger from fastapi import Depends, HTTPException -from gateway.routes.whatsapp.core import _get_db, _instantiate_provider, router +from gateway.routes.whatsapp.core import _instantiate_provider, _tenant_session, router from pydantic import BaseModel _log = get_logger("gateway.whatsapp.connect") @@ -246,8 +246,7 @@ async def embedded_signup( display = ( req.display_name.strip() or profile.get("verified_name") or "WhatsApp") phone = profile.get("display_phone_number") or "" - db = await _get_db() - try: + async with _tenant_session() as db: row = await persist_account( db, user_id=user.email or "anonymous", phone_number=phone, phone_number_id=req.phone_number_id.strip(), @@ -255,10 +254,7 @@ async def embedded_signup( display_name=display, credentials=creds, webhook_verify_token=os.environ.get("WHATSAPP_VERIFY_TOKEN") or None, ) - await db.commit() acct = _account_model(row) - finally: - await db.close() return EmbeddedSignupResponse( account_id=acct.id, display_name=acct.display_name, phone_number=acct.phone_number, subscribed=subscribed) diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/context.py b/apps/services/gateway/gateway/routes/whatsapp/transport/context.py index 3ae8d6912..d519d5ce1 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/context.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/context.py @@ -18,7 +18,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.whatsapp.core import _get_db, router +from gateway.routes.whatsapp.core import _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -107,8 +107,7 @@ async def chat_context( ): """Resolve the company context for a conversation.""" uid = user.email or "anonymous" - db = await _get_db() - try: + async with _tenant_session() as db: chat = (await db.execute( text("""SELECT c.id, c.wa_chat_id, c.name, c.category, c.kind FROM wa_chats c @@ -196,5 +195,3 @@ async def chat_context( chat_id=chat_id, contact=contact, open_loops=open_loops, waiting_on=waiting_on, stats=stats, ) - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/labels.py b/apps/services/gateway/gateway/routes/whatsapp/transport/labels.py index 8a921f84c..694de8e74 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/labels.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/labels.py @@ -16,7 +16,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends -from gateway.routes.whatsapp.core import _get_db, router +from gateway.routes.whatsapp.core import _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -38,8 +38,7 @@ async def list_labels( ): """Return the active, non-deleted labels for the account(s) the user owns, each with the number of chats currently carrying it.""" - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = {"uid": user.email or "anonymous"} scope = "l.account_id IN (SELECT id FROM wa_accounts WHERE user_id = :uid" if account_id: @@ -71,5 +70,3 @@ async def list_labels( ) for r in rows ] - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/messages.py b/apps/services/gateway/gateway/routes/whatsapp/transport/messages.py index 300f916db..8e9a22ba5 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/messages.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/messages.py @@ -6,7 +6,12 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, Query -from gateway.routes.whatsapp.core import WhatsAppMessageModel, _get_db, assert_chat_owned, router +from gateway.routes.whatsapp.core import ( + WhatsAppMessageModel, + _tenant_session, + assert_chat_owned, + router, +) from sqlalchemy import text @@ -42,8 +47,7 @@ async def list_messages( user: UserContext = Depends(get_current_user), ): """Return a conversation's messages oldest-first (thread reading order).""" - db = await _get_db() - try: + async with _tenant_session() as db: await assert_chat_owned(db, chat_id, user.email or "anonymous") rows = (await db.execute( text("""SELECT id, chat_id, wa_message_id, direction, kind, sender, @@ -56,8 +60,6 @@ async def list_messages( {"cid": chat_id, "limit": limit}, )).fetchall() return [_message_model(r) for r in rows] - finally: - await db.close() # The tsvector expression — byte-for-byte identical to migration 102's @@ -88,8 +90,7 @@ async def search_messages( closeness only re-ranks. Requires ``whatsapp_semantic_search_enabled``; if the query can't be embedded (flag off / embed error), it falls through to lexical. """ - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = { "uid": user.email or "anonymous", "q": q, "limit": limit, } @@ -135,5 +136,3 @@ async def search_messages( params, )).fetchall() return [_message_model(r) for r in rows] - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/saved_replies.py b/apps/services/gateway/gateway/routes/whatsapp/transport/saved_replies.py index fbd243b20..4a3aac9d2 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/saved_replies.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/saved_replies.py @@ -15,7 +15,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.whatsapp.core import _get_db, assert_account_owned, router +from gateway.routes.whatsapp.core import _tenant_session, assert_account_owned, router from pydantic import BaseModel from sqlalchemy import text @@ -90,16 +90,13 @@ async def list_saved_replies( account_id: str, user: UserContext = Depends(get_current_user), ): """List an account's saved replies, in the founder's chosen order.""" - db = await _get_db() - try: + async with _tenant_session() as db: await assert_account_owned(db, account_id, user.email or "anonymous") rows = (await db.execute( text(_SELECT + " WHERE account_id = :aid ORDER BY sort_order, title"), {"aid": account_id}, )).fetchall() return [_model(r) for r in rows] - finally: - await db.close() @router.post("/saved-replies", response_model=SavedReplyModel, status_code=201) @@ -112,8 +109,7 @@ async def create_saved_reply( if not title or not body: raise HTTPException(status_code=422, detail="title and body are required") shortcut = normalize_shortcut(req.shortcut) - db = await _get_db() - try: + async with _tenant_session() as db: await assert_account_owned(db, req.account_id, user.email or "anonymous") try: row = (await db.execute( @@ -125,16 +121,14 @@ async def create_saved_reply( "body": body, "shortcut": shortcut, "sort": req.sort_order}, )).fetchone() except Exception as exc: # unique-shortcut collision, etc. - await db.rollback() + # Both arms re-raise, so the explicit rollback the old shape + # needed is now the wrapper's job (rollback on exception exit). if "uq_wa_saved_replies_shortcut" in str(exc): raise HTTPException( status_code=409, detail=f"shortcut {shortcut} is already in use") from exc raise - await db.commit() return _model(row) - finally: - await db.close() @router.patch("/saved-replies/{reply_id}", response_model=SavedReplyModel) @@ -143,8 +137,7 @@ async def update_saved_reply( user: UserContext = Depends(get_current_user), ): """Edit a saved reply (title / body / shortcut / order).""" - db = await _get_db() - try: + async with _tenant_session() as db: await _owned_reply_account(db, reply_id, user.email or "anonymous") fields: dict[str, Any] = {} if req.title is not None: @@ -172,19 +165,18 @@ async def update_saved_reply( params, ) except Exception as exc: - await db.rollback() + # Re-raised either way — the wrapper rolls back on exit. if "uq_wa_saved_replies_shortcut" in str(exc): raise HTTPException( status_code=409, detail="that shortcut is already in use") from exc raise - await db.commit() + # Same transaction as the UPDATE, which sees its own writes; the + # wrapper commits both together on clean exit. updated = (await db.execute( text(_SELECT + " WHERE id = :rid"), {"rid": reply_id}, )).fetchone() return _model(updated) - finally: - await db.close() @router.delete("/saved-replies/{reply_id}", status_code=204) @@ -192,12 +184,8 @@ async def delete_saved_reply( reply_id: str, user: UserContext = Depends(get_current_user), ): """Delete a saved reply.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _owned_reply_account(db, reply_id, user.email or "anonymous") await db.execute( text("DELETE FROM wa_saved_replies WHERE id = :rid"), {"rid": reply_id}, ) - await db.commit() - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/send.py b/apps/services/gateway/gateway/routes/whatsapp/transport/send.py index a0f828e2f..e26833dfa 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/send.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/send.py @@ -17,7 +17,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.whatsapp.core import _get_db, _provider_for_account, router +from gateway.routes.whatsapp.core import _provider_for_account, _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -66,8 +66,7 @@ async def send_message( user: UserContext = Depends(get_current_user), ): """Send a message to a chat, respecting the 24h window, and store the row.""" - db = await _get_db() - try: + async with _tenant_session() as db: chat = (await db.execute( text("""SELECT c.id, c.account_id, c.wa_chat_id, c.service_window_expires_at @@ -121,7 +120,4 @@ async def send_message( WHERE id = :cid"""), {"now": now, "cid": chat_id}, ) - await db.commit() return {"wa_message_id": wamid, "send_regime": regime} - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/snooze.py b/apps/services/gateway/gateway/routes/whatsapp/transport/snooze.py index 1150af038..ed90166cb 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/snooze.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/snooze.py @@ -18,7 +18,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException from gateway.routes.whatsapp.automation.replyzero import recompute_chat_status -from gateway.routes.whatsapp.core import _get_db, assert_chat_owned, router +from gateway.routes.whatsapp.core import _tenant_session, assert_chat_owned, router from pydantic import BaseModel from sqlalchemy import text @@ -71,8 +71,7 @@ async def snooze_chat( except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc - db = await _get_db() - try: + async with _tenant_session() as db: account_id = await assert_chat_owned(db, chat_id, user.email or "anonymous") # Ensure a status row exists (a never-classified chat has none yet), then # stamp the snooze onto it. @@ -85,12 +84,11 @@ async def snooze_chat( )).fetchone() if result is None: # No status row and no messages to classify — nothing to snooze. + # The 422 rolls back the recompute too, exactly as before (the old + # shape only committed after this check). raise HTTPException( status_code=422, detail="Nothing to snooze in this chat yet") - await db.commit() return SnoozeModel(chat_id=chat_id, snoozed_until=when.isoformat()) - finally: - await db.close() @router.post("/chats/{chat_id}/unsnooze", response_model=SnoozeModel) @@ -98,15 +96,11 @@ async def unsnooze_chat( chat_id: str, user: UserContext = Depends(get_current_user), ): """Wake a snoozed chat now — it returns to the queue with its real status.""" - db = await _get_db() - try: + async with _tenant_session() as db: account_id = await assert_chat_owned(db, chat_id, user.email or "anonymous") await db.execute( text("""UPDATE wa_chat_status SET snoozed_until = NULL WHERE account_id = :aid AND chat_id = :cid"""), {"aid": account_id, "cid": chat_id}, ) - await db.commit() return SnoozeModel(chat_id=chat_id, snoozed_until=None) - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/templates.py b/apps/services/gateway/gateway/routes/whatsapp/transport/templates.py index aff279dfc..4748e9dea 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/templates.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/templates.py @@ -14,7 +14,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.whatsapp.core import _get_db, assert_account_owned, router +from gateway.routes.whatsapp.core import _tenant_session, assert_account_owned, router from pydantic import BaseModel from sqlalchemy import text @@ -116,8 +116,7 @@ async def list_templates( ): """List an account's templates. ``approved_only`` filters to what can send right now (what the composer's `/` picker offers when the window is closed).""" - db = await _get_db() - try: + async with _tenant_session() as db: await assert_account_owned(db, account_id, user.email or "anonymous") where = "account_id = :aid" params: dict[str, Any] = {"aid": account_id} @@ -131,8 +130,6 @@ async def list_templates( params, )).fetchall() return [_model(r) for r in rows] - finally: - await db.close() @router.post("/accounts/{account_id}/templates", response_model=WhatsAppTemplateModel, @@ -145,8 +142,7 @@ async def create_template( """Register/mirror a single template (upsert on name+language).""" _validate(req) import json - db = await _get_db() - try: + async with _tenant_session() as db: await assert_account_owned(db, account_id, user.email or "anonymous") row = (await db.execute( text("""INSERT INTO wa_templates @@ -169,10 +165,7 @@ async def create_template( "vars": json.dumps(req.variables), "status": req.meta_status, "cost": req.cost_hint}, )).fetchone() - await db.commit() return _model(row) - finally: - await db.close() @router.post("/accounts/{account_id}/templates/bootstrap", @@ -184,8 +177,7 @@ async def bootstrap_templates( """Seed the default template set for an account (idempotent — existing names are left untouched). Called once after connect so the rules have templates.""" import json - db = await _get_db() - try: + async with _tenant_session() as db: await assert_account_owned(db, account_id, user.email or "anonymous") for t in default_templates(): await db.execute( @@ -200,7 +192,8 @@ async def bootstrap_templates( "lang": t["language"], "cat": t["category"], "body": t["body"], "vars": json.dumps(t["variables"]), "cost": t["cost_hint"]}, ) - await db.commit() + # Same transaction as the seeding INSERTs — it reads its own writes, + # and the wrapper commits everything together on clean exit. rows = (await db.execute( text("""SELECT id, name, language, category, body, variables, meta_status, cost_hint @@ -209,5 +202,3 @@ async def bootstrap_templates( {"aid": account_id}, )).fetchall() return [_model(r) for r in rows] - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/whatsapp/transport/webhook.py b/apps/services/gateway/gateway/routes/whatsapp/transport/webhook.py index be092a1cb..c71fc11ee 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/transport/webhook.py +++ b/apps/services/gateway/gateway/routes/whatsapp/transport/webhook.py @@ -58,6 +58,9 @@ async def verify_webhook(request: Request): ok_token = configured and token == configured if not ok_token: # Fall back to matching any stored per-account verify token. + # H4/H6: service-identity route — Meta calls this with no session and + # no ambient tenant; the lookup is deliberately cross-account, and a + # tenant would have to come from the matched wa_accounts row. db = await _get_db() try: row = (await db.execute( @@ -112,6 +115,10 @@ async def receive_webhook(request: Request): # A status-only or empty batch with no metadata — ack so Meta stops. return Response(status_code=200, content="ok") + # H4/H6: service-identity route — Meta authenticates by webhook signature, + # no member session, so `system:internal`/anonymous binds NO tenant and + # `tenant_session()` here would 500 every inbound batch. Derive the tenant + # from the wa_accounts row resolved by phone_number_id. db = await _get_db() try: account_id = await _resolve_account_id(db, result.phone_number_id) diff --git a/tests/unit/test_db_engine_seam.py b/tests/unit/test_db_engine_seam.py index 77e51ba35..e43c625a9 100644 --- a/tests/unit/test_db_engine_seam.py +++ b/tests/unit/test_db_engine_seam.py @@ -309,9 +309,29 @@ def test_acb_auth_shares_the_pool() -> None: "tenant through the event payload; ambient inheritance is forbidden", } +#: routes/whatsapp's allowed remainder (H2 slice, 2026-08-10): file → site +#: count. This package is ingestion-heavy, so unlike projects it cannot pin at +#: zero — these are service-identity entry points (Meta webhook / the Go +#: bridge's shared-secret pushes: `system:internal` and signature-authed +#: callers bind NO tenant, so `tenant_session()` there would 500 every inbound +#: batch) and background consumers (post-sync hooks, the enrichment loop, the +#: Action Broker broadcast handler). H4/H6 owns threading an explicit tenant +#: through each; every site carries the matching `# H4`/`# H4/H6` marker. +H2_WHATSAPP_EXEMPT_SITES: dict[str, int] = { + "apps/services/gateway/gateway/routes/whatsapp/transport/webhook.py": 2, + "apps/services/gateway/gateway/routes/whatsapp/transport/bridge.py": 5, + "apps/services/gateway/gateway/routes/whatsapp/scheduler.py": 2, + "apps/services/gateway/gateway/routes/whatsapp/automation/intent.py": 1, + "apps/services/gateway/gateway/routes/whatsapp/automation/replyzero.py": 1, + "apps/services/gateway/gateway/routes/whatsapp/automation/transcription.py": 1, + "apps/services/gateway/gateway/routes/whatsapp/automation/groups.py": 1, + "apps/services/gateway/gateway/routes/whatsapp/automation/outbound.py": 1, +} + #: The unconverted remainder OUTSIDE routes/projects at the time the Projects #: slice landed (2026-08-10). Lower it as packages convert; never raise it. -H2_BASELINE_ELSEWHERE = 494 +#: 494 → 456: routes/whatsapp's 38 request-handler sites (2026-08-10). +H2_BASELINE_ELSEWHERE = 456 def _get_db_sites() -> dict[str, int]: @@ -342,6 +362,27 @@ def test_routes_projects_is_converted_and_stays_converted() -> None: ) +def test_routes_whatsapp_holds_at_its_named_remainder() -> None: + """The WhatsApp package's unbound sites are EXACTLY the named exemptions. + + Two failure modes, both caught: a new `get_db()` handler (would silently + read nothing under RLS — convert it to `_tenant_session`), and a site + LEAVING the list (progress — bank it by shrinking the dict, so the + remainder can only ratchet down). + """ + sites = { + f: n for f, n in _get_db_sites().items() + if f.startswith("apps/services/gateway/gateway/routes/whatsapp/") + } + assert sites == H2_WHATSAPP_EXEMPT_SITES, ( + "routes/whatsapp unbound get_db() sites drifted from the named " + f"remainder.\n measured: {sites}\n pinned: " + f"{H2_WHATSAPP_EXEMPT_SITES}\nA NEW site must use `async with " + "_tenant_session() as db:` (core.py); a RETIRED site must leave " + "H2_WHATSAPP_EXEMPT_SITES in this test." + ) + + def test_get_db_sites_elsewhere_only_ratchet_down() -> None: total = sum( n for f, n in _get_db_sites().items() diff --git a/tests/unit/test_whatsapp_calls.py b/tests/unit/test_whatsapp_calls.py index fc3c333ba..642e9af93 100644 --- a/tests/unit/test_whatsapp_calls.py +++ b/tests/unit/test_whatsapp_calls.py @@ -34,13 +34,13 @@ class _FakeDB: def __init__(self, row): self._row = row - self.closed = False + self.committed = 0 async def execute(self, *_a, **_kw): return _Result(self._row) - async def close(self): - self.closed = True + async def commit(self): + self.committed += 1 class _User: @@ -49,12 +49,23 @@ def __init__(self, email: str = "someone@example.com"): def _patch_db(monkeypatch, row) -> _FakeDB: + """Point the module's ``_tenant_session`` seam at a fake (H2). + + Mirrors ``_projects_fakes.bind_db``: an ``asynccontextmanager`` that + commits on clean exit — so "the guard read one transaction" stays an + observable fact — and commits nothing when the block raises, just as the + real wrapper rolls back. + """ + from contextlib import asynccontextmanager + db = _FakeDB(row) - async def _get_db(): - return db + @asynccontextmanager + async def _tenant_session(organization_id=None): + yield db + await db.commit() - monkeypatch.setattr(calls, "_get_db", _get_db) + monkeypatch.setattr(calls, "_tenant_session", _tenant_session) return db @@ -88,10 +99,12 @@ async def test_account_still_pairing_is_409(monkeypatch) -> None: assert "pairing" in exc.value.detail.lower() -async def test_live_account_passes_and_closes_db(monkeypatch) -> None: +async def test_live_account_passes_and_releases_session(monkeypatch) -> None: db = _patch_db(monkeypatch, _Row("live")) await calls._assert_owns_account("acct", _User()) - assert db.closed is True + # Clean exit of the tenant-session block — the wrapper committed the + # (read-only) transaction, i.e. the session was released properly. + assert db.committed == 1 async def test_missing_account_id_is_400(monkeypatch) -> None: diff --git a/tests/unit/test_whatsapp_connect.py b/tests/unit/test_whatsapp_connect.py index f12e26b09..0a226b3c1 100644 --- a/tests/unit/test_whatsapp_connect.py +++ b/tests/unit/test_whatsapp_connect.py @@ -152,11 +152,13 @@ async def test_embedded_signup_400_when_unconfigured(monkeypatch) -> None: class _FakeDB: - async def commit(self): - pass + """The H2 seam's shape: the wrapper (not the handler) commits on exit.""" + + def __init__(self): + self.committed = 0 - async def close(self): - pass + async def commit(self): + self.committed += 1 async def test_embedded_signup_happy_path(monkeypatch) -> None: @@ -173,15 +175,23 @@ async def _exchange(code, app_id, app_secret, gv): async def _subscribe(waba_id, token, gv): subscribed_calls.append(waba_id) - async def _get_db(): - return _FakeDB() + from contextlib import asynccontextmanager + + db = _FakeDB() + + @asynccontextmanager + async def _tenant_session(organization_id=None): + # Mirrors `_projects_fakes.bind_db`: commit on clean exit, so the + # one-transaction contract stays observable. + yield db + await db.commit() async def _persist(db, **kw): return "ROW" monkeypatch.setattr(connect, "exchange_code_for_token", _exchange) monkeypatch.setattr(connect, "subscribe_app_to_waba", _subscribe) - monkeypatch.setattr(connect, "_get_db", _get_db) + monkeypatch.setattr(connect, "_tenant_session", _tenant_session) monkeypatch.setattr( connect, "_instantiate_provider", lambda name, creds: _FakeProvider(profile={ @@ -202,3 +212,5 @@ async def _persist(db, **kw): assert out.account_id == "acc-1" assert out.subscribed is True assert subscribed_calls == ["waba-1"] + # One transaction, committed by the tenant-session wrapper on clean exit. + assert db.committed == 1 From 16a526b7001ad3169afb04179d9a226ef0da16b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 07:09:16 +0000 Subject: [PATCH 03/10] =?UTF-8?q?feat(tenancy):=20H2=20=E2=80=94=20routes/?= =?UTF-8?q?email=20converted=20to=20tenant=5Fsession=20(slice)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The largest remaining H2 slice: 98 of routes/email's 125 get_db() sites are now `async with _tenant_session() as db:` — the per-package alias IS acb_common.db.tenant_session (email/core.py re-exports it beside the thin `_get_db` wrapper the seam test already exempts; notes/dispatch and the 27 B/C-class sites still import that wrapper). Every site was classified before conversion: - A (converted, 98): member-identity request handlers plus helpers called only from them (chat context builder, retry_failed_executions, restore_provider_labels) — the central binding has bound the tenant. - B (left, 2): service-identity routes — the OAuth provider callback (trust = HMAC-signed state) and the Microsoft Graph webhook. Marked with the row each would derive its tenant from (app_user via the signed state's user; email_accounts via the subscription). - C (left, 25): scheduler post-sync hooks, BackgroundTask jobs, create_task paths (_compose_assist_run's streaming leg), and helpers with mixed route+background callers (sweep_uncategorized, apply_thread_status_correction, _maybe_send_follow_up_reminders). Each carries an `# H4:` marker naming why. Mid-block commits removed per the runbook (the wrapper commits on clean exit; a mid-block commit ends the transaction and drops the GUC). Four shared helpers that committed on a caller-passed session were the real traps and are restructured honestly: - core.hydrate_message_body no longer commits (cache write; callers own the boundary), - senders._apply_newsletter_status and messages._hydrate_attachments and contacts._remember_contact lose their commits (only converted callers, and BackgroundTasks run post-response, i.e. after the exit commit), - drafting._store_ai_draft grows commit=False for its converted caller while background callers keep committing. rule_policies keeps its defensive rollback (documented: nothing touches the DB after it, so the dropped GUC is moot). Tests: tests/unit/_email_fakes.py::bind_db mirrors the seam shape (commit-on-clean-exit, entry counter replacing get_db.assert_not_awaited); ~90 patch sites across 27 email test files re-pointed; the reclassify fixture doubles BOTH seams (its job stays on get_db until H4); the download-attachment "commits so rotated creds land" pin now asserts the tenant seam owns that boundary. H2_BASELINE_ELSEWHERE banked 494 → 396. Verification: all 96 tests/unit/test_email_*.py files + seam/tenancy pins green (1167 tests); ruff F821,F601,F602,F502,F7,B006 clean. NOT yet smoked against live Postgres — the runbook asks one live smoke per package; cheapest converted read is GET /email/senders/categories. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../routes/email/automation/analytics.py | 7 +- .../routes/email/automation/assistant.py | 59 +- .../gateway/routes/email/automation/chat.py | 165 +++-- .../routes/email/automation/cleanup.py | 134 ++--- .../routes/email/automation/drafting.py | 52 +- .../routes/email/automation/followups.py | 8 +- .../routes/email/automation/replyzero.py | 33 +- .../gateway/routes/email/automation/rules.py | 93 +-- .../gateway/routes/email/automation/runner.py | 76 +-- .../routes/email/automation/senders.py | 67 +-- .../routes/email/automation/voice_profile.py | 42 +- .../gateway/gateway/routes/email/core.py | 18 +- .../gateway/gateway/routes/email/digest.py | 14 +- .../gateway/routes/email/scheduler_hooks.py | 4 + .../routes/email/transport/accounts.py | 31 +- .../routes/email/transport/attachments.py | 173 +++--- .../routes/email/transport/contacts.py | 129 ++-- .../gateway/routes/email/transport/folders.py | 566 +++++++++--------- .../routes/email/transport/messages.py | 156 ++--- .../gateway/routes/email/transport/oauth.py | 5 + .../gateway/routes/email/transport/search.py | 7 +- .../gateway/routes/email/transport/send.py | 8 +- .../gateway/routes/email/transport/sync.py | 18 +- tests/unit/_email_fakes.py | 40 ++ tests/unit/test_db_engine_seam.py | 6 +- tests/unit/test_email_analytics.py | 3 +- tests/unit/test_email_attachment_download.py | 11 +- tests/unit/test_email_bulk_apply.py | 15 +- tests/unit/test_email_cleanup_backfill.py | 5 +- tests/unit/test_email_cleanup_sweep.py | 7 +- tests/unit/test_email_contact_card.py | 5 +- .../unit/test_email_conversation_collapse.py | 3 +- tests/unit/test_email_facets.py | 17 +- tests/unit/test_email_fix_feedback.py | 5 +- tests/unit/test_email_knowledge.py | 5 +- tests/unit/test_email_message_timeline.py | 7 +- tests/unit/test_email_messages_filter.py | 11 +- tests/unit/test_email_pattern_approval.py | 5 +- tests/unit/test_email_presets.py | 11 +- .../test_email_process_past_cost_guard.py | 13 +- .../unit/test_email_process_past_progress.py | 5 +- tests/unit/test_email_reclassify_resumable.py | 14 +- .../test_email_retry_and_uncategorized.py | 3 +- tests/unit/test_email_rules_admin.py | 5 +- tests/unit/test_email_search_scope.py | 7 +- tests/unit/test_email_snooze.py | 11 +- tests/unit/test_email_thread_resolve.py | 3 +- tests/unit/test_email_two_way_sync.py | 7 +- tests/unit/test_email_unsubscribe.py | 5 +- 49 files changed, 968 insertions(+), 1126 deletions(-) create mode 100644 tests/unit/_email_fakes.py diff --git a/apps/services/gateway/gateway/routes/email/automation/analytics.py b/apps/services/gateway/gateway/routes/email/automation/analytics.py index 656a37d04..617bd2ceb 100644 --- a/apps/services/gateway/gateway/routes/email/automation/analytics.py +++ b/apps/services/gateway/gateway/routes/email/automation/analytics.py @@ -26,7 +26,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, Query from gateway.routes.email.automation.senders import DISPOSED_FOLDERS -from gateway.routes.email.core import _account_scope, _get_db, router +from gateway.routes.email.core import _account_scope, _tenant_session, router from sqlalchemy import text # Mail that was never really "received into" the mailbox, or has already been @@ -60,8 +60,7 @@ async def analytics_overview( headline figure carries its ``*_prev`` counterpart from the window of equal length immediately before it — a number with no trend is not actionable. """ - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = {"uid": user.email or "anonymous", "days": days} scope = _account_scope(account_id, params) # _account_scope names the table `em`; these queries alias it `m`. @@ -149,8 +148,6 @@ async def analytics_overview( # is healthy; non-zero means conversations were re-damaged. "data_health": {"damaged_threads": damaged_threads}, } - finally: - await db.close() async def _responsiveness(db: Any, scope: str, params: dict[str, Any]) -> dict: diff --git a/apps/services/gateway/gateway/routes/email/automation/assistant.py b/apps/services/gateway/gateway/routes/email/automation/assistant.py index 0d05a1bfa..94e3d4fc1 100644 --- a/apps/services/gateway/gateway/routes/email/automation/assistant.py +++ b/apps/services/gateway/gateway/routes/email/automation/assistant.py @@ -14,7 +14,7 @@ from fastapi import Depends, HTTPException, Query, status from gateway.routes.email.core import ( _assert_account_owner, - _get_db, + _tenant_session, _log, email_memory_scope, router, @@ -246,8 +246,7 @@ async def get_assistant_settings( user: UserContext = Depends(get_current_user), ): """Get the assistant's About/signature/auto-run settings for an account.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") row = (await db.execute(text( """SELECT about, signature, auto_run, cold_email_blocker, @@ -370,8 +369,6 @@ async def get_assistant_settings( # Read-only: the account's own domain, always treated as internal. "own_domain": own_domain, } - finally: - await db.close() @router.put("/assistant/settings") @@ -380,8 +377,7 @@ async def put_assistant_settings( user: UserContext = Depends(get_current_user), ): """Upsert the assistant settings for an account.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") # `follow_up_awaiting_days` is canonical; accept the legacy `follow_up_days` # as a fallback so older clients keep working. @@ -456,7 +452,6 @@ async def put_assistant_settings( "mre": req.multi_rule_execution, "sdp": req.sensitive_data_protection, "orgd": org_domains}) - await db.commit() # inbox-zero parity: the "Auto draft replies" toggle adds/removes the # DRAFT_EMAIL action on the "Reply" rule (like inbox-zero's # enableDraftRepliesAction), so to-reply mail is auto-drafted when on. @@ -464,9 +459,9 @@ async def put_assistant_settings( from gateway.routes.email.automation.rules import ( # noqa: PLC0415 sync_draft_reply_action, ) - if await sync_draft_reply_action(db, req.account_id, req.draft_replies): - await db.commit() - except Exception as exc: # noqa: BLE001 — settings already saved; best-effort + # The wrapper's exit commit lands any action change this makes. + await sync_draft_reply_action(db, req.account_id, req.draft_replies) + except Exception as exc: # noqa: BLE001 — best-effort; the settings save must not fail over it _log.warning("email.draft_replies_sync_failed", account_id=req.account_id, error=str(exc)[:200]) acc_row = (await db.execute(text( @@ -502,8 +497,6 @@ async def put_assistant_settings( "org_domains": org_domains, "own_domain": own_domain, } - finally: - await db.close() class KnowledgeModel(BaseModel): @@ -519,8 +512,7 @@ async def list_knowledge( user: UserContext = Depends(get_current_user), ): """List the account's knowledge-base entries (used when drafting replies).""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") # Suggested entries (voice-profile candidates awaiting review) first, # so they surface for approval instead of sinking below the fold. @@ -538,8 +530,6 @@ async def list_knowledge( "updated_at": r.updated_at.isoformat() if r.updated_at else None} for r in rows ]} - finally: - await db.close() @router.post("/knowledge") @@ -548,8 +538,7 @@ async def create_knowledge( user: UserContext = Depends(get_current_user), ): """Add (or overwrite by title) a knowledge-base entry.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") kid = str(uuid4()) # A user-authored save is manual + active — including when it lands on @@ -564,11 +553,8 @@ async def create_knowledge( status = 'active', updated_at = now()""" ), {"id": kid, "aid": req.account_id, "title": req.title, "content": req.content}) - await db.commit() return {"id": kid, "account_id": req.account_id, "title": req.title, "content": req.content} - finally: - await db.close() @router.patch("/knowledge/{kid}") @@ -578,8 +564,7 @@ async def update_knowledge( user: UserContext = Depends(get_current_user), ): """Edit a knowledge-base entry.""" - db = await _get_db() - try: + async with _tenant_session() as db: owner = (await db.execute(text( """SELECT ek.id FROM email_knowledge ek JOIN email_accounts ea ON ek.account_id = ea.id @@ -591,11 +576,8 @@ async def update_knowledge( """UPDATE email_knowledge SET title = :title, content = :content, updated_at = now() WHERE id = :id""" ), {"id": kid, "title": req.title, "content": req.content}) - await db.commit() return {"id": kid, "account_id": req.account_id, "title": req.title, "content": req.content} - finally: - await db.close() @router.delete("/knowledge/{kid}", status_code=status.HTTP_204_NO_CONTENT) @@ -604,18 +586,14 @@ async def delete_knowledge( user: UserContext = Depends(get_current_user), ): """Delete a knowledge-base entry.""" - db = await _get_db() - try: + async with _tenant_session() as db: res = await db.execute(text( """DELETE FROM email_knowledge ek USING email_accounts ea WHERE ek.id = :id AND ek.account_id = ea.id AND ea.user_id = :uid""" ), {"id": kid, "uid": user.email or "anonymous"}) - await db.commit() if res.rowcount == 0: raise HTTPException(status_code=404, detail="Not found") - finally: - await db.close() async def _llm_writing_style(samples: list[str]) -> str: @@ -652,8 +630,7 @@ async def generate_writing_style( user: UserContext = Depends(get_current_user), ): """Derive a writing-style guide from the account's recent sent mail + save it.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") rows = (await db.execute(text( """SELECT body_text FROM email_messages @@ -683,7 +660,6 @@ async def generate_writing_style( ON CONFLICT (account_id) DO UPDATE SET writing_style = EXCLUDED.writing_style, updated_at = now()""" ), {"aid": account_id, "ws": style}) - await db.commit() # Index the derived style into Mem0 — keyed PER ACCOUNT so a user's other # mailboxes don't inherit this inbox's writing voice (see # email_memory_scope). The drafter reads it back under the same scope. @@ -698,8 +674,6 @@ async def generate_writing_style( except Exception: # noqa: BLE001 pass return {"writing_style": style} - finally: - await db.close() @router.get("/learned-patterns") @@ -708,8 +682,7 @@ async def list_learned_patterns( user: UserContext = Depends(get_current_user), ): """Preferences the assistant has learned from the user's draft edits.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") rows = (await db.execute(text( """SELECT id, pattern, weight, kind, scope_type, scope_value @@ -724,8 +697,6 @@ async def list_learned_patterns( "scope_value": getattr(r, "scope_value", None)} for r in rows ]} - finally: - await db.close() @router.delete("/learned-patterns/{pid}", status_code=status.HTTP_204_NO_CONTENT) @@ -734,15 +705,11 @@ async def delete_learned_pattern( user: UserContext = Depends(get_current_user), ): """Forget a learned preference.""" - db = await _get_db() - try: + async with _tenant_session() as db: res = await db.execute(text( """DELETE FROM email_learned_patterns lp USING email_accounts ea WHERE lp.id = :id AND lp.account_id = ea.id AND ea.user_id = :uid""" ), {"id": pid, "uid": user.email or "anonymous"}) - await db.commit() if res.rowcount == 0: raise HTTPException(status_code=404, detail="Not found") - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/email/automation/chat.py b/apps/services/gateway/gateway/routes/email/automation/chat.py index 296d2ccfb..bdcbf52e9 100644 --- a/apps/services/gateway/gateway/routes/email/automation/chat.py +++ b/apps/services/gateway/gateway/routes/email/automation/chat.py @@ -16,7 +16,7 @@ from fastapi import BackgroundTasks, Depends, HTTPException from fastapi.responses import StreamingResponse from gateway.routes.email.core import ( - _get_db, + _tenant_session, _log, router, ) @@ -48,90 +48,88 @@ async def _build_chat_context( Best-effort — never raises.""" parts: list[str] = [] resolved = account_id - db = await _get_db() try: - accounts = (await db.execute(text( - "SELECT id, email_address FROM email_accounts WHERE user_id = :uid " - "ORDER BY created_at" - ), {"uid": user_id})).fetchall() - acc_map = {str(a.id): a.email_address for a in accounts} - if account_id and account_id in acc_map: - resolved = account_id - elif len(accounts) == 1: - resolved = str(accounts[0].id) - - # The user's OWN addresses — so the model never reports the user as a - # sender / "someone who emails you"; mail from these was sent BY the user. - own_addrs = ", ".join(sorted(acc_map.values())) - if own_addrs: - parts.append( - "## You (the account owner)\n" - f"You are acting on behalf of the user, whose own email " - f"address(es) are: {own_addrs}. NEVER report any of these as a " - "sender, a top sender, or someone who emails the user — messages " - "from them were sent BY the user.") - - if resolved and resolved in acc_map: - parts.append( - "## Email account\n" - f"Act on account id={resolved} ({acc_map[resolved]}). Pass this " - "account_id to tools unless the user names another account.") - elif len(accounts) > 1: - listing = "; ".join( - f"{a.email_address} (id={a.id})" for a in accounts) - parts.append( - "## Email accounts\nThe user has several accounts: " + listing - + ". Ask which one before account-scoped actions.") - - if resolved and is_first_turn: - tot = (await db.execute(text( - "SELECT count(*) AS total, " - "count(*) FILTER (WHERE is_read = false) AS unread " - "FROM email_messages WHERE account_id = :aid " - "AND LOWER(folder) = 'inbox'" - ), {"aid": resolved})).fetchone() - nr = (await db.execute(text( - "SELECT count(*) AS c FROM email_thread_status " - "WHERE account_id = :aid AND status = 'NEEDS_REPLY'" - ), {"aid": resolved})).fetchone() - cats = (await db.execute(text( - "SELECT category, count(*) AS c FROM email_senders " - "WHERE account_id = :aid AND category IS NOT NULL " - "GROUP BY category ORDER BY c DESC LIMIT 6" - ), {"aid": resolved})).fetchall() - cat_str = ", ".join( - f"{r.category}: {r.c}" for r in cats) or "not categorized yet" - parts.append( - "## Inbox snapshot\n" - f"- Inbox: {tot.total if tot else 0} messages, " - f"{tot.unread if tot else 0} unread\n" - f"- Needs reply (Reply Zero): {nr.c if nr else 0}\n" - f"- Sender categories: {cat_str}\n" - "To answer questions about the WHOLE inbox use query_inbox " - "(filter by date/category/sender/read-state), " - "find_priority(kind=important|needs_reply|urgent), or " - "get_account_overview; read_email for one email's full content.") - - if email_context_id: - row = (await db.execute(text( - "SELECT em.subject, em.body_text, em.from_address, em.received_at " - "FROM email_messages em " - "JOIN email_accounts ea ON em.account_id = ea.id " - "WHERE em.id = :id AND ea.user_id = :uid" - ), {"id": email_context_id, "uid": user_id})).fetchone() - if row: - frm = row.from_address if isinstance(row.from_address, dict) \ - else json.loads(row.from_address or "{}") + async with _tenant_session() as db: + accounts = (await db.execute(text( + "SELECT id, email_address FROM email_accounts WHERE user_id = :uid " + "ORDER BY created_at" + ), {"uid": user_id})).fetchall() + acc_map = {str(a.id): a.email_address for a in accounts} + if account_id and account_id in acc_map: + resolved = account_id + elif len(accounts) == 1: + resolved = str(accounts[0].id) + + # The user's OWN addresses — so the model never reports the user as a + # sender / "someone who emails you"; mail from these was sent BY the user. + own_addrs = ", ".join(sorted(acc_map.values())) + if own_addrs: parts.append( - f"## Email open in the reader (id={email_context_id})\n" - f"From: {frm.get('name') or ''} <{frm.get('email') or ''}>\n" - f"Subject: {row.subject or ''}\n" - f"Date: {row.received_at}\n\n" - f"{(row.body_text or '')[:5000]}") + "## You (the account owner)\n" + f"You are acting on behalf of the user, whose own email " + f"address(es) are: {own_addrs}. NEVER report any of these as a " + "sender, a top sender, or someone who emails the user — messages " + "from them were sent BY the user.") + + if resolved and resolved in acc_map: + parts.append( + "## Email account\n" + f"Act on account id={resolved} ({acc_map[resolved]}). Pass this " + "account_id to tools unless the user names another account.") + elif len(accounts) > 1: + listing = "; ".join( + f"{a.email_address} (id={a.id})" for a in accounts) + parts.append( + "## Email accounts\nThe user has several accounts: " + listing + + ". Ask which one before account-scoped actions.") + + if resolved and is_first_turn: + tot = (await db.execute(text( + "SELECT count(*) AS total, " + "count(*) FILTER (WHERE is_read = false) AS unread " + "FROM email_messages WHERE account_id = :aid " + "AND LOWER(folder) = 'inbox'" + ), {"aid": resolved})).fetchone() + nr = (await db.execute(text( + "SELECT count(*) AS c FROM email_thread_status " + "WHERE account_id = :aid AND status = 'NEEDS_REPLY'" + ), {"aid": resolved})).fetchone() + cats = (await db.execute(text( + "SELECT category, count(*) AS c FROM email_senders " + "WHERE account_id = :aid AND category IS NOT NULL " + "GROUP BY category ORDER BY c DESC LIMIT 6" + ), {"aid": resolved})).fetchall() + cat_str = ", ".join( + f"{r.category}: {r.c}" for r in cats) or "not categorized yet" + parts.append( + "## Inbox snapshot\n" + f"- Inbox: {tot.total if tot else 0} messages, " + f"{tot.unread if tot else 0} unread\n" + f"- Needs reply (Reply Zero): {nr.c if nr else 0}\n" + f"- Sender categories: {cat_str}\n" + "To answer questions about the WHOLE inbox use query_inbox " + "(filter by date/category/sender/read-state), " + "find_priority(kind=important|needs_reply|urgent), or " + "get_account_overview; read_email for one email's full content.") + + if email_context_id: + row = (await db.execute(text( + "SELECT em.subject, em.body_text, em.from_address, em.received_at " + "FROM email_messages em " + "JOIN email_accounts ea ON em.account_id = ea.id " + "WHERE em.id = :id AND ea.user_id = :uid" + ), {"id": email_context_id, "uid": user_id})).fetchone() + if row: + frm = row.from_address if isinstance(row.from_address, dict) \ + else json.loads(row.from_address or "{}") + parts.append( + f"## Email open in the reader (id={email_context_id})\n" + f"From: {frm.get('name') or ''} <{frm.get('email') or ''}>\n" + f"Subject: {row.subject or ''}\n" + f"Date: {row.received_at}\n\n" + f"{(row.body_text or '')[:5000]}") except Exception as exc: # noqa: BLE001 _log.warning("email.chat_context_failed", error=str(exc)[:160]) - finally: - await db.close() return resolved, parts @@ -205,11 +203,8 @@ async def ai_chat( if req.account_id: try: from gateway.routes.email.automation.assistant import _account_models # noqa: PLC0415 - _mdb = await _get_db() - try: + async with _tenant_session() as _mdb: chat_model = (await _account_models(_mdb, req.account_id))["chat"] - finally: - await _mdb.close() except Exception: # noqa: BLE001 pass diff --git a/apps/services/gateway/gateway/routes/email/automation/cleanup.py b/apps/services/gateway/gateway/routes/email/automation/cleanup.py index 50cf2cb59..d737cb3e9 100644 --- a/apps/services/gateway/gateway/routes/email/automation/cleanup.py +++ b/apps/services/gateway/gateway/routes/email/automation/cleanup.py @@ -56,6 +56,7 @@ from gateway.routes.email.core import ( _assert_account_owner, _get_db, + _tenant_session, _instantiate_provider, _log, _persist_rotated_creds, @@ -524,6 +525,9 @@ async def sweep_uncategorized( # which means the mailbox actually ran dry. "apply_capped": False, } + # H4: mixed callers — sweep_uncategorized also runs from the scheduler's + # post-sync sweep and the backfill BackgroundTask, where no ambient + # tenant exists; needs an explicit tenant from the account row. db = await _get_db() provider = None store = None @@ -716,68 +720,65 @@ async def restore_provider_labels(account_id: str) -> dict[str, Any]: only ever existed locally isn't destroyed by the very job meant to repair. """ out: dict[str, Any] = {"messages": 0, "labels": 0, "updated": 0} - db = await _get_db() try: - acc = (await db.execute(text( - "SELECT provider, credentials_encrypted FROM email_accounts " - "WHERE id = :id" - ), {"id": account_id})).fetchone() - if not acc: - return out - import json # noqa: PLC0415 - - from acb_llm.key_store import get_key_store # noqa: PLC0415 - store = get_key_store() - creds = json.loads(store.decrypt(acc.credentials_encrypted)) - provider = _instantiate_provider(acc.provider, creds) - # Say so BEFORE authenticating: on a provider that can't list messages - # per label, an empty result is indistinguishable from "your mailbox - # has no labels". Reporting the latter tells a user with thousands of - # labelled messages that they have none — the opposite of the truth - # this repair path exists to restore. - if not getattr(provider, "SUPPORTS_LABEL_READBACK", False): - out["error"] = "unsupported" - out["provider"] = acc.provider - return out - if not await provider.authenticate(): - out["error"] = "auth-failed" + async with _tenant_session() as db: + acc = (await db.execute(text( + "SELECT provider, credentials_encrypted FROM email_accounts " + "WHERE id = :id" + ), {"id": account_id})).fetchone() + if not acc: + return out + import json # noqa: PLC0415 + + from acb_llm.key_store import get_key_store # noqa: PLC0415 + store = get_key_store() + creds = json.loads(store.decrypt(acc.credentials_encrypted)) + provider = _instantiate_provider(acc.provider, creds) + # Say so BEFORE authenticating: on a provider that can't list messages + # per label, an empty result is indistinguishable from "your mailbox + # has no labels". Reporting the latter tells a user with thousands of + # labelled messages that they have none — the opposite of the truth + # this repair path exists to restore. + if not getattr(provider, "SUPPORTS_LABEL_READBACK", False): + out["error"] = "unsupported" + out["provider"] = acc.provider + return out + if not await provider.authenticate(): + out["error"] = "auth-failed" + return out + + assignments = await provider.fetch_label_assignments() + out["messages"] = len(assignments) + out["labels"] = len({lbl for v in assignments.values() for lbl in v}) + # Group by label-set and write one statement per DISTINCT set rather + # than one per message. A mailbox has a handful of label combinations + # and tens of thousands of messages, so this is ~20 round-trips instead + # of 40,000 — the difference between a route that returns and one that + # times out on exactly the large mailbox that needs it most. + # Same canonicalisation the sync ingest applies (renamed labels map to + # their new names, the "Uncategorized" indicator is dropped) — this + # repair writes raw provider label sets, so without it a restore would + # resurrect exactly the stale names ingest exists to retire. + from email_ingestion.persist import _canon_categories # noqa: PLC0415 + by_labels: dict[tuple[str, ...], list[str]] = {} + for pmid, labels in assignments.items(): + by_labels.setdefault(tuple(_canon_categories(labels)), []).append(pmid) + for labels_key, pmids in by_labels.items(): + res = await db.execute(text( + "UPDATE email_messages SET categories = :cats, updated_at = now() " + "WHERE account_id = :aid AND provider_message_id = ANY(:pmids) " + "AND categories IS DISTINCT FROM :cats" + ), {"aid": account_id, "pmids": pmids, "cats": list(labels_key)}) + out["updated"] += res.rowcount or 0 + await _persist_rotated_creds(db, store, account_id, provider) + _log.info("email.restore_labels", account_id=account_id, **{ + k: v for k, v in out.items() if isinstance(v, int)}) return out - - assignments = await provider.fetch_label_assignments() - out["messages"] = len(assignments) - out["labels"] = len({lbl for v in assignments.values() for lbl in v}) - # Group by label-set and write one statement per DISTINCT set rather - # than one per message. A mailbox has a handful of label combinations - # and tens of thousands of messages, so this is ~20 round-trips instead - # of 40,000 — the difference between a route that returns and one that - # times out on exactly the large mailbox that needs it most. - # Same canonicalisation the sync ingest applies (renamed labels map to - # their new names, the "Uncategorized" indicator is dropped) — this - # repair writes raw provider label sets, so without it a restore would - # resurrect exactly the stale names ingest exists to retire. - from email_ingestion.persist import _canon_categories # noqa: PLC0415 - by_labels: dict[tuple[str, ...], list[str]] = {} - for pmid, labels in assignments.items(): - by_labels.setdefault(tuple(_canon_categories(labels)), []).append(pmid) - for labels_key, pmids in by_labels.items(): - res = await db.execute(text( - "UPDATE email_messages SET categories = :cats, updated_at = now() " - "WHERE account_id = :aid AND provider_message_id = ANY(:pmids) " - "AND categories IS DISTINCT FROM :cats" - ), {"aid": account_id, "pmids": pmids, "cats": list(labels_key)}) - out["updated"] += res.rowcount or 0 - await _persist_rotated_creds(db, store, account_id, provider) - await db.commit() - _log.info("email.restore_labels", account_id=account_id, **{ - k: v for k, v in out.items() if isinstance(v, int)}) - return out except Exception as exc: # noqa: BLE001 _log.warning("email.restore_labels_failed", account_id=account_id, error=str(exc)[:200]) out["error"] = str(exc)[:200] return out - finally: - await db.close() class RestoreLabelsRequest(BaseModel): @@ -794,11 +795,8 @@ async def restore_labels( Synchronous — it is a handful of list calls, not a per-message fetch — so the caller gets the real counts back rather than having to poll. """ - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") - finally: - await db.close() return await restore_provider_labels(req.account_id) @@ -875,6 +873,8 @@ async def _backfill_and_clean_job( # second request sees this run in-flight before this task even starts. started = datetime.now(timezone.utc) try: + # H4: background consumer — _backfill_and_clean_job runs as a + # post-response BackgroundTask; no ambient tenant to inherit. db = await _get_db() try: row = (await db.execute(text( @@ -887,6 +887,7 @@ async def _backfill_and_clean_job( from email_ingestion.scheduler import _sync_account # noqa: PLC0415 await _sync_account(account_id, deep=True, since=since) + # H4: background consumer (see above) — second session of the same job. db = await _get_db() try: after = (await db.execute(text( @@ -934,11 +935,8 @@ async def cleanup_backfill( Runs in the background; poll ``GET /email/cleanup/status``. """ owner = user.email or "anonymous" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, owner) - finally: - await db.close() since = None if req.since_date: @@ -993,11 +991,8 @@ async def auto_categorize_inbox( owner = user.email or "anonymous" # limit=0 means "everything"; _MAX_SWEEP is only a runaway backstop. limit = min(req.limit, _MAX_SWEEP) if req.limit > 0 else _MAX_SWEEP - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, owner) - finally: - await db.close() if req.dry_run: preview_limit = min(limit, _PREVIEW_MAX) @@ -1051,8 +1046,7 @@ async def uncategorized_overview( the sweep could only ever act on a fraction of it. A backlog number that cannot reach zero teaches the user to ignore the badge. """ - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") internal_domains = await _internal_domains(db, account_id) params = _cleanup_scope_params(account_id, internal_domains) @@ -1101,5 +1095,3 @@ async def uncategorized_overview( for r in rows ], } - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/email/automation/drafting.py b/apps/services/gateway/gateway/routes/email/automation/drafting.py index da2b36e8d..e4b4cb486 100644 --- a/apps/services/gateway/gateway/routes/email/automation/drafting.py +++ b/apps/services/gateway/gateway/routes/email/automation/drafting.py @@ -23,6 +23,7 @@ _attachment_summaries, _fmt_addr_list, _get_db, + _tenant_session, _llm_json, _log, _row_to_message, @@ -317,6 +318,8 @@ async def _cleanup_thread_drafts(account_id: str, thread_id: str) -> None: the one consumed by the send. Best-effort background task.""" if not thread_id: return + # H4: background consumer — _cleanup_thread_drafts runs as a post-response + # BackgroundTask (send paths); no ambient tenant to inherit. db = await _get_db() try: rows = (await db.execute(text( @@ -398,10 +401,16 @@ async def _resolve_existing_thread_draft( async def _store_ai_draft( - db: Any, account_id: str, thread_id: str, draft_text: str + db: Any, account_id: str, thread_id: str, draft_text: str, + *, commit: bool = True, ) -> None: """Remember the assistant's original draft for a thread, so we can later - learn from how the user edits it before sending.""" + learn from how the user edits it before sending. + + ``commit=False`` is for callers holding a ``_tenant_session`` (H2): the + wrapper commits on clean exit, and a mid-block commit would end that + transaction and drop the tenant GUC. Background callers on their own + `_get_db` session keep the default and commit here as before.""" if not account_id or not thread_id or not (draft_text or "").strip(): return try: @@ -411,7 +420,8 @@ async def _store_ai_draft( ON CONFLICT (account_id, thread_id) DO UPDATE SET draft_text = EXCLUDED.draft_text, created_at = now()""" ), {"aid": account_id, "tid": thread_id, "txt": draft_text}) - await db.commit() + if commit: + await db.commit() except Exception as exc: # noqa: BLE001 _log.warning("email.store_ai_draft_failed", error=str(exc)[:160]) @@ -511,6 +521,8 @@ async def _learn_from_sent(account_id: str, thread_id: str, sent_text: str) -> N # correspondent prose leaked into the learned preferences. Strip the quote so # both the unchanged-check and the extraction see only what the user wrote. sent_text = split_quoted_text(sent_text)[0] + # H4: background consumer — _learn_from_sent runs as a post-response + # BackgroundTask after a send; no ambient tenant to inherit. db = await _get_db() try: # The signature now rides IN draft bodies, but the /send path's body may @@ -1660,8 +1672,7 @@ async def draft_reply_smart( """Draft a context-aware reply with the orchestrating drafter (memory + sales/task-manager). Returns the draft text; optionally also creates a provider draft in the user's Drafts.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") email = await _build_reply_context( db, req.account_id, req.message_id, user.email or "anonymous") @@ -1696,7 +1707,10 @@ async def draft_reply_smart( # Remember this draft so we can learn from the user's edits on send. if not req.follow_up: - await _store_ai_draft(db, req.account_id, email["thread_id"], draft) + # commit=False: this db is the route's _tenant_session — the + # wrapper's exit commit lands the row (H2). + await _store_ai_draft( + db, req.account_id, email["thread_id"], draft, commit=False) created = False if req.create_draft: @@ -1726,14 +1740,11 @@ async def draft_reply_smart( subject=re_subject, body=draft, ) created = True - if created: - await db.commit() + # The wrapper's exit commit lands the mirrored local draft. except Exception as exc: # noqa: BLE001 _log.warning("email.draft_reply_create_failed", error=str(exc)[:160]) return {"draft": draft, "created": created} - finally: - await db.close() class ComposeAssistRequest(BaseModel): @@ -1757,6 +1768,9 @@ async def _compose_assist_run( """The ONE compose-assist implementation behind both the JSON endpoint and the SSE streaming endpoint. Returns {"draft": ...} or {"draft": "", "skipped": "low_confidence"}.""" + # H4: _compose_assist_run also runs via asyncio.create_task on the + # compose_assist_stream keep-alive path — a task must not inherit the + # ambient tenant; needs an explicit tenant threaded through the call. db = await _get_db() try: await _assert_account_owner(db, req.account_id, user.email or "anonymous") @@ -2028,8 +2042,7 @@ async def upsert_draft( place (no duplicates); ``reply_to_message_id`` threads a new reply draft; neither → a standalone draft. Returns the persisted message so the UI can show it in the Drafts folder and in-thread at once.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") # The signature lives IN the draft body so the upstream (provider) # draft shows it too. The composer normally seeds it already — this is @@ -2119,10 +2132,7 @@ async def upsert_draft( subject=subject, body=body, cc=cc, bcc=bcc, has_attachments=bool(atts), ) - await db.commit() return await _fetch_message_dict(db, local_id) - finally: - await db.close() class DraftSendRequest(BaseModel): @@ -2139,8 +2149,7 @@ async def send_draft_endpoint( """Send an existing draft natively (Drafts → Sent, no duplicate) and drop the local draft row. Falls back to send-new-then-trash for providers without a native send-draft primitive.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") drow = (await db.execute(text( "SELECT provider_message_id, subject, to_addresses, body_text," @@ -2212,7 +2221,6 @@ async def _send_new_and_trash() -> None: text("DELETE FROM email_messages WHERE id = :id"), {"id": req.draft_id}, ) - await db.commit() # Reply complete: learn from the sent body and move the thread out of # "Reply" → Awaiting Reply (same hooks as the full /send path). if drow.thread_id: @@ -2229,8 +2237,6 @@ async def _send_new_and_trash() -> None: background.add_task( _cleanup_thread_drafts, req.account_id, drow.thread_id) return {"sent": True} - finally: - await db.close() class SaveDraftRequest(BaseModel): @@ -2249,8 +2255,7 @@ async def save_draft( Powers the chat's interactive draft card: the assistant proposes a draft, the user edits it inline, then saves it to their Drafts folder verbatim. The draft is mirrored locally so it shows in Drafts/in-thread immediately.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") row = (await db.execute(text( "SELECT subject, thread_id, from_address FROM email_messages " @@ -2286,7 +2291,4 @@ async def save_draft( owner_email="", to_email=to_email, subject=re_subject, body=body, ) - await db.commit() return {"created": True, "id": local_id} - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/email/automation/followups.py b/apps/services/gateway/gateway/routes/email/automation/followups.py index 87657af3b..fe313add3 100644 --- a/apps/services/gateway/gateway/routes/email/automation/followups.py +++ b/apps/services/gateway/gateway/routes/email/automation/followups.py @@ -25,6 +25,7 @@ from gateway.routes.email.core import ( _assert_account_owner, _get_db, + _tenant_session, _instantiate_provider, _log, _persist_rotated_creds, @@ -49,11 +50,8 @@ async def scan_follow_ups( Respects the configured reminder windows; if neither is set, returns ``configured: false`` so the UI can prompt the user to set them first.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") - finally: - await db.close() return await _maybe_send_follow_up_reminders(req.account_id) @@ -84,6 +82,8 @@ async def _maybe_send_follow_up_reminders(account_id: str) -> dict[str, int | bo result: dict[str, int | bool] = { "configured": False, "scanned": 0, "labeled": 0, "drafted": 0, } + # H4: mixed callers — _maybe_send_follow_up_reminders also runs on the + # scheduler's sync loop; needs an explicit tenant from the account row. db = await _get_db() try: srow = (await db.execute(text( diff --git a/apps/services/gateway/gateway/routes/email/automation/replyzero.py b/apps/services/gateway/gateway/routes/email/automation/replyzero.py index 2563eb887..54357c3c7 100644 --- a/apps/services/gateway/gateway/routes/email/automation/replyzero.py +++ b/apps/services/gateway/gateway/routes/email/automation/replyzero.py @@ -23,6 +23,7 @@ _attachment_summaries, _fmt_addr_list, _get_db, + _tenant_session, _instantiate_provider, _llm_json, _log, @@ -904,6 +905,8 @@ async def _mark_thread_replied( instead of defaulting to Awaiting and only correcting on the next sync.""" if not thread_id: return + # H4: called only from _maybe_classify_threads (scheduler post-sync + # path); no ambient tenant to inherit. db = await _get_db() try: # Thread-status classification only decides whether a thread needs a @@ -963,6 +966,8 @@ async def _reconcile_labels_bg( Best-effort.""" if not thread_id: return + # H4: background consumer — _reconcile_labels_bg runs as a + # post-response BackgroundTask; no ambient tenant to inherit. db = await _get_db() try: acc = (await db.execute(text( @@ -1004,6 +1009,9 @@ async def apply_thread_status_correction( _canon_status_key(status_key), ("", "")) if not rz_status or not thread_id: return {"ok": False} + # H4: mixed callers — apply_thread_status_correction also runs from the + # background sync's label learner (_apply_label_status_corrections); + # needs an explicit tenant from the account row. db = await _get_db() try: latest = (await db.execute(text( @@ -1073,6 +1081,7 @@ async def _maybe_classify_threads(account_id: str) -> None: rule (FYI when none matches). Touches threads whose latest message changed OR whose stored status is provisional ("· auto" — a prior LLM fallback), so a guessed AWAITING self-heals. Caps work per cycle. Best-effort (never raises).""" + # H4: scheduler post-sync hook path; no ambient tenant to inherit. db = await _get_db() try: from gateway.routes.email.automation.engine import ( # noqa: PLC0415 @@ -1322,6 +1331,8 @@ async def _reclassify_reply_zero_job( inbound remainder can't be classified) stops the drain rather than spinning — those threads keep their gap, so re-triggering reclassify picks up where this left off. Progress is published per pass for the UI to poll. Best-effort.""" + # H4: background consumer — _reclassify_reply_zero_job runs as a + # post-response BackgroundTask; no ambient tenant to inherit. db = await _get_db() try: await db.execute(text( @@ -1344,6 +1355,7 @@ async def _reclassify_reply_zero_job( _RECLASSIFY_JOBS.update(account_id, token, total=total, remaining=total) prev_remaining: int | None = None for _ in range(_RECLASSIFY_MAX_PASSES): + # H4: background consumer (see above) — per-batch session of the same job. db = await _get_db() try: remaining = await _count_reply_zero_backlog(db, account_id) @@ -1387,8 +1399,7 @@ async def resolve_thread( are collapsed to "Done" (clearing stale Reply / Awaiting / Follow-up). Reopen → re-derive NEEDS_REPLY/AWAITING from the latest message's folder and swap the label back to Reply / Awaiting Reply.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") keep_label = "Done" if req.dismiss: @@ -1450,14 +1461,11 @@ async def resolve_thread( "UPDATE email_thread_status SET status = :st, classified_at = now() " "WHERE account_id = :aid AND thread_id = :tid" ), {"st": new_status, "aid": req.account_id, "tid": req.thread_id}) - await db.commit() # Collapse the provider/local labels to match the new status (clears the # stale Reply / Awaiting / Follow-up that the status update alone left). background.add_task( _reconcile_labels_bg, req.account_id, req.thread_id, keep_label) return {"ok": True, "thread_id": req.thread_id, "done": req.done} - finally: - await db.close() class ReplyZeroReclassifyRequest(BaseModel): @@ -1477,11 +1485,8 @@ async def reclassify_reply_zero( (not a fixed handful of passes). Runs in the background; poll GET /email/reply-zero/reclassify/status for progress, or GET /email/reply-zero to see the rebuilt buckets.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") - finally: - await db.close() # One rebuild at a time per account: a second click while one is draining # would double the LLM spend and race on the same status rows. if _RECLASSIFY_JOBS.is_running(req.account_id): @@ -1503,11 +1508,8 @@ async def reclassify_reply_zero_status( ): """Progress of an in-flight (or the last) whole-mailbox reclassify: status, total threads to rebuild, how many remain, and how many are done.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") - finally: - await db.close() job = _RECLASSIFY_JOBS.get(account_id) if not job: return {"status": "idle"} @@ -1532,8 +1534,7 @@ async def reply_zero( classified yet we kick off a one-off background backfill so the next poll is populated; an existing draft for the thread is surfaced (``draft_id``) so the UI offers "View draft" instead of drafting a second reply.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") want = {"awaiting": "AWAITING", "done": "DONE"}.get(type, "NEEDS_REPLY") # Trash is hidden from every bucket; archiving a thread also drops it from @@ -1609,7 +1610,5 @@ async def reply_zero( "draft_preview": (r.draft_text or "") if r.draft_id else None, }) return {"threads": out, "type": type} - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/email/automation/rules.py b/apps/services/gateway/gateway/routes/email/automation/rules.py index 0381ded87..57a212737 100644 --- a/apps/services/gateway/gateway/routes/email/automation/rules.py +++ b/apps/services/gateway/gateway/routes/email/automation/rules.py @@ -12,7 +12,7 @@ from gateway.routes.email.automation.senders import DISPOSED_FOLDERS from gateway.routes.email.core import ( _assert_account_owner, - _get_db, + _tenant_session, _llm_json, _log, provider_session, @@ -155,12 +155,9 @@ async def list_rules( user: UserContext = Depends(get_current_user), ): """List assistant rules (with actions) for an account.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") return {"rules": await _load_rules(db, account_id)} - finally: - await db.close() # Default inbox-zero rule set. Each preset carries a provider-agnostic @@ -307,19 +304,15 @@ async def install_preset_rules( ): """Install the default inbox-zero-style rule set (skips ones already present by name). Used by the UI's 'Add defaults' and the assistant's setup flow.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") # The account's provider decides whether cleanup categories become # folders (Outlook) or labels (Gmail) — inbox-zero parity. provider = await _account_provider(db, account_id) installed = await _seed_preset_rules( db, account_id, provider, skip_existing=True) - await db.commit() return {"installed": installed, "total_presets": len(_PRESET_RULES)} - finally: - await db.close() @router.post("/rules/reset") @@ -340,8 +333,7 @@ async def reset_rules( new id. Patterns belonging to a custom rule the user is deleting here are genuinely gone with it, which is the expected meaning of 'reset'. """ - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") provider = await _account_provider(db, account_id) # Snapshot the learned patterns keyed by their rule's NAME — the reseed @@ -387,11 +379,8 @@ async def reset_rules( "reason": s.reason, "approved": s.approved_at, "rejected": s.rejected_at}) restored += 1 - await db.commit() return {"installed": installed, "total_presets": len(_PRESET_RULES), "reset": True, "patterns_restored": restored} - finally: - await db.close() async def _replace_actions(db: Any, rule_id: str, actions: list[RuleActionModel]) -> None: @@ -478,15 +467,11 @@ async def create_rule( user: UserContext = Depends(get_current_user), ): """Create an assistant rule with its actions.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") rule_id = await _insert_rule(db, req) - await db.commit() rules = await _load_rules(db, req.account_id) return next((r for r in rules if r["id"] == rule_id), {"id": rule_id}) - finally: - await db.close() _GEN_ACTION_TYPES = { @@ -596,8 +581,7 @@ async def rule_policies( auth failure degrade to ``provider_rules_supported: false`` rather than failing the screen — the local policies still render. """ - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") cb_row = (await db.execute(text( "SELECT cold_email_blocker FROM email_assistant_settings " @@ -634,7 +618,10 @@ async def rule_policies( supported = True except Exception as exc: # noqa: BLE001 # Display-only extra — never fail the screen over it, but the - # session may be mid-transaction after a provider error. + # session may be mid-transaction after a provider error. The + # rollback ends this transaction (and with it the tenant GUC); + # nothing below touches the DB again, and the wrapper's exit + # commit closes out an empty transaction. await db.rollback() _log.warning("email.rule_policies_provider_failed", account_id=account_id, error=str(exc)[:200]) @@ -645,8 +632,6 @@ async def rule_policies( "provider_rules": provider_rules, "provider_rules_supported": supported, } - finally: - await db.close() class RuleGenerateRequest(BaseModel): @@ -663,8 +648,7 @@ async def generate_rules( The text may describe several rules at once; each is turned into a structured rule and created. Returns the created rules.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") if not (req.prompt or "").strip(): return {"created": [], "error": "Describe at least one rule."} @@ -684,12 +668,9 @@ async def generate_rules( actions=[RuleActionModel(**a) for a in spec["actions"]], ) created_ids.append(await _insert_rule(db, model)) - await db.commit() rules = await _load_rules(db, req.account_id) created = [r for r in rules if r["id"] in set(created_ids)] return {"created": created} - finally: - await db.close() @router.patch("/rules/{rule_id}") @@ -699,8 +680,7 @@ async def update_rule( user: UserContext = Depends(get_current_user), ): """Update a rule and replace its actions.""" - db = await _get_db() - try: + async with _tenant_session() as db: owner = (await db.execute(text( """SELECT er.account_id FROM email_rules er JOIN email_accounts ea ON er.account_id = ea.id @@ -724,11 +704,8 @@ async def update_rule( "tp": req.to_pattern, "sp": req.subject_pattern, "bp": req.body_pattern, "st": req.system_type}) await _replace_actions(db, rule_id, req.actions) - await db.commit() rules = await _load_rules(db, str(owner.account_id)) return next((r for r in rules if r["id"] == rule_id), {"id": rule_id}) - finally: - await db.close() @router.delete("/rules/{rule_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -737,19 +714,15 @@ async def delete_rule( user: UserContext = Depends(get_current_user), ): """Delete a rule (cascades to actions).""" - db = await _get_db() - try: + async with _tenant_session() as db: res = await db.execute(text( """DELETE FROM email_rules er USING email_accounts ea WHERE er.id = :rid AND er.account_id = ea.id AND ea.user_id = :uid""" ), {"rid": rule_id, "uid": user.email or "anonymous"}) - await db.commit() if res.rowcount == 0: raise HTTPException(status_code=404, detail="Rule not found") - finally: - await db.close() # Sources that represent a deliberate human act, so the pattern needs no review: @@ -894,8 +867,7 @@ async def list_rule_guidance( ): """Corrections that teach the classifier — the "improves the AI" half of the Learned Patterns screen.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") rows = (await db.execute(text( """SELECT g.id, g.rule_id, r.name AS rule_name, g.guidance, @@ -913,8 +885,6 @@ async def list_rule_guidance( "thread_id": r.thread_id, "created_at": r.created_at.isoformat() if r.created_at else None} for r in rows]} - finally: - await db.close() @router.post("/rules/guidance") @@ -926,15 +896,11 @@ async def add_rule_guidance( text_ = (req.guidance or "").strip() if not text_: raise HTTPException(status_code=400, detail="Guidance cannot be empty") - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") await _upsert_rule_guidance( db, req.account_id, req.rule_id, text_, "USER") - await db.commit() return {"ok": True} - finally: - await db.close() @router.delete("/rules/guidance/{gid}", status_code=status.HTTP_204_NO_CONTENT) @@ -946,16 +912,12 @@ async def delete_rule_guidance( """Withdraw a correction. Deleted outright rather than deactivated — unlike a rejected PATTERN, nothing re-infers guidance, so there is no verdict to remember and a leftover row would just be clutter the user cannot see.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") await db.execute(text( "DELETE FROM email_rule_guidance " " WHERE id = :gid AND account_id = :aid" ), {"gid": gid, "aid": account_id}) - await db.commit() - finally: - await db.close() class RuleFeedbackRequest(BaseModel): @@ -993,8 +955,7 @@ async def rule_feedback( A correction can be taught on the sender (FROM), a subject keyword (SUBJECT), or both — whichever signals the request carries.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") sender = (req.sender or "").strip() subject_kw = (req.subject_keyword or "").strip() @@ -1130,7 +1091,6 @@ async def _teach(rule_id: str, exclude: bool) -> bool: db, req.account_id, target, taught, "FIX", req.message_id, req.thread_id) - await db.commit() changed_label = bool( label_correction and (label_correction["removed"] or label_correction["added"])) @@ -1142,8 +1102,6 @@ async def _teach(rule_id: str, exclude: bool) -> bool: "signals": [t for t, _ in signals], "status_correction": status_correction, "label_correction": label_correction} - finally: - await db.close() @router.get("/rules/patterns") @@ -1166,8 +1124,7 @@ async def list_rule_patterns( UI presents it as "about". One nested-loop join over the mailbox, on an explicitly-opened review screen. """ - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") try: rows = (await db.execute(text( @@ -1210,8 +1167,6 @@ async def list_rule_patterns( "created_at": r.created_at.isoformat() if r.created_at else None} for r in rows ]} - finally: - await db.close() class PatternReviewRequest(BaseModel): @@ -1234,8 +1189,7 @@ async def review_rule_patterns( refuses to resurrect a rejected pattern unless the user themselves overturns it via Fix or a label change. """ - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") params: dict[str, Any] = {"aid": req.account_id} where = "account_id = :aid" @@ -1252,13 +1206,10 @@ async def review_rule_patterns( else "rejected_at = now(), approved_at = NULL") res = await db.execute(text( f"UPDATE email_rule_patterns SET {sets} WHERE {where}"), params) - await db.commit() updated = int(getattr(res, "rowcount", 0) or 0) _log.info("email.rule_patterns_reviewed", account_id=req.account_id, approved=req.approve, updated=updated) return {"updated": updated, "approved": req.approve} - finally: - await db.close() @router.delete("/rules/patterns/{pattern_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -1267,14 +1218,10 @@ async def delete_rule_pattern( user: UserContext = Depends(get_current_user), ): """Forget a learned classification pattern.""" - db = await _get_db() - try: + async with _tenant_session() as db: res = await db.execute(text( """DELETE FROM email_rule_patterns p USING email_accounts ea WHERE p.id = :id AND p.account_id = ea.id AND ea.user_id = :uid""" ), {"id": pattern_id, "uid": user.email or "anonymous"}) - await db.commit() if res.rowcount == 0: raise HTTPException(status_code=404, detail="Not found") - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/email/automation/runner.py b/apps/services/gateway/gateway/routes/email/automation/runner.py index 74591be03..3b8b2dde7 100644 --- a/apps/services/gateway/gateway/routes/email/automation/runner.py +++ b/apps/services/gateway/gateway/routes/email/automation/runner.py @@ -48,6 +48,7 @@ _attachment_summaries, _date_range_clause, _get_db, + _tenant_session, _instantiate_provider, _log, _parse_iso_date, @@ -85,8 +86,7 @@ async def test_rules( user: UserContext = Depends(get_current_user), ): """Test the rules against one email (selected message or a pasted sample).""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") if req.email_id: email = await _email_payload_from_id(db, req.email_id, user.email or "anonymous") @@ -109,8 +109,6 @@ async def test_rules( "reason": match["reason"], "actions": match["rule"]["actions"], } - finally: - await db.close() class RuleTestRecentRequest(BaseModel): @@ -128,8 +126,7 @@ async def test_rules_recent( Returns, per email, which rule would match and the actions it would take — inbox-zero's "test on your real inbox" preview. Applies nothing. """ - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") self_email = await _account_self_email(db, req.account_id) about, _ = await _load_assistant_about(db, req.account_id) @@ -165,8 +162,6 @@ async def test_rules_recent( if match else [], }) return {"results": results} - finally: - await db.close() @router.get("/rules/history") @@ -182,8 +177,7 @@ async def rules_history( TRASH by delta reconciliation — e.g. an AI draft the user discarded) are hidden, so History reflects the live mailbox. Pass ``include_deleted=true`` to see the full immutable log.""" - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = {"uid": user.email or "anonymous", "limit": limit} scope = ("er.account_id IN (SELECT id FROM email_accounts WHERE user_id = :uid") if account_id: @@ -272,8 +266,6 @@ async def rules_history( for r in rows ] } - finally: - await db.close() @router.get("/messages/{message_id}/timeline") @@ -290,8 +282,7 @@ async def message_timeline( moved, or failed on it (including corrections that re-ran later). Reuses the same `email_executed_rules` audit rows, scoped to this one message and to the caller's own accounts.""" - db = await _get_db() - try: + async with _tenant_session() as db: # Anchor: the message itself (received event) + ownership check. A message # the caller doesn't own — or one we never synced — yields 404, never a # cross-account peek. @@ -350,8 +341,6 @@ async def message_timeline( "subject": msg.subject or "", "events": events, } - finally: - await db.close() # Actions a retry will never perform, however the original rule was configured. @@ -378,8 +367,7 @@ async def retry_failed_executions( Safe to run repeatedly: LABEL and MOVE_FOLDER are idempotent, and a row that succeeds is flipped to APPLIED so it is not retried again. """ - db = await _get_db() - try: + async with _tenant_session() as db: owner = user_email if owner is None: owner = (await db.execute(text( @@ -464,15 +452,12 @@ async def retry_failed_executions( "aerr": json.dumps(errors)}) await _persist_rotated_creds(db, store, account_id, provider) - await db.commit() _log.info("email.retry_failed_done", account_id=account_id, considered=len(rows), repaired=repaired, still_failing=still_failing) return {"considered": len(rows), "repaired": repaired, "still_failing": still_failing, "skipped_actions": sorted(skipped)} - finally: - await db.close() class RetryFailedRequest(BaseModel): @@ -486,11 +471,8 @@ async def retry_failed( user: UserContext = Depends(get_current_user), ): """Repair rule runs the mail server refused. Never drafts or sends.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") - finally: - await db.close() return await retry_failed_executions( req.account_id, limit=max(1, min(req.limit, 1000)), user_email=user.email or "anonymous") @@ -502,8 +484,7 @@ async def approve_execution( user: UserContext = Depends(get_current_user), ): """Apply a PENDING (proposed) rule execution — the approval queue.""" - db = await _get_db() - try: + async with _tenant_session() as db: row = (await db.execute(text( """SELECT er.status, er.rule_id, er.message_id, er.provider_message_id, er.thread_id, er.subject, er.from_address, er.account_id, @@ -550,10 +531,7 @@ async def approve_execution( await _stamp_processed_watermark( db, row.message_id, provider=provider) await _persist_rotated_creds(db, store, str(row.account_id), provider) - await db.commit() return {"ok": True, "status": "APPLIED", "actions": taken} - finally: - await db.close() @router.post("/rules/history/{exec_id}/reject") @@ -562,8 +540,7 @@ async def reject_execution( user: UserContext = Depends(get_current_user), ): """Dismiss a PENDING rule execution without applying it.""" - db = await _get_db() - try: + async with _tenant_session() as db: res = await db.execute(text( """UPDATE email_executed_rules er SET status = 'REJECTED' @@ -571,12 +548,9 @@ async def reject_execution( WHERE er.id = :eid AND er.account_id = ea.id AND ea.user_id = :uid AND er.status = 'PENDING'""" ), {"eid": exec_id, "uid": user.email or "anonymous"}) - await db.commit() if res.rowcount == 0: raise HTTPException(status_code=404, detail="Pending execution not found") return {"ok": True, "status": "REJECTED"} - finally: - await db.close() @router.post("/rules/history/{exec_id}/undo") @@ -586,8 +560,7 @@ async def undo_execution( ): """Reverse an APPLIED rule execution where possible: restore the message to the inbox (archive/move/trash/spam) and remove any labels the rule added.""" - db = await _get_db() - try: + async with _tenant_session() as db: row = (await db.execute(text( """SELECT er.status, er.rule_id, er.message_id, er.provider_message_id, er.actions_taken, ea.provider, ea.credentials_encrypted @@ -638,10 +611,7 @@ async def undo_execution( await db.execute(text( "UPDATE email_executed_rules SET status='UNDONE' WHERE id=:eid" ), {"eid": exec_id}) - await db.commit() return {"status": "UNDONE", "reversed": reversed_actions} - finally: - await db.close() class RuleRunRequest(BaseModel): @@ -662,11 +632,8 @@ async def run_rules( to actually apply the matched actions. Poll GET /email/rules/history for results. """ - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") - finally: - await db.close() background.add_task( _run_rules_job, req.account_id, min(req.limit, 50), req.dry_run, user.email or "anonymous", @@ -888,8 +855,7 @@ async def process_past_estimate( start_dt = _parse_iso_date(start_date, end_of_day=False) end_dt = _parse_iso_date(end_date, end_of_day=True) only_unread = not include_read - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") async def _count(extra: str = "", unprocessed: bool = False) -> int: @@ -910,8 +876,6 @@ async def _count(extra: str = "", unprocessed: bool = False) -> int: # know this range is mostly freshly-fetched history. held_back = await _count( extra=" AND em.rules_held_back_at IS NOT NULL", unprocessed=True) - finally: - await db.close() capped = max(0, min(limit, 2000)) return { "in_range": in_range, @@ -946,8 +910,7 @@ async def process_past_emails( end_dt = _parse_iso_date(req.end_date, end_of_day=True) _assert_span_within_cap(start_dt, end_dt) only_unread = not req.include_read - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") # Best-effort pre-count of what's ALREADY synced locally — just a hint for # the caller. The job downloads the range from upstream first, so the real @@ -971,8 +934,6 @@ async def process_past_emails( f"WHERE {all_clause}" ), all_params)).fetchone() already_processed = max(0, (int(n_all.c) if n_all else 0) - count) - finally: - await db.close() # Always schedule: the job first downloads [start, end] from the provider so a # range that predates the local sync still has mail to process, THEN counts + # applies. The tracker starts in the 'downloading' phase and the UI polls it @@ -1024,8 +985,7 @@ async def run_rules_on_message( mailbox. `is_test=False` applies the matched rule's actions, logs an APPLIED (or SKIPPED) row to the history, and marks the message processed. """ - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") row = (await db.execute(text( """SELECT id, provider_message_id, thread_id, subject, body_text, @@ -1078,7 +1038,6 @@ async def run_rules_on_message( ), {"aid": req.account_id, "mid": str(row.id), "pmid": row.provider_message_id, "tid": row.thread_id, "subj": row.subject or "", "frm": frm.get("email", "")}) - await db.commit() return {"matched": False, "applied": False, "rule": None, "reason": "No rule matched.", "actions": []} @@ -1124,7 +1083,6 @@ async def run_rules_on_message( ) await _stamp_processed_watermark(db, row.id, provider=provider) await _persist_rotated_creds(db, store, req.account_id, provider) - await db.commit() # Return the row's POST-apply category + folder so the caller can refresh # its inbox row in place — the "Uncategorized" pill in the list reruns # this endpoint and needs the applied label to resolve without a full @@ -1142,8 +1100,6 @@ async def run_rules_on_message( "categories": list(fresh.categories or []) if fresh else [], "folder": fresh.folder if fresh else None, } - finally: - await db.close() async def _apply_and_log_match( @@ -1445,6 +1401,8 @@ async def _process_past_emails_job( # hidden precisely because it thinks a run is in flight. db = None try: + # H4: background consumer — _process_past_emails_job runs as a + # post-response BackgroundTask; no ambient tenant to inherit. db = await _get_db() clause, params = _date_range_clause( account_id, start, end, only_unread, skip_processed) @@ -1601,6 +1559,8 @@ async def _run_rules_job( a "No match found" entry (inbox-zero parity). Dry runs only log a PENDING preview and never touch the mailbox. """ + # H4: background consumer — _run_rules_job runs as a BackgroundTask and + # from the scheduler's auto-run hook; no ambient tenant to inherit. db = await _get_db() try: rows = (await db.execute(text( diff --git a/apps/services/gateway/gateway/routes/email/automation/senders.py b/apps/services/gateway/gateway/routes/email/automation/senders.py index dd1ba8856..cee22bef7 100644 --- a/apps/services/gateway/gateway/routes/email/automation/senders.py +++ b/apps/services/gateway/gateway/routes/email/automation/senders.py @@ -27,6 +27,7 @@ _account_scope, _assert_account_owner, _get_db, + _tenant_session, _llm_json, _log, provider_session, @@ -97,8 +98,7 @@ async def list_senders( many senders exist and every one of them is reachable by paging, so the quiet tail can still be cleaned up. """ - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = {"uid": user.email or "anonymous", "limit": limit, "offset": offset} scope = _account_scope(account_id, params) @@ -326,8 +326,6 @@ def _labelled_count(email: str) -> int: # and the UI must say so rather than imply completeness. "total": int(total_row.c) if total_row else len(rows), } - finally: - await db.close() class BulkActionRequest(BaseModel): @@ -400,8 +398,7 @@ async def bulk_action( "message_ids, sender_email, folder, older_than_days, only_read.", ) - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = {"uid": user.email or "anonymous"} scope = _account_scope(req.account_id, params) clauses = [scope] @@ -443,7 +440,6 @@ async def bulk_action( ), params)).fetchall() if not rows: return {"affected": 0} - await db.commit() # Group provider message ids per account for background reconciliation. per_account: dict[str, list[str]] = {} @@ -453,8 +449,6 @@ async def bulk_action( background.add_task(_bulk_reconcile_provider, aid, pmids, req.action) return {"affected": len(rows)} - finally: - await db.close() # How hard to push a bulk action at the provider before giving up. The dominant @@ -486,6 +480,8 @@ async def _bulk_reconcile_provider( folder reverted so the mirror matches the mailbox instead of lying until the next sync corrects it. """ + # H4: called from _maybe_auto_archive (scheduler post-sync path); no + # ambient tenant to inherit. db = await _get_db() try: # Unscoped session: background task, no request user. Missing account @@ -560,6 +556,7 @@ async def _maybe_auto_archive(account_id: str) -> None: """Archive freshly-synced inbox mail from senders marked AUTO_ARCHIVED (the bulk-archive 'Auto' action), then reconcile to the provider. This is what makes auto-archive apply to FUTURE mail, not just existing. Idempotent.""" + # H4: scheduler post-sync hook path; no ambient tenant to inherit. db = await _get_db() try: rows = (await db.execute(text( @@ -605,8 +602,7 @@ async def list_newsletters( user: UserContext = Depends(get_current_user), ): """List newsletter dispositions for the user's accounts.""" - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = {"uid": user.email or "anonymous"} scope = "account_id IN (SELECT id FROM email_accounts WHERE user_id = :uid" if account_id: @@ -628,8 +624,6 @@ async def list_newsletters( for r in rows ] } - finally: - await db.close() async def _apply_newsletter_status( @@ -662,7 +656,10 @@ async def _apply_newsletter_status( updated_at = now()""" ), {"aid": account_id, "email": email, "name": name, "status": status, "link": link}) - await db.commit() + # No commit here: both callers are converted request handlers (H2) whose + # `_tenant_session` commits on clean exit — BEFORE the response is sent, + # so the BackgroundTasks scheduled below always see the committed rows. + # A mid-block commit would end that transaction and drop the tenant GUC. archived = 0 if status in ("UNSUBSCRIBED", "AUTO_ARCHIVED"): @@ -679,7 +676,6 @@ async def _apply_newsletter_status( "UPDATE email_messages SET folder = 'archive', updated_at = now() " "WHERE id::text = ANY(:ids)" ), {"ids": ids}) - await db.commit() archived = len(ids) background.add_task( _bulk_reconcile_provider, account_id, @@ -705,16 +701,13 @@ async def upsert_newsletter( provider-native filter so future mail skips the inbox at the source.""" if req.status not in ("APPROVED", "UNSUBSCRIBED", "AUTO_ARCHIVED"): raise HTTPException(status_code=400, detail=f"Bad status: {req.status}") - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") archived = await _apply_newsletter_status( db, background, req.account_id, req.email, req.name, req.status, req.unsubscribe_link, create_filter=(req.status == "AUTO_ARCHIVED"), ) return {"ok": True, "status": req.status, "archived": archived} - finally: - await db.close() # ── Real unsubscribe: RFC 8058 one-click + mailto, with SSRF guard ─────────── @@ -808,6 +801,8 @@ async def _create_block_filter( """Best-effort background task: create a provider-native auto-archive filter for ``email`` and record its id on the newsletter row. No-ops gracefully for providers without filters (IMAP) — the AUTO_ARCHIVED sweep covers those.""" + # H4: background consumer — _create_block_filter runs as a + # post-response BackgroundTask; no ambient tenant to inherit. db = await _get_db() try: # Unscoped session: background task, no request user. Missing account @@ -840,6 +835,8 @@ async def _remove_block_filter(account_id: str, email: str) -> None: """Best-effort background task: delete the provider-native auto-archive filter recorded for ``email`` and clear it on the newsletter row. No-ops when no filter is recorded (e.g. IMAP, or never auto-archived).""" + # H4: background consumer — _remove_block_filter runs as a + # post-response BackgroundTask; no ambient tenant to inherit. db = await _get_db() try: row = (await db.execute(text( @@ -892,8 +889,7 @@ async def unsubscribe_sender( still handled rather than silently continuing to the inbox. Either way the sender's existing inbox mail is archived. Returns what was actually done so the UI can tell the user (unsubscribed vs blocked).""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") # Use the link the UI passed; otherwise recover the best one we stored. @@ -924,8 +920,8 @@ async def unsubscribe_sender( ok, detail = await _mailto_unsubscribe(sess.provider, link) else: detail = "auth-failed" - if sess.authed: - await db.commit() + # Rotated creds staged by provider_session land at the wrapper's + # exit commit. # Unsubscribe worked → UNSUBSCRIBED (the sender stops; no filter needed). # Otherwise block: AUTO_ARCHIVED + a provider filter so future mail is @@ -942,8 +938,6 @@ async def unsubscribe_sender( ) return {"ok": ok, "method": method, "detail": detail, "status": status, "archived": archived, "unsubscribe_link": link} - finally: - await db.close() EMAIL_CATEGORIES = [ @@ -1060,6 +1054,8 @@ async def _categorize_senders_job(account_id: str, limit: int) -> None: through the same ``_LABEL_TALLY_SQL`` + ``_rule_category`` helpers, so the two views cannot disagree. """ + # H4: background consumer — _categorize_senders_job runs as a + # BackgroundTask and from the scheduler; no ambient tenant to inherit. db = await _get_db() try: # Busiest senders, with their CURRENT category/source so a 'user' @@ -1172,11 +1168,8 @@ async def categorize_senders( the rules actually label more mail, use ``/email/senders/auto-categorize`` (learned-pattern sweep) or ``/email/rules/process-past`` (full re-run). """ - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") - finally: - await db.close() background.add_task(_categorize_senders_job, req.account_id, min(req.limit, 300)) return {"scheduled": True} @@ -1187,8 +1180,7 @@ async def sender_categories( user: UserContext = Depends(get_current_user), ): """List the category vocabulary + per-category sender counts.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") rows = (await db.execute(text( """SELECT category, COUNT(*) AS c FROM email_senders @@ -1199,8 +1191,6 @@ async def sender_categories( "categories": EMAIL_CATEGORIES, "counts": {r.category: r.c for r in rows}, } - finally: - await db.close() async def _llm_is_cold(email: dict[str, str]) -> tuple[bool, str]: @@ -1316,8 +1306,7 @@ async def list_cold_senders( user: UserContext = Depends(get_current_user), ): """List cold-email verdicts (flagged + whitelisted) for an account.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") rows = (await db.execute(text( """SELECT from_email, status, reason, updated_at @@ -1332,8 +1321,6 @@ async def list_cold_senders( for r in rows ] } - finally: - await db.close() @router.post("/cold-senders") @@ -1342,8 +1329,7 @@ async def upsert_cold_sender( user: UserContext = Depends(get_current_user), ): """Set a sender's cold verdict — USER_REJECTED_COLD whitelists them.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") await db.execute(text( """INSERT INTO email_cold_senders @@ -1352,7 +1338,4 @@ async def upsert_cold_sender( ON CONFLICT (account_id, from_email) DO UPDATE SET status = EXCLUDED.status, updated_at = now()""" ), {"aid": req.account_id, "e": req.from_email, "status": req.status}) - await db.commit() return {"ok": True, "status": req.status} - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/email/automation/voice_profile.py b/apps/services/gateway/gateway/routes/email/automation/voice_profile.py index 07d7bfcfa..e92b2200f 100644 --- a/apps/services/gateway/gateway/routes/email/automation/voice_profile.py +++ b/apps/services/gateway/gateway/routes/email/automation/voice_profile.py @@ -28,6 +28,7 @@ from gateway.routes.email.core import ( _assert_account_owner, _get_db, + _tenant_session, _llm_json, _log, _parse_iso_date, @@ -345,6 +346,8 @@ async def _build_voice_profile_job( profile row's status moves BUILDING → READY / FAILED so the state survives the tracker (which is in-memory and dies with the process). """ + # H4: background consumer — _build_voice_profile_job runs as a + # post-response BackgroundTask; no ambient tenant to inherit. db = await _get_db() try: _VOICE_JOBS.update(account_id, token, phase="collecting") @@ -469,8 +472,7 @@ async def get_voice_profile( ): """The account's voice profile (or an EMPTY placeholder), plus how many suggested knowledge entries are waiting for review.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") row = (await db.execute(text( """SELECT enabled, status, style_guide, traits, sources, @@ -480,8 +482,6 @@ async def get_voice_profile( ), {"aid": account_id})).fetchone() suggested = await _count_suggested(db, account_id) return _profile_dict(account_id, row, suggested) - finally: - await db.close() @router.get("/voice-profile/preview") @@ -497,8 +497,7 @@ async def preview_voice_profile( src = _clean_sources([s.strip() for s in sources.split(",")]) start = _parse_iso_date(start_date, end_of_day=False) end = _parse_iso_date(end_date, end_of_day=True) - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") counts: dict[str, int] = {} for folder in _VALID_SOURCES: @@ -519,8 +518,6 @@ async def preview_voice_profile( f"WHERE {' AND '.join(clauses)}" ), params)).fetchone() counts[folder] = int(row.c) if row else 0 - finally: - await db.close() total = sum(counts.values()) return { "sent": counts.get("sent", 0), @@ -561,8 +558,7 @@ async def build_voice_profile( if start and end and start > end: raise HTTPException(status_code=400, detail="Start date is after end date.") - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") # Seed/refresh the row first so status survives a process restart. @@ -579,9 +575,6 @@ async def build_voice_profile( ), {"aid": req.account_id, "src": sources, "rs": start.date() if start else None, "re": end.date() if end else None}) - await db.commit() - finally: - await db.close() token = _VOICE_JOBS.start( req.account_id, owner=user.email or "anonymous", status="running", phase="collecting", processed=0, total=0) @@ -617,8 +610,7 @@ async def put_voice_profile( ): """Edit the profile in place: toggle it, or hand-tune the style guide the drafter reads (the built traits stay as the record of what was learned).""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") sets, params = [], {"aid": req.account_id} @@ -635,7 +627,6 @@ async def put_voice_profile( SET {', '.join(sets)}, updated_at = now() WHERE account_id = :aid""" ), params) - await db.commit() if res.rowcount == 0: raise HTTPException(status_code=404, detail="No profile yet.") row = (await db.execute(text( @@ -646,8 +637,6 @@ async def put_voice_profile( ), {"aid": req.account_id})).fetchone() suggested = await _count_suggested(db, req.account_id) return _profile_dict(req.account_id, row, suggested) - finally: - await db.close() @router.delete("/voice-profile", status_code=status.HTTP_204_NO_CONTENT) @@ -657,8 +646,7 @@ async def delete_voice_profile( ): """Remove the profile — and the knowledge suggestions it proposed that were never approved (approved entries are the user's now and stay).""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") await db.execute(text( """DELETE FROM email_knowledge @@ -668,9 +656,6 @@ async def delete_voice_profile( await db.execute(text( "DELETE FROM email_voice_profiles WHERE account_id = :aid" ), {"aid": account_id}) - await db.commit() - finally: - await db.close() class VoiceProfileSampleRequest(BaseModel): @@ -687,16 +672,13 @@ async def sample_voice_profile( ): """"Try it": write a short sample email in the profile's voice so the user can judge the profile before trusting it with real drafts.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") row = (await db.execute(text( """SELECT style_guide, traits FROM email_voice_profiles WHERE account_id = :aid AND status = 'READY'""" ), {"aid": req.account_id})).fetchone() - finally: - await db.close() if not row: raise HTTPException(status_code=404, detail="Build a profile first.") block = voice_profile_block(row.style_guide or "", row.traits) @@ -733,8 +715,7 @@ async def approve_knowledge( user: UserContext = Depends(get_current_user), ): """Approve a suggested knowledge entry so it starts feeding drafts.""" - db = await _get_db() - try: + async with _tenant_session() as db: res = await db.execute(text( """UPDATE email_knowledge ek SET status = 'active', updated_at = now() @@ -742,9 +723,6 @@ async def approve_knowledge( WHERE ek.id = :id AND ek.account_id = ea.id AND ea.user_id = :uid""" ), {"id": kid, "uid": user.email or "anonymous"}) - await db.commit() if res.rowcount == 0: raise HTTPException(status_code=404, detail="Not found") return {"id": kid, "status": "active"} - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/email/core.py b/apps/services/gateway/gateway/routes/email/core.py index a6cd51dbe..dc2dce10d 100644 --- a/apps/services/gateway/gateway/routes/email/core.py +++ b/apps/services/gateway/gateway/routes/email/core.py @@ -20,6 +20,17 @@ # The shared gateway engine (BO-10) — see the DB section below. from gateway.db import get_session_factory as _get_session_factory + +# The shared tenant-bound seam (BO-10 → MT-1c/H2). `_tenant_session` IS +# `acb_common.db.tenant_session`, aliased per-package for the same reason +# `_get_db` was: every submodule imports it from here BY NAME, which is the +# seam the hermetic tests patch per module. The tenant comes from the request +# context — bound once in `_with_resolved_access` — so no call site passes +# one (H2). A call outside a bound request raises `TenantUnbound` rather than +# defaulting: fail closed, never "the usual org". Background jobs, webhooks +# and the OAuth callback stay on `_get_db` below until H4/H6 thread an +# explicit tenant to them — service identity binds NO ambient tenant. +from gateway.db import tenant_session as _tenant_session from pydantic import BaseModel from sqlalchemy import text from acb_auth import require_feature_router @@ -371,7 +382,12 @@ async def hydrate_message_body(db: Any, message_id: str, user_email: str) -> str ), {"id": message_id, "bt": body_text, "bh": body_html}, ) - await db.commit() + # No commit here: the CALLER owns the transaction boundary. Converted + # request handlers pass a `_tenant_session` session, and a mid-block + # commit would end that transaction and silently drop the tenant GUC + # for everything after it (H2); background callers commit their own + # session as before, and a rolled-back hydrate persist just re-fetches + # next time — it is a cache write, not state. return body_text except HTTPException: raise diff --git a/apps/services/gateway/gateway/routes/email/digest.py b/apps/services/gateway/gateway/routes/email/digest.py index 6861183aa..48fc5ff4b 100644 --- a/apps/services/gateway/gateway/routes/email/digest.py +++ b/apps/services/gateway/gateway/routes/email/digest.py @@ -16,6 +16,7 @@ from gateway.routes.email.core import ( _assert_account_owner, _get_db, + _tenant_session, _llm_json, _log, provider_session, @@ -600,16 +601,13 @@ async def get_digest( user: UserContext = Depends(get_current_user), ): """Generate an inbox digest for the account (day or week window).""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") days = 7 if period == "week" else 1 cats = await _configured_categories(db, account_id) # The in-app view is the DASHBOARD projection: full lists + ids so # every row can open its thread. The scheduled email keeps small caps. return await _generate_digest(db, account_id, days, cats, full=True) - finally: - await db.close() class DigestSendRequest(BaseModel): @@ -623,8 +621,7 @@ async def send_digest( user: UserContext = Depends(get_current_user), ): """Generate the digest and email it to the account's own address.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, req.account_id, user.email or "anonymous") days = 7 if req.period == "week" else 1 cats = await _configured_categories(db, req.account_id) @@ -645,10 +642,7 @@ async def send_digest( "UPDATE email_assistant_settings SET last_digest_at = now() " "WHERE account_id = :aid" ), {"aid": req.account_id}) - await db.commit() return {"sent": True, "to": sess.owner_email} - finally: - await db.close() async def _maybe_send_digest(account_id: str) -> None: @@ -658,6 +652,8 @@ async def _maybe_send_digest(account_id: str) -> None: before that UTC time), digest_day_of_week (WEEKLY only; 0=Sun…6=Sat), digest_categories (which categories to include) and digest_send_to_email. """ + # H4: scheduler-run digest tick — no ambient tenant; needs an explicit + # tenant derived from the email_accounts row before conversion. db = await _get_db() try: row = (await db.execute(text( diff --git a/apps/services/gateway/gateway/routes/email/scheduler_hooks.py b/apps/services/gateway/gateway/routes/email/scheduler_hooks.py index 95d7f864f..170c7dfdd 100644 --- a/apps/services/gateway/gateway/routes/email/scheduler_hooks.py +++ b/apps/services/gateway/gateway/routes/email/scheduler_hooks.py @@ -24,6 +24,8 @@ async def auto_run_rules_for_account(account_id: str) -> None: from gateway.routes.email.automation.runner import _run_rules_job from sqlalchemy import text + # H4: scheduler post-sync hook — runs outside any request; no ambient + # tenant to inherit (the runbook forbids jobs inheriting one). db = await _get_db() try: settings = ( @@ -159,6 +161,8 @@ async def learn_label_changes(account_id: str, changes: list) -> None: learn_from_label_change_events, ) + # H4: scheduler post-sync hook — runs outside any request; no ambient + # tenant to inherit. db = await _get_db() try: await learn_from_label_change_events(db, account_id, changes) diff --git a/apps/services/gateway/gateway/routes/email/transport/accounts.py b/apps/services/gateway/gateway/routes/email/transport/accounts.py index ce628e345..b8067cbea 100644 --- a/apps/services/gateway/gateway/routes/email/transport/accounts.py +++ b/apps/services/gateway/gateway/routes/email/transport/accounts.py @@ -8,7 +8,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException, status -from gateway.routes.email.core import _default_label, _get_db, router +from gateway.routes.email.core import _default_label, _tenant_session, router from pydantic import BaseModel from sqlalchemy import text @@ -45,8 +45,7 @@ async def list_accounts( user: UserContext = Depends(get_current_user), ): """List all connected email accounts for the current user.""" - db = await _get_db() - try: + async with _tenant_session() as db: result = await db.execute( text( """SELECT id, provider, email_address, label, avatar_color, @@ -86,8 +85,6 @@ async def list_accounts( is_default=bool(row.is_default), )) return accounts - finally: - await db.close() @router.post("/accounts", response_model=EmailAccountModel, status_code=201) @@ -123,8 +120,7 @@ async def create_account( store = get_key_store() encrypted_creds = store.encrypt(json.dumps(req.credentials)) - db = await _get_db() - try: + async with _tenant_session() as db: # Check for duplicate account existing = await db.execute( text( @@ -170,7 +166,6 @@ async def create_account( }, ) created_default = bool(is_default_row.scalar()) - await db.commit() # Start background sync for this account try: @@ -191,8 +186,6 @@ async def create_account( unread_count=0, is_default=created_default, ) - finally: - await db.close() @router.post("/accounts/{account_id}/default", response_model=EmailAccountModel) @@ -205,8 +198,7 @@ async def set_default_account( Clears the flag on the user's other accounts first so the partial unique index (one default per user) is never violated, then sets it on this one. """ - db = await _get_db() - try: + async with _tenant_session() as db: owner = user.email or "anonymous" # Verify ownership before mutating anything. owned = await db.execute( @@ -237,7 +229,6 @@ async def set_default_account( {"id": account_id, "uid": owner}, ) row = result.fetchone() - await db.commit() unread_result = await db.execute( text( @@ -261,8 +252,6 @@ async def set_default_account( unread_count=unread, is_default=bool(row.is_default), ) - finally: - await db.close() @router.delete("/accounts/{account_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -271,8 +260,7 @@ async def delete_account( user: UserContext = Depends(get_current_user), ): """Remove an email account and all its synced messages.""" - db = await _get_db() - try: + async with _tenant_session() as db: owner = user.email or "anonymous" result = await db.execute( text( @@ -300,7 +288,6 @@ async def delete_account( ), {"uid": owner}, ) - await db.commit() # Stop background sync for this account try: @@ -308,8 +295,6 @@ async def delete_account( await remove_account_sync(account_id) except Exception: pass - finally: - await db.close() @router.patch("/accounts/{account_id}", response_model=EmailAccountModel) @@ -319,8 +304,7 @@ async def update_account( user: UserContext = Depends(get_current_user), ): """Update account settings (label, sync toggle).""" - db = await _get_db() - try: + async with _tenant_session() as db: set_clauses = [] params: dict[str, Any] = {"id": account_id, "user_id": user.email or "anonymous"} @@ -349,7 +333,6 @@ async def update_account( row = result.fetchone() if not row: raise HTTPException(status_code=404, detail="Account not found") - await db.commit() # Refresh background sync: start/stop loop for this account try: @@ -372,5 +355,3 @@ async def update_account( last_synced_at=row.last_synced_at.isoformat() if row.last_synced_at else None, ) - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/email/transport/attachments.py b/apps/services/gateway/gateway/routes/email/transport/attachments.py index ccb7a10eb..4323ce441 100644 --- a/apps/services/gateway/gateway/routes/email/transport/attachments.py +++ b/apps/services/gateway/gateway/routes/email/transport/attachments.py @@ -14,7 +14,7 @@ from fastapi.responses import StreamingResponse from gateway.routes.email.core import ( ATTACHMENT_CACHE_TTL_SECS, - _get_db, + _tenant_session, _get_redis, _log, provider_session, @@ -137,92 +137,89 @@ async def download_attachment( Checks Redis cache first (TTL 1 hour) to avoid redundant provider API calls for attachments downloaded multiple times. """ - db = await _get_db() - try: - # Look up attachment and verify user owns the parent message - result = await db.execute( - text( - """SELECT ea.id, ea.filename, ea.mime_type, ea.size_bytes, - ea.provider_attachment_id, ea.storage_path, - em.provider_message_id, em.account_id - FROM email_attachments ea - JOIN email_messages em ON ea.message_id = em.id - JOIN email_accounts p ON em.account_id = p.id - WHERE ea.id = :aid AND p.user_id = :user_id""" - ), - {"aid": attachment_id, "user_id": user.email or "anonymous"}, - ) - row = result.fetchone() - if not row: - raise HTTPException(status_code=404, detail="Attachment not found") - - # Sanitise the filename for the Content-Disposition header — strip quotes - # / CR / LF so an attacker-controlled attachment name can't break out of - # the quoted value (header-injection / filename-spoofing). - safe_name = ( - (row.filename or "attachment") - .replace('"', "'").replace("\n", " ").replace("\r", " ") - ) - - # ── Ownership confirmed — NOW it's safe to serve from the Redis cache. - # Reading the cache before this check was an IDOR: any caller who knew - # an attachment_id could pull another user's cached bytes. ── - redis = await _get_redis() - if redis: - try: - cached = await redis.get(f"email:att:cache:{attachment_id}") - if cached: - return StreamingResponse( - io.BytesIO(cached), - media_type=row.mime_type or "application/octet-stream", - headers={ - "Content-Disposition": ( - f'attachment; filename="{safe_name}"' - ), - "Content-Length": str(len(cached)), - "X-Cache": "HIT", - }, - ) - except Exception: - redis = None # fall through to provider fetch - - # Fetch through the ONE provider dance. This path used to instantiate - # the provider raw — never authenticating (an expired access token just - # 401'd) and never persisting a rotated refresh token (silently dropped, - # so the NEXT request re-authed from a stale token). - async with provider_session( - db, user.email or "anonymous", account_id=str(row.account_id), - ) as sess: - content = await sess.provider.get_attachment( - row.provider_message_id, row.provider_attachment_id + async with _tenant_session() as db: + try: + # Look up attachment and verify user owns the parent message + result = await db.execute( + text( + """SELECT ea.id, ea.filename, ea.mime_type, ea.size_bytes, + ea.provider_attachment_id, ea.storage_path, + em.provider_message_id, em.account_id + FROM email_attachments ea + JOIN email_messages em ON ea.message_id = em.id + JOIN email_accounts p ON em.account_id = p.id + WHERE ea.id = :aid AND p.user_id = :user_id""" + ), + {"aid": attachment_id, "user_id": user.email or "anonymous"}, + ) + row = result.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Attachment not found") + + # Sanitise the filename for the Content-Disposition header — strip quotes + # / CR / LF so an attacker-controlled attachment name can't break out of + # the quoted value (header-injection / filename-spoofing). + safe_name = ( + (row.filename or "attachment") + .replace('"', "'").replace("\n", " ").replace("\r", " ") ) - await db.commit() # land the rotated-cred persist staged by the session - - # ── Store in Redis cache ── - if redis and content: - try: - cache_key = f"email:att:cache:{attachment_id}" - await redis.setex( - cache_key, ATTACHMENT_CACHE_TTL_SECS, content + + # ── Ownership confirmed — NOW it's safe to serve from the Redis cache. + # Reading the cache before this check was an IDOR: any caller who knew + # an attachment_id could pull another user's cached bytes. ── + redis = await _get_redis() + if redis: + try: + cached = await redis.get(f"email:att:cache:{attachment_id}") + if cached: + return StreamingResponse( + io.BytesIO(cached), + media_type=row.mime_type or "application/octet-stream", + headers={ + "Content-Disposition": ( + f'attachment; filename="{safe_name}"' + ), + "Content-Length": str(len(cached)), + "X-Cache": "HIT", + }, + ) + except Exception: + redis = None # fall through to provider fetch + + # Fetch through the ONE provider dance. This path used to instantiate + # the provider raw — never authenticating (an expired access token just + # 401'd) and never persisting a rotated refresh token (silently dropped, + # so the NEXT request re-authed from a stale token). + async with provider_session( + db, user.email or "anonymous", account_id=str(row.account_id), + ) as sess: + content = await sess.provider.get_attachment( + row.provider_message_id, row.provider_attachment_id ) - except Exception: - pass - - return StreamingResponse( - io.BytesIO(content), - media_type=row.mime_type, - headers={ - "Content-Disposition": ( - f'attachment; filename="{safe_name}"' - ), - "Content-Length": str(len(content)), - "X-Cache": "MISS", - }, - ) - except HTTPException: - raise - except Exception as exc: - _log.error("download_attachment.failed", aid=attachment_id, error=str(exc)[:200]) - raise HTTPException(status_code=500, detail="Failed to download attachment") - finally: - await db.close() + + # ── Store in Redis cache ── + if redis and content: + try: + cache_key = f"email:att:cache:{attachment_id}" + await redis.setex( + cache_key, ATTACHMENT_CACHE_TTL_SECS, content + ) + except Exception: + pass + + return StreamingResponse( + io.BytesIO(content), + media_type=row.mime_type, + headers={ + "Content-Disposition": ( + f'attachment; filename="{safe_name}"' + ), + "Content-Length": str(len(content)), + "X-Cache": "MISS", + }, + ) + except HTTPException: + raise + except Exception as exc: + _log.error("download_attachment.failed", aid=attachment_id, error=str(exc)[:200]) + raise HTTPException(status_code=500, detail="Failed to download attachment") diff --git a/apps/services/gateway/gateway/routes/email/transport/contacts.py b/apps/services/gateway/gateway/routes/email/transport/contacts.py index 5ce660211..38374206c 100644 --- a/apps/services/gateway/gateway/routes/email/transport/contacts.py +++ b/apps/services/gateway/gateway/routes/email/transport/contacts.py @@ -23,7 +23,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, Query -from gateway.routes.email.core import _account_scope, _get_db, _log, router +from gateway.routes.email.core import _account_scope, _tenant_session, _log, router from pydantic import BaseModel from sqlalchemy import text @@ -365,7 +365,9 @@ async def _remember_contact( "links": details.links, "source_message_id": details.source_message_id, })).fetchone() - await db.commit() + # No commit here: the only caller is the converted contact_card (H2), + # whose `_tenant_session` commits on clean exit — a mid-block commit + # would end that transaction and drop the tenant GUC. return row except Exception: _log.debug("contact directory upsert failed for %s", email, exc_info=True) @@ -399,8 +401,7 @@ async def contact_card( if not address: return card - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = {"uid": user.email or "anonymous", "addr": address} scope = _account_scope(account_id, params) # Mail FROM this person. Drafts are excluded: an unsent draft of the @@ -546,8 +547,6 @@ async def contact_card( card.details = details return card - finally: - await db.close() # ── Recipient autocomplete ──────────────────────────────────────────────────── @@ -578,67 +577,65 @@ async def suggest_contacts( needle = (q or "").strip().lower() if not needle: return [] - db = await _get_db() try: - params: dict[str, Any] = { - "uid": user.email or "anonymous", - "aid": account_id or "", - "any": f"%{needle}%", - "pre": f"{needle}%", - "lim": limit, - } - rows = (await db.execute(text( - """ - WITH accts AS ( - SELECT id FROM email_accounts - WHERE user_id = :uid AND (:aid = '' OR id::text = :aid) - ), - cands (email, name, weight, last_at) AS ( - -- People the user has WRITTEN to — the strongest signal. - SELECT LOWER(r->>'email'), NULLIF(TRIM(r->>'name'), ''), - 3, em.received_at - FROM email_messages em - CROSS JOIN LATERAL jsonb_array_elements( - COALESCE(em.to_addresses, '[]'::jsonb) - || COALESCE(em.cc_addresses, '[]'::jsonb)) AS r - WHERE em.account_id IN (SELECT id FROM accts) - AND LOWER(COALESCE(em.folder, '')) = 'sent' - UNION ALL - -- The learned people directory (names parsed from signatures). - SELECT LOWER(ec.email), NULLIF(TRIM(ec.display_name), ''), - 2, ec.updated_at - FROM email_contacts ec - WHERE ec.account_id IN (SELECT id FROM accts) - UNION ALL - -- Everyone who has mailed the user (the senders rollup). - SELECT LOWER(es.email), NULLIF(TRIM(es.name), ''), - 1, es.updated_at - FROM email_senders es - WHERE es.account_id IN (SELECT id FROM accts) - ) - SELECT c.email, - COALESCE(MAX(c.name), '') AS name, - (BOOL_OR(c.email LIKE :pre) - OR BOOL_OR(COALESCE(c.name, '') ILIKE :pre)) AS prefix_hit, - MAX(c.weight) AS weight, - COUNT(*) AS hits, - MAX(c.last_at) AS last_at - FROM cands c - WHERE c.email IS NOT NULL AND POSITION('@' IN c.email) > 1 - AND (c.email LIKE :any OR COALESCE(c.name, '') ILIKE :any) - AND c.email NOT IN ( - SELECT LOWER(email_address) FROM email_accounts - WHERE user_id = :uid) - GROUP BY c.email - ORDER BY prefix_hit DESC, weight DESC, hits DESC, - last_at DESC NULLS LAST - LIMIT :lim - """ - ), params)).fetchall() - return [ContactSuggestion(email=r.email, name=r.name or "") - for r in rows] + async with _tenant_session() as db: + params: dict[str, Any] = { + "uid": user.email or "anonymous", + "aid": account_id or "", + "any": f"%{needle}%", + "pre": f"{needle}%", + "lim": limit, + } + rows = (await db.execute(text( + """ + WITH accts AS ( + SELECT id FROM email_accounts + WHERE user_id = :uid AND (:aid = '' OR id::text = :aid) + ), + cands (email, name, weight, last_at) AS ( + -- People the user has WRITTEN to — the strongest signal. + SELECT LOWER(r->>'email'), NULLIF(TRIM(r->>'name'), ''), + 3, em.received_at + FROM email_messages em + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE(em.to_addresses, '[]'::jsonb) + || COALESCE(em.cc_addresses, '[]'::jsonb)) AS r + WHERE em.account_id IN (SELECT id FROM accts) + AND LOWER(COALESCE(em.folder, '')) = 'sent' + UNION ALL + -- The learned people directory (names parsed from signatures). + SELECT LOWER(ec.email), NULLIF(TRIM(ec.display_name), ''), + 2, ec.updated_at + FROM email_contacts ec + WHERE ec.account_id IN (SELECT id FROM accts) + UNION ALL + -- Everyone who has mailed the user (the senders rollup). + SELECT LOWER(es.email), NULLIF(TRIM(es.name), ''), + 1, es.updated_at + FROM email_senders es + WHERE es.account_id IN (SELECT id FROM accts) + ) + SELECT c.email, + COALESCE(MAX(c.name), '') AS name, + (BOOL_OR(c.email LIKE :pre) + OR BOOL_OR(COALESCE(c.name, '') ILIKE :pre)) AS prefix_hit, + MAX(c.weight) AS weight, + COUNT(*) AS hits, + MAX(c.last_at) AS last_at + FROM cands c + WHERE c.email IS NOT NULL AND POSITION('@' IN c.email) > 1 + AND (c.email LIKE :any OR COALESCE(c.name, '') ILIKE :any) + AND c.email NOT IN ( + SELECT LOWER(email_address) FROM email_accounts + WHERE user_id = :uid) + GROUP BY c.email + ORDER BY prefix_hit DESC, weight DESC, hits DESC, + last_at DESC NULLS LAST + LIMIT :lim + """ + ), params)).fetchall() + return [ContactSuggestion(email=r.email, name=r.name or "") + for r in rows] except Exception as exc: # noqa: BLE001 — typeahead is best-effort _log.warning("email.contact_suggest_failed", error=str(exc)[:160]) return [] - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/email/transport/folders.py b/apps/services/gateway/gateway/routes/email/transport/folders.py index feee43122..3128c885a 100644 --- a/apps/services/gateway/gateway/routes/email/transport/folders.py +++ b/apps/services/gateway/gateway/routes/email/transport/folders.py @@ -7,7 +7,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException from gateway.routes.email.core import ( - _get_db, + _tenant_session, _instantiate_provider, _log, _persist_rotated_creds, @@ -58,74 +58,71 @@ async def list_folders( Fetches live from the provider (Gmail labels, Outlook folders, IMAP mailboxes) so the UI always shows the current folder structure. """ - db = await _get_db() - try: - result = await db.execute( - text( - """SELECT provider, credentials_encrypted - FROM email_accounts - WHERE id = :id AND user_id = :user_id""" - ), - {"id": account_id, "user_id": user.email or "anonymous"}, - ) - row = result.fetchone() - if not row: - raise HTTPException(status_code=404, detail="Account not found") - - # Decrypt credentials - from acb_llm.key_store import get_key_store - store = get_key_store() - creds = json.loads(store.decrypt(row.credentials_encrypted)) - - # Instantiate provider - provider = _instantiate_provider(row.provider, creds) - - # Authenticate and fetch folders - if not await provider.authenticate(): - raise HTTPException( - status_code=401, - detail="Email account authentication failed — token may have expired", + async with _tenant_session() as db: + try: + result = await db.execute( + text( + """SELECT provider, credentials_encrypted + FROM email_accounts + WHERE id = :id AND user_id = :user_id""" + ), + {"id": account_id, "user_id": user.email or "anonymous"}, ) + row = result.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Account not found") - folders = await provider.list_folders() + # Decrypt credentials + from acb_llm.key_store import get_key_store + store = get_key_store() + creds = json.loads(store.decrypt(row.credentials_encrypted)) - # Persist rotated OAuth tokens so a later sync doesn't reuse a stale one. - if provider.credentials_dirty(): - await db.execute( - text( - """UPDATE email_accounts - SET credentials_encrypted = :creds, updated_at = now() - WHERE id = :id""" - ), - { - "id": account_id, - "creds": store.encrypt( - json.dumps(provider.export_credentials()) + # Instantiate provider + provider = _instantiate_provider(row.provider, creds) + + # Authenticate and fetch folders + if not await provider.authenticate(): + raise HTTPException( + status_code=401, + detail="Email account authentication failed — token may have expired", + ) + + folders = await provider.list_folders() + + # Persist rotated OAuth tokens so a later sync doesn't reuse a stale one. + if provider.credentials_dirty(): + await db.execute( + text( + """UPDATE email_accounts + SET credentials_encrypted = :creds, updated_at = now() + WHERE id = :id""" ), - }, - ) - await db.commit() - - return [ - EmailFolderModel( - provider_folder_id=f.provider_folder_id, - name=f.name, - type=f.type, - message_count=f.message_count, - unread_count=f.unread_count, + { + "id": account_id, + "creds": store.encrypt( + json.dumps(provider.export_credentials()) + ), + }, + ) + + return [ + EmailFolderModel( + provider_folder_id=f.provider_folder_id, + name=f.name, + type=f.type, + message_count=f.message_count, + unread_count=f.unread_count, + ) + for f in folders + ] + except HTTPException: + raise + except Exception as exc: + _log.error("list_folders.failed", account_id=account_id, error=str(exc)[:200]) + raise HTTPException( + status_code=500, + detail=f"Failed to list folders: {str(exc)}", ) - for f in folders - ] - except HTTPException: - raise - except Exception as exc: - _log.error("list_folders.failed", account_id=account_id, error=str(exc)[:200]) - raise HTTPException( - status_code=500, - detail=f"Failed to list folders: {str(exc)}", - ) - finally: - await db.close() @router.post( @@ -142,71 +139,68 @@ async def create_folder( provider returns the existing folder if one with the same name already exists (Outlook get-or-create, Gmail label create). """ - db = await _get_db() - try: - row = (await db.execute( - text( - """SELECT provider, credentials_encrypted - FROM email_accounts - WHERE id = :id AND user_id = :user_id""" - ), - {"id": account_id, "user_id": user.email or "anonymous"}, - )).fetchone() - if not row: - raise HTTPException(status_code=404, detail="Account not found") - - from acb_llm.key_store import get_key_store - store = get_key_store() - creds = json.loads(store.decrypt(row.credentials_encrypted)) - provider = _instantiate_provider(row.provider, creds) - if not await provider.authenticate(): - raise HTTPException( - status_code=401, - detail="Email account authentication failed — reconnect.", - ) - + async with _tenant_session() as db: try: - folder = await provider.create_folder(req.name) - except NotImplementedError: + row = (await db.execute( + text( + """SELECT provider, credentials_encrypted + FROM email_accounts + WHERE id = :id AND user_id = :user_id""" + ), + {"id": account_id, "user_id": user.email or "anonymous"}, + )).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Account not found") + + from acb_llm.key_store import get_key_store + store = get_key_store() + creds = json.loads(store.decrypt(row.credentials_encrypted)) + provider = _instantiate_provider(row.provider, creds) + if not await provider.authenticate(): + raise HTTPException( + status_code=401, + detail="Email account authentication failed — reconnect.", + ) + + try: + folder = await provider.create_folder(req.name) + except NotImplementedError: + raise HTTPException( + status_code=400, + detail="This account type doesn't support creating folders.", + ) + + # Mirror into email_folders so the folder is queryable immediately. + await db.execute( + text( + """INSERT INTO email_folders + (account_id, provider_folder_id, name, type) + VALUES (:aid, :pid, :name, :type) + ON CONFLICT (account_id, provider_folder_id) + DO UPDATE SET name = EXCLUDED.name""" + ), + {"aid": account_id, "pid": folder.provider_folder_id, + "name": folder.name, "type": folder.type}, + ) + await _persist_rotated_creds(db, store, account_id, provider) + + return EmailFolderModel( + provider_folder_id=folder.provider_folder_id, + name=folder.name, + type=folder.type, + message_count=folder.message_count, + unread_count=folder.unread_count, + ) + except HTTPException: + raise + except Exception as exc: + _log.error( + "create_folder.failed", account_id=account_id, error=str(exc)[:200] + ) raise HTTPException( - status_code=400, - detail="This account type doesn't support creating folders.", + status_code=500, detail=f"Failed to create folder: {str(exc)}" ) - # Mirror into email_folders so the folder is queryable immediately. - await db.execute( - text( - """INSERT INTO email_folders - (account_id, provider_folder_id, name, type) - VALUES (:aid, :pid, :name, :type) - ON CONFLICT (account_id, provider_folder_id) - DO UPDATE SET name = EXCLUDED.name""" - ), - {"aid": account_id, "pid": folder.provider_folder_id, - "name": folder.name, "type": folder.type}, - ) - await _persist_rotated_creds(db, store, account_id, provider) - await db.commit() - - return EmailFolderModel( - provider_folder_id=folder.provider_folder_id, - name=folder.name, - type=folder.type, - message_count=folder.message_count, - unread_count=folder.unread_count, - ) - except HTTPException: - raise - except Exception as exc: - _log.error( - "create_folder.failed", account_id=account_id, error=str(exc)[:200] - ) - raise HTTPException( - status_code=500, detail=f"Failed to create folder: {str(exc)}" - ) - finally: - await db.close() - @router.get("/accounts/{account_id}/labels", response_model=list[LabelInfo]) async def list_labels( @@ -219,60 +213,57 @@ async def list_labels( ('preset0'..'preset24') or null. Gmail = user labels, Outlook = master categories, IMAP = none. """ - db = await _get_db() - try: - result = await db.execute( - text( - """SELECT provider, credentials_encrypted - FROM email_accounts - WHERE id = :id AND user_id = :user_id""" - ), - {"id": account_id, "user_id": user.email or "anonymous"}, - ) - row = result.fetchone() - if not row: - raise HTTPException(status_code=404, detail="Account not found") - - from acb_llm.key_store import get_key_store - store = get_key_store() - creds = json.loads(store.decrypt(row.credentials_encrypted)) - + async with _tenant_session() as db: try: - provider = _instantiate_provider(row.provider, creds) - except HTTPException: - # list_labels degrades gracefully for an unknown provider. - return [] - - if not await provider.authenticate(): - raise HTTPException( - status_code=401, - detail="Email account authentication failed — reconnect.", - ) - labels = await provider.list_labels() - - if provider.credentials_dirty(): - await db.execute( + result = await db.execute( text( - """UPDATE email_accounts - SET credentials_encrypted = :creds, updated_at = now() - WHERE id = :id""" + """SELECT provider, credentials_encrypted + FROM email_accounts + WHERE id = :id AND user_id = :user_id""" ), - { - "id": account_id, - "creds": store.encrypt( - json.dumps(provider.export_credentials()) - ), - }, + {"id": account_id, "user_id": user.email or "anonymous"}, ) - await db.commit() - return labels - except HTTPException: - raise - except Exception as exc: - _log.error("list_labels.failed", account_id=account_id, error=str(exc)[:200]) - raise HTTPException(status_code=500, detail=f"Failed to list labels: {exc}") - finally: - await db.close() + row = result.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Account not found") + + from acb_llm.key_store import get_key_store + store = get_key_store() + creds = json.loads(store.decrypt(row.credentials_encrypted)) + + try: + provider = _instantiate_provider(row.provider, creds) + except HTTPException: + # list_labels degrades gracefully for an unknown provider. + return [] + + if not await provider.authenticate(): + raise HTTPException( + status_code=401, + detail="Email account authentication failed — reconnect.", + ) + labels = await provider.list_labels() + + if provider.credentials_dirty(): + await db.execute( + text( + """UPDATE email_accounts + SET credentials_encrypted = :creds, updated_at = now() + WHERE id = :id""" + ), + { + "id": account_id, + "creds": store.encrypt( + json.dumps(provider.export_credentials()) + ), + }, + ) + return labels + except HTTPException: + raise + except Exception as exc: + _log.error("list_labels.failed", account_id=account_id, error=str(exc)[:200]) + raise HTTPException(status_code=500, detail=f"Failed to list labels: {exc}") @router.patch("/accounts/{account_id}/labels", response_model=LabelInfo) @@ -284,45 +275,42 @@ async def set_label_color( """Set a label/category's colour on the provider (Gmail label / Outlook master category). The colour is a canonical preset token; it round-trips to the real mailbox. No-op for providers without label colours (IMAP).""" - db = await _get_db() - try: - result = await db.execute( - text( - """SELECT provider, credentials_encrypted - FROM email_accounts - WHERE id = :id AND user_id = :user_id""" - ), - {"id": account_id, "user_id": user.email or "anonymous"}, - ) - row = result.fetchone() - if not row: - raise HTTPException(status_code=404, detail="Account not found") - - from acb_llm.key_store import get_key_store - store = get_key_store() - creds = json.loads(store.decrypt(row.credentials_encrypted)) - provider = _instantiate_provider(row.provider, creds) - - if not await provider.authenticate(): + async with _tenant_session() as db: + try: + result = await db.execute( + text( + """SELECT provider, credentials_encrypted + FROM email_accounts + WHERE id = :id AND user_id = :user_id""" + ), + {"id": account_id, "user_id": user.email or "anonymous"}, + ) + row = result.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Account not found") + + from acb_llm.key_store import get_key_store + store = get_key_store() + creds = json.loads(store.decrypt(row.credentials_encrypted)) + provider = _instantiate_provider(row.provider, creds) + + if not await provider.authenticate(): + raise HTTPException( + status_code=401, + detail="Email account authentication failed — reconnect.", + ) + await provider.set_label_color(req.name, req.color) + await _persist_rotated_creds(db, store, account_id, provider) + return LabelInfo(name=req.name, color=req.color) + except HTTPException: + raise + except Exception as exc: + _log.error( + "set_label_color.failed", account_id=account_id, error=str(exc)[:200] + ) raise HTTPException( - status_code=401, - detail="Email account authentication failed — reconnect.", + status_code=500, detail=f"Failed to set label colour: {exc}" ) - await provider.set_label_color(req.name, req.color) - await _persist_rotated_creds(db, store, account_id, provider) - await db.commit() - return LabelInfo(name=req.name, color=req.color) - except HTTPException: - raise - except Exception as exc: - _log.error( - "set_label_color.failed", account_id=account_id, error=str(exc)[:200] - ) - raise HTTPException( - status_code=500, detail=f"Failed to set label colour: {exc}" - ) - finally: - await db.close() @router.post("/accounts/{account_id}/backfill") @@ -340,87 +328,83 @@ async def backfill_folder( """ from email_ingestion.providers.base import canonical_folder - db = await _get_db() - try: - result = await db.execute( - text( - """SELECT provider, credentials_encrypted - FROM email_accounts - WHERE id = :id AND user_id = :user_id""" - ), - {"id": account_id, "user_id": user.email or "anonymous"}, - ) - row = result.fetchone() - if not row: - raise HTTPException(status_code=404, detail="Account not found") - - from acb_llm.key_store import get_key_store - store = get_key_store() - creds = json.loads(store.decrypt(row.credentials_encrypted)) - - provider = _instantiate_provider(row.provider, creds) - - if not await provider.authenticate(): - raise HTTPException( - status_code=401, - detail="Email account authentication failed — reconnect.", + async with _tenant_session() as db: + try: + result = await db.execute( + text( + """SELECT provider, credentials_encrypted + FROM email_accounts + WHERE id = :id AND user_id = :user_id""" + ), + {"id": account_id, "user_id": user.email or "anonymous"}, ) + row = result.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Account not found") - canon_req = canonical_folder(req.folder) + from acb_llm.key_store import get_key_store + store = get_key_store() + creds = json.loads(store.decrypt(row.credentials_encrypted)) - # Resolve the provider-native folder id/label for the canonical key so - # both system and user folders page correctly (Gmail label id, Graph - # folder id, IMAP mailbox name). - provider_folder = req.folder - try: - for f in await provider.list_folders(): - if canonical_folder(f.name) == canon_req: - provider_folder = f.provider_folder_id + provider = _instantiate_provider(row.provider, creds) + + if not await provider.authenticate(): + raise HTTPException( + status_code=401, + detail="Email account authentication failed — reconnect.", + ) + + canon_req = canonical_folder(req.folder) + + # Resolve the provider-native folder id/label for the canonical key so + # both system and user folders page correctly (Gmail label id, Graph + # folder id, IMAP mailbox name). + provider_folder = req.folder + try: + for f in await provider.list_folders(): + if canonical_folder(f.name) == canon_req: + provider_folder = f.provider_folder_id + break + except Exception: + pass + + token = req.page_token + synced = 0 + for _ in range(req.max_pages): + msgs, token = await provider.list_messages( + folder=provider_folder, + max_results=100, + page_token=token, + canonical_override=canon_req, + ) + for msg in msgs: + await _upsert_message(db, account_id, msg) + synced += 1 + if not token: break - except Exception: - pass - - token = req.page_token - synced = 0 - for _ in range(req.max_pages): - msgs, token = await provider.list_messages( - folder=provider_folder, - max_results=100, - page_token=token, - canonical_override=canon_req, - ) - for msg in msgs: - await _upsert_message(db, account_id, msg) - synced += 1 - if not token: - break - await db.commit() - - if provider.credentials_dirty(): - await db.execute( - text( - """UPDATE email_accounts - SET credentials_encrypted = :creds, updated_at = now() - WHERE id = :id""" - ), - { - "id": account_id, - "creds": store.encrypt( - json.dumps(provider.export_credentials()) + + if provider.credentials_dirty(): + await db.execute( + text( + """UPDATE email_accounts + SET credentials_encrypted = :creds, updated_at = now() + WHERE id = :id""" ), - }, - ) - await db.commit() - - return { - "synced": synced, - "next_page_token": token, - "exhausted": token is None, - } - except HTTPException: - raise - except Exception as exc: - _log.error("backfill.failed", account_id=account_id, error=str(exc)[:200]) - raise HTTPException(status_code=500, detail=f"Backfill failed: {str(exc)}") - finally: - await db.close() + { + "id": account_id, + "creds": store.encrypt( + json.dumps(provider.export_credentials()) + ), + }, + ) + + return { + "synced": synced, + "next_page_token": token, + "exhausted": token is None, + } + except HTTPException: + raise + except Exception as exc: + _log.error("backfill.failed", account_id=account_id, error=str(exc)[:200]) + raise HTTPException(status_code=500, detail=f"Backfill failed: {str(exc)}") diff --git a/apps/services/gateway/gateway/routes/email/transport/messages.py b/apps/services/gateway/gateway/routes/email/transport/messages.py index fb66428d3..87e693765 100644 --- a/apps/services/gateway/gateway/routes/email/transport/messages.py +++ b/apps/services/gateway/gateway/routes/email/transport/messages.py @@ -20,7 +20,7 @@ _assert_account_owner, _fetch_attachments, _fetch_attachments_batch, - _get_db, + _tenant_session, _instantiate_provider, _log, _persist_rotated_creds, @@ -81,8 +81,7 @@ async def message_facets( Counts, not booleans, because the same query yields them and "Newsletter 1,204" is the number that tells you where to start. """ - db = await _get_db() - try: + async with _tenant_session() as db: params: dict[str, Any] = {"user_id": user.email or "anonymous"} where = ["ea.user_id = :user_id"] if account_id: @@ -132,8 +131,6 @@ async def message_facets( # still lights up the Newsletter chip. "labels": {r.label: int(r.n or 0) for r in rows}, } - finally: - await db.close() @router.get("/messages") @@ -179,8 +176,7 @@ async def list_messages( assistant's inbox-query tools leave it off so their counts stay per-message. Ignored when ``thread_id`` is set (the conversation view wants every message). """ - db = await _get_db() - try: + async with _tenant_session() as db: where_clauses = [ "ea.user_id = :user_id" ] @@ -392,8 +388,6 @@ async def list_messages( "page": page, "page_size": page_size, } - finally: - await db.close() # Sender categories that are bulk/automated and never "important to check". @@ -417,8 +411,7 @@ async def priority_inbox( Bulk/automated senders (Newsletter / Marketing / Cold Email / Notification) are excluded so the list stays high-signal. Returns one row per thread with the reason it ranked, newest-first within score.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, user.email or "anonymous") rows = (await db.execute(text( """WITH latest AS ( @@ -486,8 +479,6 @@ async def priority_inbox( "reason": ", ".join(reasons) or "recent", }) return {"emails": out, "count": len(out), "days": days} - finally: - await db.close() async def _hydrate_attachments( @@ -529,7 +520,9 @@ async def _hydrate_attachments( }, ) await _persist_rotated_creds(db, store, account_id, provider) - await db.commit() + # No commit here: the only caller is the converted get_message (H2), + # whose `_tenant_session` commits on clean exit — a mid-block commit + # would end that transaction and drop the tenant GUC. return await _fetch_attachments(db, message_id) except Exception as exc: # noqa: BLE001 _log.warning( @@ -559,8 +552,7 @@ async def message_summaries( ids = [i for i in (req.ids or []) if i] if not ids: return {"summaries": []} - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text( """SELECT em.id, em.thread_id, em.subject, em.from_address, @@ -588,8 +580,6 @@ async def message_summaries( # Preserve caller order (and drop ids the user doesn't own). summaries = [by_id[i] for i in ids if i in by_id] return {"summaries": summaries} - finally: - await db.close() @router.get("/messages/{message_id}", response_model=EmailMessageModel) @@ -598,8 +588,7 @@ async def get_message( user: UserContext = Depends(get_current_user), ): """Get full email detail.""" - db = await _get_db() - try: + async with _tenant_session() as db: result = await db.execute( text( """SELECT em.id, em.provider_message_id, em.thread_id, @@ -629,7 +618,6 @@ async def get_message( ), {"id": message_id}, ) - await db.commit() msg = _row_to_message(row) @@ -685,7 +673,6 @@ async def get_message( }, ) await _persist_rotated_creds(db, store, account_id, provider) - await db.commit() msg.body_text = body_text msg.body_html = body_html msg.has_attachments = full.has_attachments @@ -703,8 +690,6 @@ async def get_message( db, message_id, user.email or "anonymous" ) return msg - finally: - await db.close() @router.patch("/messages/{message_id}", response_model=EmailMessageModel) @@ -722,8 +707,7 @@ async def update_message( name for name in updates.add_labels if name.strip().lower() not in RESERVED_INDICATORS ] - db = await _get_db() - try: + async with _tenant_session() as db: # Verify ownership result = await db.execute( text( @@ -760,7 +744,6 @@ async def update_message( ), params, ) - await db.commit() # Apply label add/remove locally — the categories column drives the # label chips shown in the UI. @@ -784,7 +767,6 @@ async def update_message( ), {"id": message_id, "cats": cats}, ) - await db.commit() # ── Two-way sync: push the change to the provider (best-effort) ── # The local DB is already updated; if the provider write fails we keep the @@ -821,7 +803,6 @@ async def update_message( ), {"pid": new_pid, "id": message_id}, ) - await db.commit() provider_msg_id = new_pid if updates.add_labels or updates.remove_labels: await provider.set_labels( @@ -830,9 +811,9 @@ async def update_message( remove=updates.remove_labels or [], ) await _persist_rotated_creds(db, store, account_id, provider) - await db.commit() except Exception as exc: # noqa: BLE001 - # Best-effort: the local change is already committed, so a provider + # Best-effort: the local change lands at the tenant session's + # clean-exit commit either way, so a provider # failure (incl. an HTTPException from the provider lookup/write) must # NOT fail the user's action — just log it. _log.warning( @@ -842,8 +823,6 @@ async def update_message( # Return updated message return await get_message(message_id, user) - finally: - await db.close() class SnoozeRequest(BaseModel): @@ -864,8 +843,7 @@ async def snooze_message( no thread is stamped on its own). It's app-local — there is no provider concept of snooze — so nothing is pushed upstream. The conversation reappears on its own once the time passes (query-time wake; no scheduler).""" - db = await _get_db() - try: + async with _tenant_session() as db: row = (await db.execute(text( """SELECT em.account_id, em.thread_id FROM email_messages em @@ -887,7 +865,6 @@ async def snooze_message( f"""UPDATE email_messages SET snoozed_until = :until, updated_at = now() WHERE account_id = :acc AND {scope}""" ), params) - await db.commit() return { "ok": True, "message_id": message_id, @@ -895,8 +872,6 @@ async def snooze_message( "snoozed_until": until.isoformat() if until else None, "affected": res.rowcount, } - finally: - await db.close() @router.delete("/messages/{message_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -905,8 +880,7 @@ async def delete_message( user: UserContext = Depends(get_current_user), ): """Move email to trash (locally and on the provider).""" - db = await _get_db() - try: + async with _tenant_session() as db: result = await db.execute( text( """UPDATE email_messages SET folder = 'trash', updated_at = now() @@ -919,7 +893,6 @@ async def delete_message( ) if result.rowcount == 0: raise HTTPException(status_code=404, detail="Message not found") - await db.commit() # ── Two-way sync: trash on the provider too (best-effort) ── try: @@ -940,7 +913,6 @@ async def delete_message( {"pid": new_pid, "id": message_id}, ) await _persist_rotated_creds(db, store, account_id, provider) - await db.commit() except Exception as exc: # noqa: BLE001 # Best-effort: local trash already committed; never fail the user's # action on a provider error (incl. provider-raised HTTPException). @@ -948,8 +920,6 @@ async def delete_message( "delete_message.provider_sync_failed", message_id=message_id, error=str(exc)[:200], ) - finally: - await db.close() @router.get("/messages/{message_id}/full-body") @@ -963,56 +933,54 @@ async def get_full_body( was capped to stay within storage limits. This endpoint reaches out to Gmail/Microsoft/IMAP live to retrieve the complete message body. """ - db = await _get_db() - try: - result = await db.execute( - text( - """SELECT em.provider_message_id, p.provider, - p.credentials_encrypted - FROM email_messages em - JOIN email_accounts p ON em.account_id = p.id - WHERE em.id = :mid AND p.user_id = :user_id""" - ), - {"mid": message_id, "user_id": user.email or "anonymous"}, - ) - row = result.fetchone() - if not row: - raise HTTPException(status_code=404, detail="Message not found") - - # Decrypt credentials - from acb_llm.key_store import get_key_store - store = get_key_store() - creds = json.loads(store.decrypt(row.credentials_encrypted)) - - # Instantiate provider - provider = _instantiate_provider(row.provider, creds) + async with _tenant_session() as db: + try: + result = await db.execute( + text( + """SELECT em.provider_message_id, p.provider, + p.credentials_encrypted + FROM email_messages em + JOIN email_accounts p ON em.account_id = p.id + WHERE em.id = :mid AND p.user_id = :user_id""" + ), + {"mid": message_id, "user_id": user.email or "anonymous"}, + ) + row = result.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Message not found") + + # Decrypt credentials + from acb_llm.key_store import get_key_store + store = get_key_store() + creds = json.loads(store.decrypt(row.credentials_encrypted)) + + # Instantiate provider + provider = _instantiate_provider(row.provider, creds) + + if not await provider.authenticate(): + raise HTTPException( + status_code=401, + detail="Email account authentication failed", + ) - if not await provider.authenticate(): + msg = await provider.get_message(row.provider_message_id) + return { + "message_id": message_id, + "body_text": msg.body_text, + "body_html": msg.body_html, + "subject": msg.subject, + "from": ( + f"{msg.from_address.name} <{msg.from_address.email}>" + if msg.from_address else "" + ), + } + except HTTPException: + raise + except Exception as exc: + _log.error( + "full_body.failed", message_id=message_id, error=str(exc)[:200] + ) raise HTTPException( - status_code=401, - detail="Email account authentication failed", + status_code=500, + detail=f"Failed to fetch full body: {str(exc)}", ) - - msg = await provider.get_message(row.provider_message_id) - return { - "message_id": message_id, - "body_text": msg.body_text, - "body_html": msg.body_html, - "subject": msg.subject, - "from": ( - f"{msg.from_address.name} <{msg.from_address.email}>" - if msg.from_address else "" - ), - } - except HTTPException: - raise - except Exception as exc: - _log.error( - "full_body.failed", message_id=message_id, error=str(exc)[:200] - ) - raise HTTPException( - status_code=500, - detail=f"Failed to fetch full body: {str(exc)}", - ) - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/email/transport/oauth.py b/apps/services/gateway/gateway/routes/email/transport/oauth.py index 35bef764f..d052c9328 100644 --- a/apps/services/gateway/gateway/routes/email/transport/oauth.py +++ b/apps/services/gateway/gateway/routes/email/transport/oauth.py @@ -218,6 +218,11 @@ async def oauth_callback( creds_json = json.dumps(token_data) encrypted_creds = store.encrypt(creds_json) + # H4/H6: service-identity route — the OAuth callback is a provider + # browser redirect with no member session (trust = HMAC-signed state), + # so no ambient tenant is bound; needs an explicit tenant derived from + # the app_user row of the user encoded in the signed state before + # conversion. db = await _get_db() try: # Check if an account already exists for this user+email. If so, this diff --git a/apps/services/gateway/gateway/routes/email/transport/search.py b/apps/services/gateway/gateway/routes/email/transport/search.py index b489f1b61..523204c63 100644 --- a/apps/services/gateway/gateway/routes/email/transport/search.py +++ b/apps/services/gateway/gateway/routes/email/transport/search.py @@ -43,7 +43,7 @@ KNOWN_LABELS_LOWER, UNCATEGORIZED_SQL, _account_scope, - _get_db, + _tenant_session, _row_to_message, folder_scope, router, @@ -202,8 +202,7 @@ async def search_messages( ``all`` (everything but junk/trash), or ``starred``; omit it to span every folder. Unless ``account_id`` narrows it, the search spans all the user's accounts.""" - db = await _get_db() - try: + async with _tenant_session() as db: uid = user.email or "anonymous" text_q = (q or "").strip() params: dict[str, Any] = {"uid": uid, "q": text_q} @@ -345,5 +344,3 @@ async def search_messages( "query": text_q, "hybrid": bool(join_sql), } - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/email/transport/send.py b/apps/services/gateway/gateway/routes/email/transport/send.py index 3139ec7f8..d02fe032b 100644 --- a/apps/services/gateway/gateway/routes/email/transport/send.py +++ b/apps/services/gateway/gateway/routes/email/transport/send.py @@ -6,7 +6,7 @@ from acb_auth import UserContext, get_current_user from fastapi import BackgroundTasks, Depends, HTTPException from gateway.routes.email.core import ( - _get_db, + _tenant_session, provider_session, router, ) @@ -98,8 +98,7 @@ async def send_email( user: UserContext = Depends(get_current_user), ): """Send a new email from a connected account.""" - db = await _get_db() - try: + async with _tenant_session() as db: # Ownership check + auth + rotated-cred persist all live in the session # helper (401 on auth failure, 404 on a foreign account). async with provider_session( @@ -169,7 +168,6 @@ async def send_email( ) # Commit the rotated-cred persist the session wrote on clean exit. - await db.commit() # If this was a reply, learn from how the user edited the AI's draft. if req.reply_to_message_id and req.body_text and reply_thread_id: @@ -195,8 +193,6 @@ async def send_email( pass return {"id": msg_id, "ok": True} - finally: - await db.close() class ImportArtifactRequest(BaseModel): diff --git a/apps/services/gateway/gateway/routes/email/transport/sync.py b/apps/services/gateway/gateway/routes/email/transport/sync.py index 9c785a818..f100ee0bb 100644 --- a/apps/services/gateway/gateway/routes/email/transport/sync.py +++ b/apps/services/gateway/gateway/routes/email/transport/sync.py @@ -15,6 +15,7 @@ from fastapi.responses import PlainTextResponse from gateway.routes.email.core import ( _get_db, + _tenant_session, _instantiate_provider, _log, _persist_rotated_creds, @@ -205,15 +206,12 @@ async def trigger_sync( the deep-vs-incremental heuristic and label-change capture (the ``learn_label_changes`` hook) all live in the core. """ - db = await _get_db() - try: + async with _tenant_session() as db: own = (await db.execute(text( "SELECT id FROM email_accounts WHERE id = :id AND user_id = :uid" ), {"id": req.account_id, "uid": user.email or "anonymous"})).fetchone() if not own: raise HTTPException(status_code=404, detail="Account not found") - finally: - await db.close() return await _run_manual_sync(req.account_id, background, full=req.full) @@ -262,8 +260,7 @@ async def resync_account( pulls its full history. With ``purge=true`` it first DELETES the account's local messages (cascades attachments) before re-fetching — use this when local data is corrupt or badly out of sync. Returns the sync result.""" - db = await _get_db() - try: + async with _tenant_session() as db: own = (await db.execute(text( "SELECT id FROM email_accounts WHERE id = :id AND user_id = :uid" ), {"id": account_id, "uid": user.email or "anonymous"})).fetchone() @@ -279,9 +276,6 @@ async def resync_account( "UPDATE email_accounts SET last_history_id = NULL, updated_at = now() " "WHERE id = :id" ), {"id": account_id}) - await db.commit() - finally: - await db.close() # Re-fetch through the shared core, forcing the DEEP (≈1-year, all-folder) # backfill — ``full=True`` overrides the ``initial_sync_done`` gate so an # already-initialised account actually pulls its older mail instead of just @@ -333,6 +327,10 @@ async def microsoft_webhook(request: Request, background: BackgroundTasks): client_state = n.get("clientState") if not sub_id: continue + # H4/H6: service-identity route — Graph change notification carries no + # member session, so no ambient tenant is bound; needs an explicit + # tenant derived from the email_accounts row matched by the + # subscription's clientState/account before conversion. db = await _get_db() try: row = (await db.execute(text( @@ -360,6 +358,8 @@ async def _ensure_subscription(account_id: str) -> None: ).rstrip("/") if not public: return + # H4: scheduler post-sync hook (_ensure_subscription); no ambient + # tenant to inherit. db = await _get_db() try: row = (await db.execute(text( diff --git a/tests/unit/_email_fakes.py b/tests/unit/_email_fakes.py new file mode 100644 index 000000000..5380fd6bb --- /dev/null +++ b/tests/unit/_email_fakes.py @@ -0,0 +1,40 @@ +"""Seam double for the converted ``gateway.routes.email`` handlers (H2). + +Converted request handlers acquire their session via the package's +``_tenant_session`` alias — ``acb_common.db.tenant_session``, an async context +manager that begins a transaction, binds the tenant GUC and commits on clean +exit. ``bind_db`` mirrors only that SHAPE over any test double: it yields the +fake, then ``await``s its ``commit`` (if it has one) on a clean exit — exactly +like the real wrapper, which commits reads too — and commits nothing when the +body raised. Patch it over the SUT module's ``_tenant_session`` BY NAME +(each submodule imports the alias from ``core``, so patch the module you call). + +Not named ``test_*``, so pytest imports it without collecting it. The GUC +plumbing itself is pinned by ``test_tenant_session.py``, not mirrored here. +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import Any + + +def bind_db(db: Any): + """A ``_tenant_session`` double bound to ``db``. + + The returned factory records each entry in ``.calls`` so a test can assert + "no session was opened" (the old ``get_db.assert_not_awaited()`` proxy). + """ + calls: list[int] = [] + + @asynccontextmanager + async def _tenant_session(): + calls.append(1) + yield db + commit = getattr(db, "commit", None) + if commit is not None: + await commit() + + _tenant_session.calls = calls # type: ignore[attr-defined] + _tenant_session.db = db # type: ignore[attr-defined] + return _tenant_session diff --git a/tests/unit/test_db_engine_seam.py b/tests/unit/test_db_engine_seam.py index 77e51ba35..eb4432bd3 100644 --- a/tests/unit/test_db_engine_seam.py +++ b/tests/unit/test_db_engine_seam.py @@ -311,7 +311,11 @@ def test_acb_auth_shares_the_pool() -> None: #: The unconverted remainder OUTSIDE routes/projects at the time the Projects #: slice landed (2026-08-10). Lower it as packages convert; never raise it. -H2_BASELINE_ELSEWHERE = 494 +#: 494 → 396 when routes/email converted its 98 request-handler sites +#: (2026-08-10); the 27 sites still inside routes/email are B/C-class +#: (webhook, OAuth callback, scheduler hooks, BackgroundTask jobs) and carry +#: per-site H4/H6 markers naming the tenant each needs. +H2_BASELINE_ELSEWHERE = 396 def _get_db_sites() -> dict[str, int]: diff --git a/tests/unit/test_email_analytics.py b/tests/unit/test_email_analytics.py index e6f078e46..28804be6b 100644 --- a/tests/unit/test_email_analytics.py +++ b/tests/unit/test_email_analytics.py @@ -29,6 +29,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from gateway.routes.email.automation import analytics as a +from tests.unit._email_fakes import bind_db _PARAMS = {"uid": "u@example.com", "days": 30} _SCOPE = "m.account_id IN (SELECT id FROM email_accounts WHERE user_id = :uid)" @@ -84,7 +85,7 @@ async def test_every_flow_figure_on_the_page_carries_the_window() -> None: page displayed all-time numbers. Only the assembled query text can tell. """ db = _db(rows=[], row=_ANY_ROW, scalar=0) - with patch.object(a, "_get_db", AsyncMock(return_value=db)): + with patch.object(a, "_tenant_session", bind_db(db)): await a.analytics_overview( account_id=None, days=30, user=SimpleNamespace(email="u@example.com")) diff --git a/tests/unit/test_email_attachment_download.py b/tests/unit/test_email_attachment_download.py index b0bf1f29a..db0fb0fa4 100644 --- a/tests/unit/test_email_attachment_download.py +++ b/tests/unit/test_email_attachment_download.py @@ -27,10 +27,15 @@ def test_download_uses_provider_session_not_a_raw_instantiate() -> None: def test_download_commits_so_the_rotated_creds_land() -> None: - """provider_session only STAGES the credential UPDATE; the caller owns the - commit boundary. A download that never commits silently re-drops the token.""" + """provider_session only STAGES the credential UPDATE; the commit boundary + is now the tenant session's clean-exit commit (H2). A download that opened + its session any other way would silently re-drop the token.""" src = inspect.getsource(m.download_attachment) - assert "db.commit()" in src + assert "_tenant_session(" in src, ( + "download_attachment left the tenant-bound seam — its clean-exit " + "commit is what lands the rotated-cred persist" + ) + assert "_get_db(" not in src def test_download_requires_auth() -> None: diff --git a/tests/unit/test_email_bulk_apply.py b/tests/unit/test_email_bulk_apply.py index 0f4909206..133bdfddb 100644 --- a/tests/unit/test_email_bulk_apply.py +++ b/tests/unit/test_email_bulk_apply.py @@ -16,6 +16,7 @@ import pytest from email_ingestion.providers.gmail import GmailProvider +from tests.unit._email_fakes import bind_db def _gmail() -> GmailProvider: @@ -201,7 +202,7 @@ async def commit(self): ... async def close(self): ... req = s.BulkActionRequest(action="archive", account_id="acc-1", **kwargs) - with patch.object(s, "_get_db", AsyncMock(return_value=_DB())): + with patch.object(s, "_tenant_session", bind_db(_DB())): res = await s.bulk_action(req, MagicMock(), MagicMock(email="u@x.io")) assert res == {"affected": 0} @@ -232,7 +233,7 @@ async def test_archiving_a_sender_never_reaches_into_the_bin() -> None: captured: dict = {} req = s.BulkActionRequest( action="archive", account_id="acc-1", sender_email="news@site.com") - with patch.object(s, "_get_db", AsyncMock(return_value=_capture_db(captured))): + with patch.object(s, "_tenant_session", bind_db(_capture_db(captured))): await s.bulk_action(req, MagicMock(), MagicMock(email="u@x.io")) # Disposed mail (trash/junk/spam/drafts) is out of reach for an archive. @@ -247,7 +248,7 @@ async def test_trashing_still_reaches_archived_mail() -> None: captured: dict = {} req = s.BulkActionRequest( action="trash", account_id="acc-1", sender_email="news@site.com") - with patch.object(s, "_get_db", AsyncMock(return_value=_capture_db(captured))): + with patch.object(s, "_tenant_session", bind_db(_capture_db(captured))): await s.bulk_action(req, MagicMock(), MagicMock(email="u@x.io")) # Only already-trashed mail is skipped; the disposed-folder guard (which @@ -277,7 +278,7 @@ async def test_affected_counts_only_what_changed(action, already) -> None: captured: dict = {} req = s.BulkActionRequest( action=action, account_id="acc-1", sender_email="news@site.com") - with patch.object(s, "_get_db", AsyncMock(return_value=_capture_db(captured))): + with patch.object(s, "_tenant_session", bind_db(_capture_db(captured))): await s.bulk_action(req, MagicMock(), MagicMock(email="u@x.io")) assert f"NOT ({already})" in captured["sql"] @@ -410,7 +411,7 @@ async def test_cleaner_excludes_archived_mail_by_default() -> None: from gateway.routes.email.automation import senders as s captured: dict = {} - with patch.object(s, "_get_db", AsyncMock(return_value=_senders_db(captured))): + with patch.object(s, "_tenant_session", bind_db(_senders_db(captured))): await s.list_senders( account_id="acc-1", folder=None, include_archived=False, limit=200, offset=0, user=MagicMock(email="u@x.io")) @@ -427,7 +428,7 @@ async def test_cleaner_can_still_show_the_whole_mailbox() -> None: from gateway.routes.email.automation import senders as s captured: dict = {} - with patch.object(s, "_get_db", AsyncMock(return_value=_senders_db(captured))): + with patch.object(s, "_tenant_session", bind_db(_senders_db(captured))): await s.list_senders( account_id="acc-1", folder=None, include_archived=True, limit=200, offset=0, user=MagicMock(email="u@x.io")) @@ -495,7 +496,7 @@ async def execute(self, clause, params=None): async def close(self): ... - with patch.object(sr, "_get_db", AsyncMock(return_value=_DB())): + with patch.object(sr, "_tenant_session", bind_db(_DB())): await sr.search_messages( q=None, account_id="acc-1", folder="all", label=None, labels=None, uncategorized=False, from_addr="news@site.com", to_addr=None, diff --git a/tests/unit/test_email_cleanup_backfill.py b/tests/unit/test_email_cleanup_backfill.py index 4d917cbb8..478761264 100644 --- a/tests/unit/test_email_cleanup_backfill.py +++ b/tests/unit/test_email_cleanup_backfill.py @@ -28,6 +28,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from gateway.routes.email.automation import cleanup as c +from tests.unit._email_fakes import bind_db _ACC = "acc-backfill" @@ -194,7 +195,7 @@ async def test_a_second_run_is_refused_while_one_is_in_flight() -> None: row. Say so rather than silently start a second.""" c._SWEEP_JOBS.set(_ACC, {"owner": "u@x", "status": "running"}) try: - with patch.object(c, "_get_db", AsyncMock(return_value=AsyncMock())), \ + with patch.object(c, "_tenant_session", bind_db(AsyncMock())), \ patch.object(c, "_assert_account_owner", AsyncMock()): from fastapi import BackgroundTasks res = await c.cleanup_backfill( @@ -211,7 +212,7 @@ async def test_a_finished_run_does_not_block_the_next_one() -> None: c._SWEEP_JOBS.set(_ACC, {"owner": "u@x", "status": "done"}) try: bg = None - with patch.object(c, "_get_db", AsyncMock(return_value=AsyncMock())), \ + with patch.object(c, "_tenant_session", bind_db(AsyncMock())), \ patch.object(c, "_assert_account_owner", AsyncMock()): from fastapi import BackgroundTasks bg = BackgroundTasks() diff --git a/tests/unit/test_email_cleanup_sweep.py b/tests/unit/test_email_cleanup_sweep.py index c76f23cb6..f70c88e75 100644 --- a/tests/unit/test_email_cleanup_sweep.py +++ b/tests/unit/test_email_cleanup_sweep.py @@ -12,6 +12,7 @@ from types import SimpleNamespace from gateway.routes.email.automation import cleanup as c +from tests.unit._email_fakes import bind_db def _msg(sender, subject="Hi", mid="m1"): @@ -193,7 +194,7 @@ async def close(self): ... "pm-3": ["Newsletter"], }) - with patch.object(c, "_get_db", AsyncMock(return_value=_DB())), \ + with patch.object(c, "_tenant_session", bind_db(_DB())), \ patch.object(c, "_instantiate_provider", MagicMock(return_value=provider)), \ patch.object(c, "_persist_rotated_creds", AsyncMock()), \ patch("acb_llm.key_store.get_key_store", @@ -517,7 +518,7 @@ async def close(self): ... provider.authenticate = AsyncMock( side_effect=AssertionError("must not even authenticate")) - with patch.object(c, "_get_db", AsyncMock(return_value=_DB())), \ + with patch.object(c, "_tenant_session", bind_db(_DB())), \ patch.object(c, "_instantiate_provider", MagicMock(return_value=provider)), \ patch("acb_llm.key_store.get_key_store", MagicMock(return_value=MagicMock(decrypt=MagicMock( @@ -557,7 +558,7 @@ async def close(self): ... provider = MagicMock() provider.authenticate = AsyncMock(return_value=False) - with patch.object(c, "_get_db", AsyncMock(return_value=_DB())), \ + with patch.object(c, "_tenant_session", bind_db(_DB())), \ patch.object(c, "_instantiate_provider", MagicMock(return_value=provider)), \ patch("acb_llm.key_store.get_key_store", MagicMock(return_value=MagicMock(decrypt=MagicMock( diff --git a/tests/unit/test_email_contact_card.py b/tests/unit/test_email_contact_card.py index 756d1b591..d64f7d16c 100644 --- a/tests/unit/test_email_contact_card.py +++ b/tests/unit/test_email_contact_card.py @@ -20,6 +20,7 @@ _preview, _signature_block, ) +from tests.unit._email_fakes import bind_db class _Row: @@ -225,7 +226,7 @@ async def execute(self, clause, params=None): async def close(self): ... - with patch.object(C, "_get_db", AsyncMock(return_value=_DB())): + with patch.object(C, "_tenant_session", bind_db(_DB())): out = await C.suggest_contacts( q="Ayu", account_id="acc-1", limit=8, user=MagicMock(email="me@fracktal.in")) @@ -253,7 +254,7 @@ async def test_suggest_is_best_effort_on_failure() -> None: db = AsyncMock() db.execute.side_effect = RuntimeError("relation email_contacts missing") - with patch.object(C, "_get_db", AsyncMock(return_value=db)): + with patch.object(C, "_tenant_session", bind_db(db)): out = await C.suggest_contacts( q="ay", account_id=None, limit=8, user=MagicMock(email="me@x.io")) assert out == [] diff --git a/tests/unit/test_email_conversation_collapse.py b/tests/unit/test_email_conversation_collapse.py index 507c4fb13..42fccec9c 100644 --- a/tests/unit/test_email_conversation_collapse.py +++ b/tests/unit/test_email_conversation_collapse.py @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from gateway.routes.email.transport import messages as m +from tests.unit._email_fakes import bind_db class _User: @@ -47,7 +48,7 @@ async def _exec(sql, params=None): async def _run(**kw): args = {**_OFF, **kw} db, seen = _capture_db() - with patch.object(m, "_get_db", AsyncMock(return_value=db)): + with patch.object(m, "_tenant_session", bind_db(db)): out = await m.list_messages(user=_User(), **args) return out, seen diff --git a/tests/unit/test_email_facets.py b/tests/unit/test_email_facets.py index 2d752ca73..3f1f2ccdc 100644 --- a/tests/unit/test_email_facets.py +++ b/tests/unit/test_email_facets.py @@ -16,6 +16,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from gateway.routes.email import core +from tests.unit._email_fakes import bind_db # ── the shared vocabulary ─────────────────────────────────────────────────── @@ -86,8 +87,8 @@ async def test_facets_report_counts_per_label_plus_the_two_scalars() -> None: ] totals = MagicMock(total=5000, unread=42, uncategorized=311) - with patch("gateway.routes.email.transport.messages._get_db", - AsyncMock(return_value=_facet_db(rows, totals))): + with patch("gateway.routes.email.transport.messages._tenant_session", + bind_db(_facet_db(rows, totals))): res = await message_facets( account_id="acc-1", folder="inbox", user=MagicMock(email="u@x.io")) @@ -105,8 +106,8 @@ async def test_a_label_with_no_mail_in_this_folder_is_simply_absent() -> None: the same, but an explicit 0 would imply we looked and found a bucket.""" from gateway.routes.email.transport.messages import message_facets - with patch("gateway.routes.email.transport.messages._get_db", - AsyncMock(return_value=_facet_db( + with patch("gateway.routes.email.transport.messages._tenant_session", + bind_db(_facet_db( [], MagicMock(total=90, unread=0, uncategorized=90)))): res = await message_facets( account_id="acc-1", folder="sent", @@ -136,8 +137,8 @@ async def execute(self, clause, params=None): async def close(self): ... - with patch("gateway.routes.email.transport.messages._get_db", - AsyncMock(return_value=_DB())): + with patch("gateway.routes.email.transport.messages._tenant_session", + bind_db(_DB())): res = await message_facets( account_id="acc-1", folder="inbox", user=MagicMock(email="u@x.io")) @@ -156,8 +157,8 @@ async def test_facets_bind_the_shared_label_vocabulary() -> None: from gateway.routes.email.transport.messages import message_facets db = _facet_db([], MagicMock(total=1, unread=0, uncategorized=1)) - with patch("gateway.routes.email.transport.messages._get_db", - AsyncMock(return_value=db)): + with patch("gateway.routes.email.transport.messages._tenant_session", + bind_db(db)): await message_facets(account_id="acc-1", folder="inbox", user=MagicMock(email="u@x.io")) diff --git a/tests/unit/test_email_fix_feedback.py b/tests/unit/test_email_fix_feedback.py index e29cb0a94..e5a3c7c4d 100644 --- a/tests/unit/test_email_fix_feedback.py +++ b/tests/unit/test_email_fix_feedback.py @@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from gateway.routes import email as m +from tests.unit._email_fakes import bind_db _rules = m.automation.rules _rz = m.automation.replyzero @@ -48,7 +49,7 @@ async def fake_status(aid, tid, key): "label": "Reply"} user = SimpleNamespace(email="u@example.com") - with patch.object(_rules, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(_rules, "_tenant_session", bind_db(db)), \ patch.object(_rules, "_assert_account_owner", AsyncMock()), \ patch.object(_rules, "_load_rules", AsyncMock(return_value=rules)), \ patch.object(_rules, "_upsert_rule_pattern", @@ -148,7 +149,7 @@ async def refuse(*_a, **_k): return False # every write refused by a guard user = SimpleNamespace(email="u@example.com") - with patch.object(_rules, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(_rules, "_tenant_session", bind_db(db)), \ patch.object(_rules, "_assert_account_owner", AsyncMock()), \ patch.object(_rules, "_load_rules", AsyncMock(return_value=rules)), \ patch.object(_rules, "_upsert_rule_pattern", diff --git a/tests/unit/test_email_knowledge.py b/tests/unit/test_email_knowledge.py index 4fd185105..84d664161 100644 --- a/tests/unit/test_email_knowledge.py +++ b/tests/unit/test_email_knowledge.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from gateway.routes import email as m +from tests.unit._email_fakes import bind_db # ── Settings model carries the new drafting fields ────────────────────────── @@ -91,7 +92,7 @@ async def test_create_knowledge_inserts_and_returns() -> None: db = AsyncMock() user = SimpleNamespace(email="u@example.com") req = m.KnowledgeModel(account_id="acc-1", title="FAQ", content="Answers.") - with patch.object(m.automation.assistant, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(m.automation.assistant, "_tenant_session", bind_db(db)), \ patch.object(m.automation.assistant, "_assert_account_owner", AsyncMock()): res = await m.create_knowledge(req, user=user) assert res["title"] == "FAQ" @@ -109,7 +110,7 @@ async def test_list_knowledge_returns_entries() -> None: db = AsyncMock() db.execute.return_value = result user = SimpleNamespace(email="u@example.com") - with patch.object(m.automation.assistant, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(m.automation.assistant, "_tenant_session", bind_db(db)), \ patch.object(m.automation.assistant, "_assert_account_owner", AsyncMock()): res = await m.list_knowledge(account_id="acc-1", user=user) assert [e["title"] for e in res["entries"]] == ["Pricing", "Policy"] diff --git a/tests/unit/test_email_message_timeline.py b/tests/unit/test_email_message_timeline.py index d211528d0..85fa2501a 100644 --- a/tests/unit/test_email_message_timeline.py +++ b/tests/unit/test_email_message_timeline.py @@ -15,6 +15,7 @@ import pytest from fastapi import HTTPException from gateway.routes.email.automation import runner as r +from tests.unit._email_fakes import bind_db _MID = "msg-1" @@ -52,7 +53,7 @@ async def test_timeline_assembles_received_then_events_oldest_first() -> None: action_errors=None, match_source=None, created_at=_dt(22)), ] db = _db_returning(msg, rows) - with patch.object(r, "_get_db", AsyncMock(return_value=db)): + with patch.object(r, "_tenant_session", bind_db(db)): out = await r.message_timeline(_MID, user=_User()) kinds = [e["kind"] for e in out["events"]] @@ -67,7 +68,7 @@ async def test_timeline_assembles_received_then_events_oldest_first() -> None: async def test_unowned_or_unknown_message_is_404() -> None: db = _db_returning(None, []) - with patch.object(r, "_get_db", AsyncMock(return_value=db)): + with patch.object(r, "_tenant_session", bind_db(db)): with pytest.raises(HTTPException) as ei: await r.message_timeline(_MID, user=_User()) assert ei.value.status_code == 404 @@ -83,7 +84,7 @@ async def test_failed_run_carries_its_action_errors() -> None: reason="matched", match_source="pattern", created_at=_dt(21), action_errors=[{"type": "ARCHIVE", "error": "provider refused"}])] db = _db_returning(msg, rows) - with patch.object(r, "_get_db", AsyncMock(return_value=db)): + with patch.object(r, "_tenant_session", bind_db(db)): out = await r.message_timeline(_MID, user=_User()) ev = out["events"][-1] diff --git a/tests/unit/test_email_messages_filter.py b/tests/unit/test_email_messages_filter.py index 753199954..80dad708e 100644 --- a/tests/unit/test_email_messages_filter.py +++ b/tests/unit/test_email_messages_filter.py @@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from gateway.routes import email as m +from tests.unit._email_fakes import bind_db async def _run_list(label): @@ -25,7 +26,7 @@ async def fake_execute(stmt, params=None): db = AsyncMock() db.execute.side_effect = fake_execute user = SimpleNamespace(email="u@example.com") - with patch.object(m.transport.messages, "_get_db", AsyncMock(return_value=db)): + with patch.object(m.transport.messages, "_tenant_session", bind_db(db)): resp = await m.list_messages( account_id="acc-1", folder="inbox", label=label, query=None, thread_id=None, @@ -76,7 +77,7 @@ async def fake_execute(stmt, params=None): page=1, page_size=50, user=user, ) args.update(kw) - with patch.object(m.transport.messages, "_get_db", AsyncMock(return_value=db)): + with patch.object(m.transport.messages, "_tenant_session", bind_db(db)): resp = await m.list_messages(**args) return resp, captured @@ -154,8 +155,8 @@ async def fake_execute(stmt, params=None): db = AsyncMock() db.execute.side_effect = fake_execute user = SimpleNamespace(email="u@example.com") - with patch.object(m.transport.messages, "_get_db", - AsyncMock(return_value=db)), \ + with patch.object(m.transport.messages, "_tenant_session", + bind_db(db)), \ patch.object(m.transport.messages, "_assert_account_owner", AsyncMock()): resp = await m.priority_inbox( @@ -209,7 +210,7 @@ async def fake_execute(stmt, params=None): db = AsyncMock() db.execute.side_effect = fake_execute user = SimpleNamespace(email="u@example.com") - with patch.object(m.transport.messages, "_get_db", AsyncMock(return_value=db)): + with patch.object(m.transport.messages, "_tenant_session", bind_db(db)): resp = await m.list_messages( account_id="acc-1", folder=None, label=None, query=None, thread_id=thread_id, received_after=None, received_before=None, diff --git a/tests/unit/test_email_pattern_approval.py b/tests/unit/test_email_pattern_approval.py index 0a8d357e3..46fc94cb0 100644 --- a/tests/unit/test_email_pattern_approval.py +++ b/tests/unit/test_email_pattern_approval.py @@ -26,6 +26,7 @@ from gateway.routes.email.automation import cleanup as c from gateway.routes.email.automation import engine as e from gateway.routes.email.automation import rules as r +from tests.unit._email_fakes import bind_db _ACC = "acc-approve" _USER = SimpleNamespace(email="u@example.com") @@ -145,7 +146,7 @@ def test_resetting_the_rules_preserves_review_state() -> None: async def _review(**kw) -> tuple[dict, str, dict]: db = AsyncMock() db.execute.return_value = MagicMock(rowcount=7) - with patch.object(r, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(r, "_tenant_session", bind_db(db)), \ patch.object(r, "_assert_account_owner", AsyncMock()): res = await r.review_rule_patterns( r.PatternReviewRequest(account_id=_ACC, **kw), user=_USER) @@ -193,7 +194,7 @@ async def test_an_empty_selection_is_a_no_op_not_an_approve_all() -> None: """`pattern_ids=[]` means "nothing selected". Falling through to the unfiltered UPDATE would approve the entire backlog on an empty click.""" db = AsyncMock() - with patch.object(r, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(r, "_tenant_session", bind_db(db)), \ patch.object(r, "_assert_account_owner", AsyncMock()): res = await r.review_rule_patterns( r.PatternReviewRequest(account_id=_ACC, pattern_ids=[]), user=_USER) diff --git a/tests/unit/test_email_presets.py b/tests/unit/test_email_presets.py index 516879ef7..1b22e0ad6 100644 --- a/tests/unit/test_email_presets.py +++ b/tests/unit/test_email_presets.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from gateway.routes import email as m +from tests.unit._email_fakes import bind_db _actions_for_preset = m.automation.rules._actions_for_preset @@ -64,7 +65,7 @@ async def test_install_presets_uses_folder_actions_for_outlook() -> None: ) user = SimpleNamespace(email="u@example.com") captured: list = [] - with patch.object(m.automation.rules, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(m.automation.rules, "_tenant_session", bind_db(db)), \ patch.object(m.automation.rules, "_assert_account_owner", AsyncMock()), \ patch.object(m.automation.rules, "_load_rules", AsyncMock(return_value=[])), \ patch.object(m.automation.rules, "_replace_actions", @@ -94,7 +95,7 @@ async def test_install_presets_creates_only_missing() -> None: # it — two conversation rules for one status would double-classify. db = _db_with_provider() user = SimpleNamespace(email="u@example.com") - with patch.object(m.automation.rules, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(m.automation.rules, "_tenant_session", bind_db(db)), \ patch.object(m.automation.rules, "_assert_account_owner", AsyncMock()), \ patch.object(m.automation.rules, "_load_rules", AsyncMock(return_value=[{"name": "Reply"}])), \ @@ -111,7 +112,7 @@ async def test_install_presets_idempotent_when_all_present() -> None: db = _db_with_provider() user = SimpleNamespace(email="u@example.com") all_rules = [{"name": p["name"]} for p in m._PRESET_RULES] - with patch.object(m.automation.rules, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(m.automation.rules, "_tenant_session", bind_db(db)), \ patch.object(m.automation.rules, "_assert_account_owner", AsyncMock()), \ patch.object(m.automation.rules, "_load_rules", AsyncMock(return_value=all_rules)), \ patch.object(m.automation.rules, "_replace_actions", AsyncMock()): @@ -125,7 +126,7 @@ async def test_reset_rules_deletes_then_reinstalls_every_preset() -> None: db = _db_with_provider("microsoft") user = SimpleNamespace(email="u@example.com") existing = [{"name": p["name"]} for p in m._PRESET_RULES] # all present… - with patch.object(m.automation.rules, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(m.automation.rules, "_tenant_session", bind_db(db)), \ patch.object(m.automation.rules, "_assert_account_owner", AsyncMock()), \ patch.object(m.automation.rules, "_load_rules", AsyncMock(return_value=existing)), \ patch.object(m.automation.rules, "_replace_actions", AsyncMock()): @@ -165,7 +166,7 @@ async def test_reset_preserves_learned_patterns_across_the_reseed() -> None: user = SimpleNamespace(email="u@example.com") reseeded = [{"id": f"new-{p['name']}", "name": p["name"]} for p in m._PRESET_RULES] - with patch.object(m.automation.rules, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(m.automation.rules, "_tenant_session", bind_db(db)), \ patch.object(m.automation.rules, "_assert_account_owner", AsyncMock()), \ patch.object(m.automation.rules, "_load_rules", AsyncMock(return_value=reseeded)), \ diff --git a/tests/unit/test_email_process_past_cost_guard.py b/tests/unit/test_email_process_past_cost_guard.py index fed9712a0..aae355051 100644 --- a/tests/unit/test_email_process_past_cost_guard.py +++ b/tests/unit/test_email_process_past_cost_guard.py @@ -24,6 +24,7 @@ from fastapi import BackgroundTasks, HTTPException from gateway.routes.email.automation import runner as m +from tests.unit._email_fakes import bind_db _ACC = "acc-cost" _USER = SimpleNamespace(email="u@example.com") @@ -71,8 +72,8 @@ def test_an_open_ended_END_is_measured_to_today() -> None: async def test_the_endpoint_enforces_the_cap_before_touching_the_db() -> None: """A bound checked after the work has begun is not a bound. This also keeps the refusal cheap: no connection, no owner check, no count.""" - get_db = AsyncMock() - with patch.object(m, "_get_db", get_db): + seam = bind_db(AsyncMock()) + with patch.object(m, "_tenant_session", seam): with pytest.raises(HTTPException) as e: await m.process_past_emails( m.RuleProcessPastRequest( @@ -80,12 +81,12 @@ async def test_the_endpoint_enforces_the_cap_before_touching_the_db() -> None: end_date="2026-07-01"), background=BackgroundTasks(), user=_USER) assert e.value.status_code == 400 - get_db.assert_not_awaited() + assert seam.calls == [] # no session was ever opened async def test_a_refused_range_schedules_nothing() -> None: bg = BackgroundTasks() - with patch.object(m, "_get_db", AsyncMock()), \ + with patch.object(m, "_tenant_session", bind_db(AsyncMock())), \ patch.object(m, "_assert_account_owner", AsyncMock()): with pytest.raises(HTTPException): await m.process_past_emails( @@ -107,7 +108,7 @@ def _db_returning(*counts: int) -> AsyncMock: async def _estimate(*counts: int, limit: int = 1000) -> dict: db = _db_returning(*counts) - with patch.object(m, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(m, "_tenant_session", bind_db(db)), \ patch.object(m, "_assert_account_owner", AsyncMock()): return await m.process_past_estimate( account_id=_ACC, start_date="2026-01-01", end_date="2026-07-01", @@ -147,7 +148,7 @@ async def test_held_back_history_is_counted_separately() -> None: async def test_the_estimate_asks_the_db_for_held_back_mail_explicitly() -> None: db = _db_returning(10, 10, 3) - with patch.object(m, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(m, "_tenant_session", bind_db(db)), \ patch.object(m, "_assert_account_owner", AsyncMock()): await m.process_past_estimate( account_id=_ACC, start_date="2026-01-01", user=_USER) diff --git a/tests/unit/test_email_process_past_progress.py b/tests/unit/test_email_process_past_progress.py index 353ff310a..32803e66f 100644 --- a/tests/unit/test_email_process_past_progress.py +++ b/tests/unit/test_email_process_past_progress.py @@ -15,6 +15,7 @@ from fastapi import BackgroundTasks from gateway.routes import email as m +from tests.unit._email_fakes import bind_db runner = m.automation.runner @@ -114,7 +115,7 @@ async def test_handler_seeds_tracker_and_schedules_when_mail_exists() -> None: req = m.RuleProcessPastRequest( account_id="acc-1", start_date="2026-01-01", end_date="2026-01-31", is_test=False, include_read=True) - with patch.object(runner, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(runner, "_tenant_session", bind_db(db)), \ patch.object(runner, "_assert_account_owner", AsyncMock()): res = await m.process_past_emails(req, background=bg, user=user) @@ -147,7 +148,7 @@ async def test_handler_schedules_download_even_when_nothing_local() -> None: # received, at one AI call each (see _assert_span_within_cap). req = m.RuleProcessPastRequest( account_id="acc-1", is_test=False, start_date="2026-05-01") - with patch.object(runner, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(runner, "_tenant_session", bind_db(db)), \ patch.object(runner, "_assert_account_owner", AsyncMock()): res = await m.process_past_emails(req, background=bg, user=user) diff --git a/tests/unit/test_email_reclassify_resumable.py b/tests/unit/test_email_reclassify_resumable.py index cdf26b5fb..77f0a6ddc 100644 --- a/tests/unit/test_email_reclassify_resumable.py +++ b/tests/unit/test_email_reclassify_resumable.py @@ -8,10 +8,12 @@ """ from __future__ import annotations +from contextlib import contextmanager from unittest.mock import AsyncMock, patch from fastapi import BackgroundTasks from gateway.routes.email.automation import replyzero as r +from tests.unit._email_fakes import bind_db _ACC = "acc-reclass" @@ -20,9 +22,17 @@ class _User: email = "u@example.com" +@contextmanager def _mock_db_ctx(): - """_get_db() → an AsyncMock db whose execute/commit/close all no-op.""" - return patch.object(r, "_get_db", AsyncMock(return_value=AsyncMock())) + """Both DB seams → an AsyncMock db whose execute/commit/close all no-op. + + The routes here are converted to `_tenant_session` (H2) while the + background `_reclassify_reply_zero_job` deliberately stays on `_get_db` + until H4 — so this fixture doubles both. + """ + with patch.object(r, "_get_db", AsyncMock(return_value=AsyncMock())), \ + patch.object(r, "_tenant_session", bind_db(AsyncMock())): + yield async def test_drain_classifies_until_no_threads_need_a_status() -> None: diff --git a/tests/unit/test_email_retry_and_uncategorized.py b/tests/unit/test_email_retry_and_uncategorized.py index 54fa885d9..029255031 100644 --- a/tests/unit/test_email_retry_and_uncategorized.py +++ b/tests/unit/test_email_retry_and_uncategorized.py @@ -32,6 +32,7 @@ from email_ingestion import scheduler as sched from gateway.routes.email.automation import analytics as a from gateway.routes.email.automation import runner as m +from tests.unit._email_fakes import bind_db # ── the account id is a string, everywhere ────────────────────────────────── @@ -113,7 +114,7 @@ async def test_nothing_to_retry_is_not_an_error() -> None: scalar=MagicMock(return_value="u@example.com"), ) import unittest.mock as _mock - with _mock.patch.object(m, "_get_db", AsyncMock(return_value=db)): + with _mock.patch.object(m, "_tenant_session", bind_db(db)): out = await m.retry_failed_executions("acc-1") assert out["considered"] == 0 and out["repaired"] == 0 diff --git a/tests/unit/test_email_rules_admin.py b/tests/unit/test_email_rules_admin.py index 509af3549..237ceaf9b 100644 --- a/tests/unit/test_email_rules_admin.py +++ b/tests/unit/test_email_rules_admin.py @@ -8,6 +8,7 @@ from fastapi import HTTPException from gateway.routes import email as m +from tests.unit._email_fakes import bind_db def test_rules_sort_canonically_not_by_user_order() -> None: @@ -39,7 +40,7 @@ async def test_undo_not_found_raises_404() -> None: db = AsyncMock() db.execute.return_value = result user = SimpleNamespace(email="u@example.com") - with patch.object(m.automation.runner, "_get_db", AsyncMock(return_value=db)): + with patch.object(m.automation.runner, "_tenant_session", bind_db(db)): with pytest.raises(HTTPException) as ei: await m.undo_execution("e1", user=user) assert ei.value.status_code == 404 @@ -55,7 +56,7 @@ async def test_undo_rejects_non_applied_execution() -> None: db = AsyncMock() db.execute.return_value = result user = SimpleNamespace(email="u@example.com") - with patch.object(m.automation.runner, "_get_db", AsyncMock(return_value=db)): + with patch.object(m.automation.runner, "_tenant_session", bind_db(db)): with pytest.raises(HTTPException) as ei: await m.undo_execution("e1", user=user) assert ei.value.status_code == 400 diff --git a/tests/unit/test_email_search_scope.py b/tests/unit/test_email_search_scope.py index 0815f4af9..98c108992 100644 --- a/tests/unit/test_email_search_scope.py +++ b/tests/unit/test_email_search_scope.py @@ -13,6 +13,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from gateway.routes import email as m +from tests.unit._email_fakes import bind_db async def _run_search(**kw): @@ -37,7 +38,7 @@ async def fake_execute(stmt, params=None): user=SimpleNamespace(email="u@example.com"), ) args.update(kw) - with patch.object(m.transport.search, "_get_db", AsyncMock(return_value=db)): + with patch.object(m.transport.search, "_tenant_session", bind_db(db)): resp = await m.search_messages(**args) sql = " ".join(s for s, _ in captured) params: dict = {} @@ -248,7 +249,7 @@ async def fake_execute(stmt, params=None): db = AsyncMock() db.execute.side_effect = fake_execute - with patch.object(m.transport.messages, "_get_db", AsyncMock(return_value=db)): + with patch.object(m.transport.messages, "_tenant_session", bind_db(db)): await m.list_messages( account_id="acc-1", folder=folder, label=None, query=None, thread_id=None, received_after=None, received_before=None, @@ -316,7 +317,7 @@ async def fake_execute(stmt, params=None): db = AsyncMock() db.execute.side_effect = fake_execute - with patch.object(m.transport.messages, "_get_db", AsyncMock(return_value=db)): + with patch.object(m.transport.messages, "_tenant_session", bind_db(db)): await m.transport.messages.message_facets( account_id="acc-1", folder="all", user=SimpleNamespace(email="u@example.com"), diff --git a/tests/unit/test_email_snooze.py b/tests/unit/test_email_snooze.py index 07035b76c..d78cd6cdb 100644 --- a/tests/unit/test_email_snooze.py +++ b/tests/unit/test_email_snooze.py @@ -14,6 +14,7 @@ from fastapi import HTTPException from gateway.routes.email import core from gateway.routes.email.transport import messages as m +from tests.unit._email_fakes import bind_db class _User: @@ -53,7 +54,7 @@ async def _exec(sql, params=None): async def _list(**kw): db, seen = _capture_db() - with patch.object(m, "_get_db", AsyncMock(return_value=db)): + with patch.object(m, "_tenant_session", bind_db(db)): await m.list_messages(user=_User(), **{**_OFF, **kw}) return "\n".join(seen) @@ -100,7 +101,7 @@ async def _exec(sql, params=None): async def test_snooze_stamps_the_whole_thread() -> None: row = SimpleNamespace(account_id="acc-1", thread_id="th-9") db = _snooze_db(row) - with patch.object(m, "_get_db", AsyncMock(return_value=db)): + with patch.object(m, "_tenant_session", bind_db(db)): out = await m.snooze_message( "msg-1", m.SnoozeRequest(until="2026-08-01T08:00:00Z"), user=_User()) @@ -114,7 +115,7 @@ async def test_snooze_stamps_the_whole_thread() -> None: async def test_unsnooze_clears_the_stamp() -> None: row = SimpleNamespace(account_id="acc-1", thread_id="th-9") db = _snooze_db(row) - with patch.object(m, "_get_db", AsyncMock(return_value=db)): + with patch.object(m, "_tenant_session", bind_db(db)): out = await m.snooze_message("msg-1", m.SnoozeRequest(until=None), user=_User()) upd = [c for c in db.calls if "UPDATE" in c[0]][0] @@ -125,7 +126,7 @@ async def test_unsnooze_clears_the_stamp() -> None: async def test_lone_message_snoozes_by_id() -> None: row = SimpleNamespace(account_id="acc-1", thread_id=None) db = _snooze_db(row) - with patch.object(m, "_get_db", AsyncMock(return_value=db)): + with patch.object(m, "_tenant_session", bind_db(db)): await m.snooze_message("msg-1", m.SnoozeRequest(until="2026-08-01T08:00:00Z"), user=_User()) upd = [c for c in db.calls if "UPDATE" in c[0]][0] @@ -135,7 +136,7 @@ async def test_lone_message_snoozes_by_id() -> None: async def test_unowned_message_is_404() -> None: db = _snooze_db(None) - with patch.object(m, "_get_db", AsyncMock(return_value=db)): + with patch.object(m, "_tenant_session", bind_db(db)): with pytest.raises(HTTPException) as ei: await m.snooze_message("msg-1", m.SnoozeRequest(until=None), user=_User()) diff --git a/tests/unit/test_email_thread_resolve.py b/tests/unit/test_email_thread_resolve.py index 63572c84a..fb07cffab 100644 --- a/tests/unit/test_email_thread_resolve.py +++ b/tests/unit/test_email_thread_resolve.py @@ -13,6 +13,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from gateway.routes.email.automation import replyzero as rz +from tests.unit._email_fakes import bind_db def _db(rowcount: int = 1): @@ -28,7 +29,7 @@ def _db(rowcount: int = 1): async def _call(req): db = _db() background = MagicMock() - with patch.object(rz, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(rz, "_tenant_session", bind_db(db)), \ patch.object(rz, "_assert_account_owner", AsyncMock()): out = await rz.resolve_thread( req, background, user=SimpleNamespace(email="u@x.com")) diff --git a/tests/unit/test_email_two_way_sync.py b/tests/unit/test_email_two_way_sync.py index 658bffeec..25ec431a1 100644 --- a/tests/unit/test_email_two_way_sync.py +++ b/tests/unit/test_email_two_way_sync.py @@ -8,6 +8,7 @@ from fastapi import HTTPException from gateway.routes import email as m +from tests.unit._email_fakes import bind_db USER = SimpleNamespace(email="u@example.com") @@ -44,7 +45,7 @@ async def test_update_message_provider_error_does_not_fail_action() -> None: db = _db_with_owned_row() prov = _provider(apply_flags=AsyncMock(side_effect=HTTPException(status_code=502))) sentinel = object() - with patch.object(m.transport.messages, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(m.transport.messages, "_tenant_session", bind_db(db)), \ patch.object(m.transport.messages, "_provider_for_message", AsyncMock(return_value=(prov, "OLD_PID", "acc-1", object()))), \ patch.object(m.transport.messages, "_persist_rotated_creds", AsyncMock()), \ @@ -59,7 +60,7 @@ async def test_update_message_move_rekeys_provider_id() -> None: # Outlook /move returns a new id -> we must persist it. db = _db_with_owned_row() prov = _provider(move_to_folder=AsyncMock(return_value="NEW_PID")) - with patch.object(m.transport.messages, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(m.transport.messages, "_tenant_session", bind_db(db)), \ patch.object(m.transport.messages, "_provider_for_message", AsyncMock(return_value=(prov, "OLD_PID", "acc-1", object()))), \ patch.object(m.transport.messages, "_persist_rotated_creds", AsyncMock()), \ @@ -76,7 +77,7 @@ async def test_update_message_move_rekeys_provider_id() -> None: async def test_delete_message_rekeys_and_swallows_provider_error() -> None: db = _db_with_owned_row() prov = _provider(trash_message=AsyncMock(return_value="NEW_PID")) - with patch.object(m.transport.messages, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(m.transport.messages, "_tenant_session", bind_db(db)), \ patch.object(m.transport.messages, "_provider_for_message", AsyncMock(return_value=(prov, "OLD_PID", "acc-1", object()))), \ patch.object(m.transport.messages, "_persist_rotated_creds", AsyncMock()): diff --git a/tests/unit/test_email_unsubscribe.py b/tests/unit/test_email_unsubscribe.py index 2d1044ad3..d6ad8ca0b 100644 --- a/tests/unit/test_email_unsubscribe.py +++ b/tests/unit/test_email_unsubscribe.py @@ -17,6 +17,7 @@ ) from email_ingestion.providers.gmail import GmailProvider from gateway.routes.email.automation import senders as s +from tests.unit._email_fakes import bind_db # ── HTML-body scraping + best-link selection ──────────────────────────────── @@ -199,7 +200,7 @@ async def _apply(db, bg, aid, email, name, status, link, *, create_filter): req = s.UnsubscribeRequest( account_id="acc-1", email="news@x.com", unsubscribe_link="https://list.example/u") - with patch.object(s, "_get_db", AsyncMock(return_value=AsyncMock())), \ + with patch.object(s, "_tenant_session", bind_db(AsyncMock())), \ patch.object(s, "_assert_account_owner", AsyncMock()), \ patch.object(s, "_http_unsubscribe", AsyncMock(return_value=(True, "one-click-post"))), \ @@ -224,7 +225,7 @@ async def _apply(db, bg, aid, email, name, status, link, *, create_filter): db.execute.return_value = SimpleNamespace( fetchone=lambda: SimpleNamespace(link=None)) req = s.UnsubscribeRequest(account_id="acc-1", email="news@x.com") - with patch.object(s, "_get_db", AsyncMock(return_value=db)), \ + with patch.object(s, "_tenant_session", bind_db(db)), \ patch.object(s, "_assert_account_owner", AsyncMock()), \ patch.object(s, "_apply_newsletter_status", _apply): res = await s.unsubscribe_sender(req, SimpleNamespace(add_task=lambda *a: None), From d64789a410a174703df230170a70290ecad5f21e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 07:16:45 +0000 Subject: [PATCH 04/10] =?UTF-8?q?feat(tenancy):=20H2=20=E2=80=94=20routes/?= =?UTF-8?q?tasks=20converted=20to=20tenant=5Fsession=20(slice)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 73 of the package's 79 get_db() sites — every user-identity request handler — now acquire sessions as 'async with _tenant_session() as db:' where _tenant_session IS acb_common.db.tenant_session (aliased in core.py, imported by every submodule by name, mirroring the Projects exemplar). The tenant comes from the request context bound centrally in _with_resolved_access; no call site passes one. The 6 sites that stay on get_db() are background consumers, each with an H4 comment naming why ambient inheritance is forbidden for them: broker_handlers.py (1, action-broker execute), scheduler.py (3, asyncio.create_task sync loops), calendar.py (2, the nightly rollover sweep — cross-tenant by construction). Restructures where a mid-block commit would have dropped the tenant GUC (never a naive swap): - commit-then-push handlers (patch_item, merge_into, archive, bulk- archive, organize, delegate) become TWO tenant blocks: the local edit commits on the first block's clean exit, the best-effort provider back-sync runs in a second — same "the edit is saved before upstream" contract as before. - _push_patch_upstream / _push_pending_item / the calendar pending-plan helpers no longer commit; the owning handler block does (which also makes the *-today apply atomic). - _refresh_schema no longer commits or calls _reconcile_people; handlers run both inside tenant blocks, the (unbound, H4) scheduler commits explicitly and sequences the reconcile itself. - accounts/settings handlers that must be COMMITTED before the scheduler re-reads on its own session (sync_enabled / background_sync toggles, put_day_state's echo) commit by block boundary, then act. - _sync_account / embed_pending_people keep their trailing commit and are documented as sole-occupant-of-a-block helpers (commit is their last statement, wrapper exit is an empty no-op). Tests: the tasks hermetic seams (test_tasks_people_scoping, test_people_write) patch an @asynccontextmanager _tenant_session with commit-on-clean-exit; test_tasks_gtd's commit-before-push source pin now asserts the two-block shape. H2_BASELINE_ELSEWHERE banked 494 → 421. Live scratch-Postgres smoke (Postgres 16, FORCE RLS on gtd_items/ gtd_waiting/gtd_projects/task_accounts, non-superuser role): unbound handler raises TenantUnbound; capture_item writes under the GUC with the row stamped by the bound org; the same read handlers return the row under org A and zero rows under org B; delete_item's tombstone persists via the wrapper commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../gateway/gateway/routes/tasks/accounts.py | 139 +++++----- .../gateway/gateway/routes/tasks/ai.py | 38 +-- .../gateway/routes/tasks/attachments.py | 13 +- .../gateway/routes/tasks/broker_handlers.py | 8 + .../gateway/gateway/routes/tasks/calendar.py | 84 +++--- .../gateway/routes/tasks/capability.py | 10 +- .../gateway/routes/tasks/capture_email.py | 35 +-- .../gateway/gateway/routes/tasks/core.py | 12 + .../gateway/gateway/routes/tasks/hierarchy.py | 25 +- .../gateway/gateway/routes/tasks/items.py | 249 +++++++----------- .../gateway/gateway/routes/tasks/people.py | 54 ++-- .../gateway/gateway/routes/tasks/planning.py | 17 +- .../gateway/gateway/routes/tasks/scheduler.py | 27 +- .../gateway/gateway/routes/tasks/settings.py | 104 ++++---- .../gateway/gateway/routes/tasks/sync.py | 54 ++-- tests/unit/test_db_engine_seam.py | 6 +- tests/unit/test_people_write.py | 15 +- tests/unit/test_tasks_gtd.py | 9 +- tests/unit/test_tasks_people_scoping.py | 23 +- 19 files changed, 415 insertions(+), 507 deletions(-) diff --git a/apps/services/gateway/gateway/routes/tasks/accounts.py b/apps/services/gateway/gateway/routes/tasks/accounts.py index 595d67593..6671e4d61 100644 --- a/apps/services/gateway/gateway/routes/tasks/accounts.py +++ b/apps/services/gateway/gateway/routes/tasks/accounts.py @@ -25,10 +25,10 @@ PersonModel, TaskAccountModel, _assert_account_owner, - _get_db, _key_store, _log, _parse_jsonb, + _tenant_session, _uid, router, ) @@ -102,16 +102,13 @@ async def list_provider_workspaces( @router.get("/accounts", response_model=list[TaskAccountModel]) async def list_accounts(user: UserContext = Depends(get_current_user)): - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text("""SELECT * FROM task_accounts WHERE user_id = :uid ORDER BY created_at"""), {"uid": _uid(user)}, )).fetchall() return [_row_to_account(r) for r in rows] - finally: - await db.close() @router.post("/accounts", response_model=TaskAccountModel, status_code=201) @@ -134,8 +131,7 @@ async def create_account( encrypted = _key_store().encrypt(json.dumps({"api_token": req.api_token})) label = req.label or workspaces[req.workspace_id].get("name") or req.provider - db = await _get_db() - try: + async with _tenant_session() as db: dup = (await db.execute( text("""SELECT 1 FROM task_accounts WHERE user_id = :uid AND provider = :p AND workspace_id = :w"""), @@ -154,29 +150,32 @@ async def create_account( {"id": account_id, "uid": _uid(user), "p": req.provider, "w": req.workspace_id, "label": label, "creds": encrypted}, ) - await db.commit() - try: + # The account row is committed above; the first schema fetch runs in its + # own transaction so a provider failure can't undo the connect (H2 + # restructure of the old commit-then-continue shape). + try: + async with _tenant_session() as db: await _refresh_schema(db, account_id, _uid(user)) - except Exception as exc: + await _reconcile_people(db, _uid(user)) + except Exception as exc: + async with _tenant_session() as db: await db.execute( text("""UPDATE task_accounts SET sync_status='error', sync_error=:e, updated_at=now() WHERE id=:id"""), {"id": account_id, "e": str(exc)[:500]}, ) - await db.commit() + async with _tenant_session() as db: row = (await db.execute( text("SELECT * FROM task_accounts WHERE id = :id"), {"id": account_id}, )).fetchone() - # Launch this workspace's background sync loop now (no gateway restart). - try: - from gateway.routes.tasks.scheduler import refresh_account_sync - await refresh_account_sync(account_id) - except Exception as exc: - _log.warning("tasks.accounts.scheduler_start_failed", - account_id=account_id[:12], error=str(exc)[:160]) - return _row_to_account(row) - finally: - await db.close() + # Launch this workspace's background sync loop now (no gateway restart). + try: + from gateway.routes.tasks.scheduler import refresh_account_sync + await refresh_account_sync(account_id) + except Exception as exc: + _log.warning("tasks.accounts.scheduler_start_failed", + account_id=account_id[:12], error=str(exc)[:160]) + return _row_to_account(row) @router.patch("/accounts/{account_id}", response_model=TaskAccountModel) @@ -185,8 +184,7 @@ async def update_account( req: AccountUpdateRequest, user: UserContext = Depends(get_current_user), ): - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, _uid(user)) sets, params = [], {"id": account_id} if req.label is not None: @@ -203,22 +201,21 @@ async def update_account( "WHERE id = :id RETURNING *"), params, )).fetchone() - await db.commit() - # Reflect a sync_enabled toggle in the background scheduler at runtime. - if req.sync_enabled is not None: - try: - if req.sync_enabled: - from gateway.routes.tasks.scheduler import refresh_account_sync - await refresh_account_sync(account_id) - else: - from gateway.routes.tasks.scheduler import remove_account_sync - await remove_account_sync(account_id) - except Exception as exc: - _log.warning("tasks.accounts.scheduler_toggle_failed", - account_id=account_id[:12], error=str(exc)[:160]) - return _row_to_account(row) - finally: - await db.close() + # Reflect a sync_enabled toggle in the background scheduler at runtime — + # AFTER the block above committed, because the scheduler re-reads the row + # on its own session and must see the new value. + if req.sync_enabled is not None: + try: + if req.sync_enabled: + from gateway.routes.tasks.scheduler import refresh_account_sync + await refresh_account_sync(account_id) + else: + from gateway.routes.tasks.scheduler import remove_account_sync + await remove_account_sync(account_id) + except Exception as exc: + _log.warning("tasks.accounts.scheduler_toggle_failed", + account_id=account_id[:12], error=str(exc)[:160]) + return _row_to_account(row) @router.delete("/accounts/{account_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -227,8 +224,7 @@ async def delete_account( user: UserContext = Depends(get_current_user), ): """Disconnect a workspace. Its mirrored rows cascade away (FK ON DELETE).""" - db = await _get_db() - try: + async with _tenant_session() as db: res = (await db.execute( text("""DELETE FROM task_accounts WHERE id = :id AND user_id = :uid RETURNING id"""), @@ -236,16 +232,13 @@ async def delete_account( )).fetchone() if res is None: raise HTTPException(status_code=404, detail="Account not found") - await db.commit() - # Stop this workspace's background sync loop. - try: - from gateway.routes.tasks.scheduler import remove_account_sync - await remove_account_sync(account_id) - except Exception as exc: - _log.warning("tasks.accounts.scheduler_remove_failed", - account_id=account_id[:12], error=str(exc)[:160]) - finally: - await db.close() + # Stop this workspace's background sync loop (after the delete committed). + try: + from gateway.routes.tasks.scheduler import remove_account_sync + await remove_account_sync(account_id) + except Exception as exc: + _log.warning("tasks.accounts.scheduler_remove_failed", + account_id=account_id[:12], error=str(exc)[:160]) @router.post("/accounts/{account_id}/schema/refresh", response_model=TaskAccountModel) @@ -254,16 +247,14 @@ async def refresh_account_schema( user: UserContext = Depends(get_current_user), ): """Re-fetch the provider schema (projects/members/statuses) on demand.""" - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_account_owner(db, account_id, _uid(user)) await _refresh_schema(db, account_id, _uid(user)) + await _reconcile_people(db, _uid(user)) row = (await db.execute( text("SELECT * FROM task_accounts WHERE id = :id"), {"id": account_id}, )).fetchone() return _row_to_account(row) - finally: - await db.close() class CreateProjectRequest(BaseModel): @@ -280,8 +271,7 @@ async def refresh_account_members( ): """LIVE member pull (delegate-picker freshness): people removed in the tool disappear immediately, without the heavier full schema refresh.""" - db = await _get_db() - try: + async with _tenant_session() as db: row = await _assert_account_owner(db, account_id, _uid(user)) creds = json.loads(_key_store().decrypt(row.credentials_encrypted)) provider = build_provider(row.provider, creds, row.workspace_id) @@ -294,16 +284,16 @@ async def refresh_account_members( WHERE id = :id"""), {"id": account_id, "cache": json.dumps(cache)}, ) - await db.commit() - # Keep the org roster in step with live membership (§6). + # The member cache is committed above; keep the org roster in step with + # live membership (§6) in its own transaction, so a reconcile hiccup never + # loses the refreshed cache (H2 restructure). + async with _tenant_session() as db: await _reconcile_people(db, _uid(user)) fresh = (await db.execute( text("SELECT * FROM task_accounts WHERE id = :id"), {"id": account_id}, )).fetchone() return _row_to_account(fresh) - finally: - await db.close() @router.post("/accounts/{account_id}/projects", status_code=201) @@ -320,8 +310,7 @@ async def create_account_project( name = (req.name or "").strip() if not name: raise HTTPException(status_code=400, detail="Project name is required") - db = await _get_db() - try: + async with _tenant_session() as db: row = await _assert_account_owner(db, account_id, _uid(user)) creds = json.loads(_key_store().decrypt(row.credentials_encrypted)) provider = build_provider( @@ -373,14 +362,11 @@ async def create_account_project( WHERE id = :id"""), {"id": account_id, "cache": json.dumps(cache)}, ) - await db.commit() return { "project_id": str(proj_row.id), "provider_ref": created["id"], "name": name, } - finally: - await db.close() class CreateAccountFolderRequest(BaseModel): @@ -401,8 +387,7 @@ async def create_account_folder( name = (req.name or "").strip() if not name: raise HTTPException(status_code=400, detail="Folder name is required") - db = await _get_db() - try: + async with _tenant_session() as db: row = await _assert_account_owner(db, account_id, _uid(user)) creds = json.loads(_key_store().decrypt(row.credentials_encrypted)) provider = build_provider( @@ -424,14 +409,11 @@ async def create_account_folder( WHERE id = :id"""), {"id": account_id, "cache": json.dumps(cache)}, ) - await db.commit() return { "folder_id": created["id"], "space_id": req.space_id, "name": created["name"], } - finally: - await db.close() async def _refresh_schema(db: Any, account_id: str, user_id: str) -> None: @@ -440,6 +422,12 @@ async def _refresh_schema(db: Any, account_id: str, user_id: str) -> None: Mirrored provider lists become SYNCED ``gtd_projects`` rows (upsert on (account_id, provider_ref)) so projects from every source render in the one unified picker (§5.1). + + Does NOT commit, and no longer calls ``_reconcile_people`` itself — the + caller owns the transaction and sequences the reconcile (H2: a mid-helper + commit inside a ``_tenant_session`` block would drop the tenant GUC for + every statement after it). Request handlers run inside a tenant block whose + clean exit commits; the background scheduler commits explicitly. """ row = (await db.execute( text("SELECT * FROM task_accounts WHERE id = :id"), {"id": account_id}, @@ -468,10 +456,6 @@ async def _refresh_schema(db: Any, account_id: str, user_id: str) -> None: {"id": str(uuid4()), "uid": user_id, "aid": account_id, "ref": str(proj.get("id")), "outcome": name}, ) - await db.commit() - # Reflect current ClickUp membership into the org roster (§6): add joiners, - # link matches, deactivate people we auto-added who have since left. - await _reconcile_people(db, user_id) async def _reconcile_people(db: Any, user_id: str) -> None: @@ -490,6 +474,10 @@ async def _reconcile_people(db: Any, user_id: str) -> None: NEVER touches manually-added or seed-imported people's status (the user owns those). Best-effort — a reconcile failure never breaks the sync/refresh. + + Does NOT commit — the caller owns the transaction (a request handler's + ``_tenant_session`` block commits on clean exit; the background scheduler + commits explicitly). H2: a mid-block commit would drop the tenant GUC. """ try: acct_rows = (await db.execute( @@ -569,6 +557,5 @@ async def _reconcile_people(db: Any, user_id: str) -> None: updated_at = now()"""), {"id": str(uuid4()), "name": name, "email": m.get("email"), "pid": pid}) - await db.commit() except Exception as exc: # noqa: BLE001 _log.warning("tasks.reconcile_people_failed", error=str(exc)[:200]) diff --git a/apps/services/gateway/gateway/routes/tasks/ai.py b/apps/services/gateway/gateway/routes/tasks/ai.py index be73906ba..3abf3cc91 100644 --- a/apps/services/gateway/gateway/routes/tasks/ai.py +++ b/apps/services/gateway/gateway/routes/tasks/ai.py @@ -23,8 +23,8 @@ from gateway.routes.tasks.core import ( DEFAULT_CONTEXTS, PROJECT_SELECT, - _get_db, _parse_jsonb, + _tenant_session, _uid, router, ) @@ -1196,8 +1196,7 @@ async def clarify_item( An optional `note` (request body) is freeform user guidance that steers the title/project/steps for this pass.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: item = await _fetch_item(db, item_id, uid) projects = (await db.execute( text(PROJECT_SELECT + " WHERE p.user_id = :uid"), {"uid": uid}, @@ -1291,8 +1290,6 @@ async def clarify_item( db, uid, item, str(proposal["project_id"]), models["clarify"]) return proposal - finally: - await db.close() # ── Enrich: fill a task's MISSING GTD fields (never overwrite) ──────────────── @@ -1508,13 +1505,10 @@ async def enrich_item( assignee). Proposes only — the client applies via PATCH. Empty `fields` when nothing was missing or nothing could be confidently filled.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: item = await _fetch_item(db, item_id, uid) return {"item_id": item_id, "fields": await _enrich_fields( db, uid, item, only=None)} - finally: - await db.close() async def _llm_suggest_title(title: str, notes: str | None, model: str) -> dict[str, Any]: @@ -1572,8 +1566,7 @@ async def suggest_title( editing it live. LLM-backed; degrades to {is_vague:false, suggested_title: null} when the assistant is off/unreachable.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: item = await _fetch_item(db, item_id, uid) from gateway.routes.tasks.settings import gtd_models, gtd_toggles if not (await gtd_toggles(db, uid))["clarify_use_llm"]: @@ -1582,8 +1575,6 @@ async def suggest_title( use_title = (title or item.title or "").strip() return await _llm_suggest_title( use_title, getattr(item, "description", None), models["clarify"]) - finally: - await db.close() @router.post("/ai/backfill-context") @@ -1594,8 +1585,7 @@ async def backfill_context(user: UserContext = Depends(get_current_user)): pushed upstream); returns how many were set. LLM-backed with a heuristic fallback, capped so one call can't run unbounded.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute(text( """SELECT * FROM gtd_items WHERE user_id = :uid @@ -1633,10 +1623,7 @@ async def _ctx_for(item: Any) -> str | None: WHERE id = :id AND user_id = :uid"""), {"ctx": ctx, "id": str(item.id), "uid": uid}) updated += 1 - await db.commit() return {"scanned": len(rows), "updated": updated} - finally: - await db.close() @router.get("/insights") @@ -1644,8 +1631,7 @@ async def inbox_insights(user: UserContext = Depends(get_current_user)): """Whole-inbox signals for the processing surface: counts, aging, project clusters, stale waiting-fors. (Agent narration comes later.)""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: counts = (await db.execute(text( """SELECT disposition, count(*) AS n FROM gtd_items WHERE user_id = :uid GROUP BY disposition"""), {"uid": uid}, @@ -1675,8 +1661,6 @@ async def inbox_insights(user: UserContext = Depends(get_current_user)): "stale_waiting": stale.n if stale else 0, "projects_without_next_action": no_next.n if no_next else 0, } - finally: - await db.close() # ── Atomize + dedup: mind-dump → atomic captures (§2.1 seam) ───────────────── @@ -1856,15 +1840,11 @@ async def atomize_dump( uid = _uid(user) # Per-user model choice (gtd_settings) — cheap read, defaults on failure. from gateway.routes.tasks.settings import gtd_models - _mdb = await _get_db() - try: + async with _tenant_session() as _mdb: models = await gtd_models(_mdb, uid) - finally: - await _mdb.close() existing: list[dict[str, Any]] = [] if req.dedup: - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute(text( """SELECT id, title, disposition, source FROM gtd_items WHERE user_id = :uid @@ -1875,8 +1855,6 @@ async def atomize_dump( existing = [{"id": str(r.id), "title": r.title, "disposition": r.disposition, "source": r.source} for r in rows if str(r.id) not in skip] - finally: - await db.close() llm_items = await _llm_atomize(text_, existing, model=models["atomize"]) used_llm = llm_items is not None diff --git a/apps/services/gateway/gateway/routes/tasks/attachments.py b/apps/services/gateway/gateway/routes/tasks/attachments.py index 1a5ce29df..212e8ef08 100644 --- a/apps/services/gateway/gateway/routes/tasks/attachments.py +++ b/apps/services/gateway/gateway/routes/tasks/attachments.py @@ -21,7 +21,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException, UploadFile from fastapi.responses import FileResponse -from gateway.routes.tasks.core import _get_db, _uid, router +from gateway.routes.tasks.core import _tenant_session, _uid, router from sqlalchemy import text _MAX_BYTES = 15 * 1024 * 1024 # 15 MB per attachment @@ -66,17 +66,13 @@ async def upload_attachment( dest = _storage_dir() / f"{att_id}{Path(name).suffix.lower()}" dest.write_bytes(content) - db = await _get_db() - try: + async with _tenant_session() as db: await db.execute(text( """INSERT INTO gtd_attachments (id, user_id, name, mime, size_bytes, path) VALUES (:id, :uid, :name, :mime, :size, :path)"""), {"id": att_id, "uid": _uid(user), "name": name, "mime": mime, "size": len(content), "path": str(dest)}) - await db.commit() - finally: - await db.close() return { "attachment_id": att_id, @@ -94,14 +90,11 @@ async def serve_attachment( filename: str, # cosmetic — the row's stored name wins user: UserContext = Depends(get_current_user), ): - db = await _get_db() - try: + async with _tenant_session() as db: row = (await db.execute(text( """SELECT name, mime, path FROM gtd_attachments WHERE id = :id AND user_id = :uid"""), {"id": attachment_id, "uid": _uid(user)})).fetchone() - finally: - await db.close() if row is None or not Path(row.path).is_file(): raise HTTPException(status_code=404, detail="Attachment not found") return FileResponse(row.path, media_type=row.mime or "application/octet-stream", diff --git a/apps/services/gateway/gateway/routes/tasks/broker_handlers.py b/apps/services/gateway/gateway/routes/tasks/broker_handlers.py index 53abbc1ee..a0f70b055 100644 --- a/apps/services/gateway/gateway/routes/tasks/broker_handlers.py +++ b/apps/services/gateway/gateway/routes/tasks/broker_handlers.py @@ -32,6 +32,14 @@ async def _resolve_provider(account_id: str): """Rebuild a provider (with its token) from a ``task_accounts`` id.""" + # ⚠️ H4, DELIBERATELY NOT H2 (`saas_multitenancy_handover.md`): this module + # is an ACTION-BROKER CONSUMER, not a request handler — `execute()` runs + # when an owner approves a queued proposal, outside the request (and tenant + # binding) that enqueued it. The runbook's rule for that category is "do + # not let a job inherit an ambient tenant", so this site stays on the + # unbound `get_db()` until H4 threads an EXPLICIT tenant through the queued + # proposal (`tenant_session(org_id)`). Sequencing is safe: RLS phase 4 is + # gated on H2+H4 both being complete. from gateway.routes.tasks.core import _get_db, _key_store from gateway.routes.tasks.providers import build_provider from sqlalchemy import text diff --git a/apps/services/gateway/gateway/routes/tasks/calendar.py b/apps/services/gateway/gateway/routes/tasks/calendar.py index 02864dce1..6582e9e38 100644 --- a/apps/services/gateway/gateway/routes/tasks/calendar.py +++ b/apps/services/gateway/gateway/routes/tasks/calendar.py @@ -30,9 +30,13 @@ from fastapi import Depends, HTTPException from gateway.routes.tasks.core import ( ITEM_SELECT, + # `_get_db` is used ONLY by the auto-rollover background job at the bottom + # of this file (H4 — see the comment there); every request handler in this + # module uses `_tenant_session`. _get_db, _log, _row_to_item, + _tenant_session, _uid, router, ) @@ -115,8 +119,7 @@ async def get_day_state( if not d: raise HTTPException(status_code=400, detail="day must be YYYY-MM-DD.") uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: row = (await db.execute( text("SELECT one_thing_id, seed_ids FROM gtd_day_state " "WHERE user_id = :uid AND day = :day"), @@ -132,8 +135,6 @@ async def get_day_state( if isinstance(raw, list): seeds = [str(x) for x in raw if x] return DayStateModel(day=d.isoformat(), one_thing_id=one, seed_ids=seeds) - finally: - await db.close() @router.put("/calendar/day-state", response_model=DayStateModel) @@ -147,8 +148,7 @@ async def put_day_state( raise HTTPException(status_code=400, detail="day must be YYYY-MM-DD.") uid = _uid(user) provided = patch.__pydantic_fields_set__ - db = await _get_db() - try: + async with _tenant_session() as db: # Ensure a row exists, then update only the provided columns. await db.execute( text("INSERT INTO gtd_day_state (user_id, day) VALUES (:uid, :day) " @@ -166,10 +166,9 @@ async def put_day_state( text("UPDATE gtd_day_state SET seed_ids = CAST(:s AS jsonb), " "updated_at = now() WHERE user_id = :uid AND day = :day"), {"s": seeds, "uid": uid, "day": d}) - await db.commit() - return await get_day_state(patch.day, user) # echo the stored row - finally: - await db.close() + # Echo the stored row — get_day_state opens its own session, so it must run + # AFTER the block above committed (H2 restructure). + return await get_day_state(patch.day, user) # ── AI day-planner (P2) ────────────────────────────────────────────────────── @@ -559,16 +558,13 @@ async def estimate_stats(user: UserContext = Depends(get_current_user)): """Planned-vs-actual accuracy over recent timed blocks — the learned-estimate signal shown in the end-of-day review (and used to pad the planner). §3 P3.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: ratio, n = await _estimate_ratio(db, uid) return { "samples": n, "ratio": round(ratio, 2), "over_pct": round((ratio - 1) * 100), } - finally: - await db.close() _CANDIDATE_WHERE = ( @@ -1027,13 +1023,10 @@ async def plan_day( status_code=400, detail="Valid day_start/day_end (ISO) required.") uid = _uid(user) now = datetime.now(UTC) - db = await _get_db() - try: + async with _tenant_session() as db: one = await _one_thing_for(db, uid, win_start.date()) return await _replan_core( db, uid, win_start, win_end, req, now, one, include_new=True) - finally: - await db.close() # ── Roll-over = RETURN unfinished tasks to the unscheduled list ────────────── @@ -1063,8 +1056,7 @@ async def rollover_day( status_code=400, detail="Valid day_start/day_end (ISO) required.") uid = _uid(user) now = datetime.now(UTC) - db = await _get_db() - try: + async with _tenant_session() as db: over_rows = (await db.execute( text(ITEM_SELECT + _OVERDUE_WHERE), {"uid": uid, "now": now}, )).fetchall() @@ -1079,8 +1071,6 @@ async def rollover_day( return DayPlan( blocks=[], unplaced=[], evicted=evicted, notes=notes, used_mins=0, capacity_mins=req.capacity_mins) - finally: - await db.close() @router.post("/calendar/replan", response_model=DayPlan) @@ -1100,13 +1090,10 @@ async def replan_day( status_code=400, detail="Valid day_start/day_end (ISO) required.") uid = _uid(user) now = datetime.now(UTC) - db = await _get_db() - try: + async with _tenant_session() as db: one = await _one_thing_for(db, uid, win_start.date()) return await _replan_core( db, uid, win_start, win_end, req, now, one, include_new=False) - finally: - await db.close() # ── Agent-facing planner (no client geometry) ──────────────────────────────── @@ -1193,7 +1180,9 @@ async def _build_agent_request( async def _apply_plan_blocks(db: Any, uid: str, plan: DayPlan) -> None: """Write a proposed plan to the calendar: place the blocks (scheduled_start/ end) AND clear any EVICTED blocks (schedule → NULL, back to the unscheduled - list). Only reached via the apply path, which replays a reviewed plan.""" + list). Only reached via the apply path, which replays a reviewed plan. + Does NOT commit — the handler's `_tenant_session` block commits on clean + exit (H2), which also makes the whole apply atomic.""" for b in plan.blocks: s, e = _parse_iso(b.start), _parse_iso(b.end) if not s or not e: @@ -1208,7 +1197,6 @@ async def _apply_plan_blocks(db: Any, uid: str, plan: DayPlan) -> None: " scheduled_end = NULL, updated_at = now()" " WHERE id = :id AND user_id = :uid"), {"id": ev.item_id, "uid": uid}) - await db.commit() # ── Reviewed-plan gate (R1/S1) ─────────────────────────────────────────────── @@ -1231,7 +1219,6 @@ async def _store_pending_plan( "ON CONFLICT (user_id, day) DO UPDATE SET " "pending_plan = CAST(:p AS jsonb), updated_at = now()"), {"uid": uid, "day": local_day, "p": payload}) - await db.commit() async def _take_pending_plan( @@ -1256,7 +1243,6 @@ async def _take_pending_plan( text("UPDATE gtd_day_state SET pending_plan = NULL, updated_at = now() " "WHERE user_id = :uid AND day = :day"), {"uid": uid, "day": local_day}) - await db.commit() try: return DayPlan(**data["plan"]) except Exception: @@ -1271,6 +1257,10 @@ async def _resolve_agent_plan( - propose (apply=false): stash `fresh`, return it (applied=False). - apply (apply=true): replay the stored proposal verbatim and clear it; if none is pending, fall back to proposing `fresh` (never a blind write). + + Neither this nor the helpers it calls commit — the handler's + `_tenant_session` block commits everything on clean exit (H2), which makes + take-pending + apply one atomic transaction. """ # A plan is worth storing/applying if it either PLACES blocks or EVICTS/ # RELEASES some (rollover returns only evicted — no blocks). @@ -1301,8 +1291,7 @@ async def plan_today( settings, plans (One-Thing-aware), and — if `apply` — writes the blocks.""" uid = _uid(user) now = datetime.now(UTC) - db = await _get_db() - try: + async with _tenant_session() as db: pdr, win_start, win_end, _tz, local_day = await _build_agent_request( db, uid, req.date, req.energy_note) one = await _one_thing_for(db, uid, local_day) @@ -1310,8 +1299,6 @@ async def plan_today( db, uid, win_start, win_end, pdr, now, one, include_new=True) return await _resolve_agent_plan( db, uid, local_day, "plan", req.apply, plan, now) - finally: - await db.close() @router.post("/calendar/replan-today", response_model=DayPlan) @@ -1323,15 +1310,12 @@ async def replan_today( `apply` replays the reviewed proposal (see _resolve_agent_plan).""" uid = _uid(user) now = datetime.now(UTC) - db = await _get_db() - try: + async with _tenant_session() as db: pdr, _ws, _we, _tz, local_day = await _build_agent_request( db, uid, req.date, None) plan = await replan_day(pdr, user) return await _resolve_agent_plan( db, uid, local_day, "replan", req.apply, plan, now) - finally: - await db.close() @router.post("/calendar/rollover-today", response_model=DayPlan) @@ -1343,15 +1327,12 @@ async def rollover_today( reviewed proposal (see _resolve_agent_plan).""" uid = _uid(user) now = datetime.now(UTC) - db = await _get_db() - try: + async with _tenant_session() as db: pdr, _ws, _we, _tz, local_day = await _build_agent_request( db, uid, req.date, None) plan = await rollover_day(pdr, user) return await _resolve_agent_plan( db, uid, local_day, "rollover", req.apply, plan, now) - finally: - await db.close() @router.get("/calendar/day-summary") @@ -1363,8 +1344,7 @@ async def day_summary( Thing, and estimate accuracy. See calendar_ai_review.md §4.4.""" uid = _uid(user) now = datetime.now(UTC) - db = await _get_db() - try: + async with _tenant_session() as db: row = await _load_settings_row(db, uid) tz = _tz_of(row) local_day = _parse_day(date) or now.astimezone(tz).date() @@ -1410,8 +1390,6 @@ async def day_summary( "one_thing": ({"id": one_id, "title": one_title} if one_id else None), "estimate_over_pct": round((ratio - 1) * 100) if samples >= 5 else None, } - finally: - await db.close() @router.get("/calendar/rollover/log") @@ -1420,8 +1398,7 @@ async def rollover_log( ): """Recent automatic roll-overs (audit/history): what moved, from → to.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text("""SELECT item_id, title, rolled_from, rolled_to, created_at FROM gtd_rollover_log WHERE user_id = :uid @@ -1438,8 +1415,6 @@ async def rollover_log( } for r in rows ] - finally: - await db.close() # ── Automatic roll-over background job (nightly, per local day) ─────────────── @@ -1449,6 +1424,15 @@ async def rollover_log( # their overdue-incomplete blocks into the new day and APPLIES it (this is the # only place scheduling changes are written server-side, without the client). # Server-side geometry needs the user's timezone + prefs, all stored (mig 77/78). +# +# ⚠️ H4, DELIBERATELY NOT H2 (`saas_multitenancy_handover.md`): everything below +# runs from `asyncio.create_task`, long after any request (and its tenant +# binding) is gone — and the sweep itself is CROSS-tenant by construction (it +# reads every user's gtd_settings row). The runbook's rule for background +# consumers is "do not let a job inherit an ambient tenant", so these two sites +# stay on the unbound `get_db()` until H4 threads an EXPLICIT per-user tenant +# into `tenant_session(org_id)` inside the per-user loop. Sequencing is safe: +# RLS phase 4 (which would starve these reads) is gated on H2+H4 both complete. _rollover_task: asyncio.Task | None = None _ROLLOVER_TICK_SECS = 900 # 15 min — catches each local day boundary promptly. diff --git a/apps/services/gateway/gateway/routes/tasks/capability.py b/apps/services/gateway/gateway/routes/tasks/capability.py index e5177ae81..f88986fb2 100644 --- a/apps/services/gateway/gateway/routes/tasks/capability.py +++ b/apps/services/gateway/gateway/routes/tasks/capability.py @@ -26,7 +26,7 @@ from acb_auth import UserContext, get_current_user from acb_common import get_logger from fastapi import Depends -from gateway.routes.tasks.core import _get_db, require_people_write, router +from gateway.routes.tasks.core import _tenant_session, require_people_write, router from sqlalchemy import text _log = get_logger("gateway.tasks.capability") @@ -236,9 +236,9 @@ async def backfill_people_embeddings( if not _semantic_enabled(): return {"enabled": False, "embedded": 0, "detail": "task_semantic_match_enabled is off."} - db = await _get_db() - try: + # `embed_pending_people` commits as its LAST database action, so it may be + # the sole occupant of this tenant block (H2): every statement runs under + # the GUC and the wrapper's exit commit is an empty no-op. + async with _tenant_session() as db: n = await embed_pending_people(db) return {"enabled": True, "embedded": n} - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/tasks/capture_email.py b/apps/services/gateway/gateway/routes/tasks/capture_email.py index 6424eaf7c..eabf15b18 100644 --- a/apps/services/gateway/gateway/routes/tasks/capture_email.py +++ b/apps/services/gateway/gateway/routes/tasks/capture_email.py @@ -33,9 +33,9 @@ from gateway.routes.tasks.core import ( ITEM_SELECT, GtdItemModel, - _get_db, _parse_jsonb, _row_to_item, + _tenant_session, _uid, router, ) @@ -636,8 +636,7 @@ async def capture_from_email( user: UserContext = Depends(get_current_user), ): uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: # Owner check THROUGH the email account: the email must belong to one of # the user's mailboxes. Pull to/cc/thread + the account's own address. email = (await db.execute(text( @@ -797,7 +796,6 @@ async def capture_from_email( {"iid": item_id, "who": json.dumps(waiting_on), "now": datetime.now(tz=UTC)}, ) - await db.commit() row = (await db.execute( text(ITEM_SELECT + " WHERE i.id = :id"), {"id": item_id}, )).fetchone() @@ -807,8 +805,6 @@ async def capture_from_email( disposition=item.disposition, assignee_name=item.assignee.name if item.assignee else None, due_at=item.due_at) - finally: - await db.close() # ── Popup flow: preview → enhance → create ─────────────────────────────────── @@ -829,8 +825,7 @@ async def preview_capture_from_email( fuzzy title). If this exact email was already captured, surface the existing item so the popup can offer to open it instead of duplicating.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: email = await _load_email(db, uid, req.account_id, req.email_id) from_addr = _parse_addr(email.from_address) from_name = str(from_addr.get("name") or from_addr.get("email") or "") @@ -853,8 +848,6 @@ async def preview_capture_from_email( return CapturePreviewResponse( already_captured=already, draft=draft, similar=similar, from_name=from_name, subject=email.subject or "") - finally: - await db.close() @router.post("/capture/from-email/enhance", @@ -867,8 +860,7 @@ async def enhance_capture_from_email( returns a routed draft (title/notes/disposition/due/delegate/context). No write — the user still reviews and confirms in the popup.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: email = await _load_email(db, uid, req.account_id, req.email_id) from_addr = _parse_addr(email.from_address) from_name = str(from_addr.get("name") or from_addr.get("email") or "") @@ -919,8 +911,6 @@ async def enhance_capture_from_email( return CaptureEnhanceResponse( draft=draft, used_llm=used_llm, assignee_resolved=resolved["name"] if resolved else None) - finally: - await db.close() @router.post("/capture/from-email/create", @@ -933,8 +923,7 @@ async def create_capture_from_email( if the user left the popup open and the email was captured meanwhile, the existing item wins rather than creating a duplicate.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: email = await _load_email(db, uid, req.account_id, req.email_id) existing = await _find_existing_capture(db, uid, str(email.id)) @@ -950,7 +939,6 @@ async def create_capture_from_email( item_id, _disp, _assignee = await _route_and_persist( db, uid, email, req.draft.model_dump(), people) - await db.commit() row = (await db.execute( text(ITEM_SELECT + " WHERE i.id = :id"), {"id": item_id}, )).fetchone() @@ -960,8 +948,6 @@ async def create_capture_from_email( disposition=item.disposition, assignee_name=item.assignee.name if item.assignee else None, due_at=item.due_at) - finally: - await db.close() # ── Commitment capture: a reply I SENT that promises a future action ────────── @@ -1271,8 +1257,7 @@ async def detect_commitment_from_reply( commitment — or if this thread already has an open commitment task — returns is_commitment=false so no popup opens. No write happens here.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: owner_name, owner_email = await _account_owner(db, uid, req.account_id) # Already have a commitment task on this thread → don't re-prompt. @@ -1323,8 +1308,6 @@ async def detect_commitment_from_reply( db, uid, req.thread_id, "", draft.title) return DetectCommitmentResponse( is_commitment=True, draft=draft, similar=similar, used_llm=True) - finally: - await db.close() @router.post("/capture/from-reply/create", @@ -1338,8 +1321,7 @@ async def create_commitment_from_reply( thread with the "Task" category so the mailbox surfaces it. Idempotent per thread — a second confirm on the same thread returns the existing task.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: owner_name, owner_email = await _account_owner(db, uid, req.account_id) existing = await _find_existing_commitment(db, uid, req.thread_id) @@ -1371,7 +1353,6 @@ async def create_commitment_from_reply( ), {"id": item_id}) await _tag_thread_task_category( db, req.account_id, req.thread_id, uid) - await db.commit() row = (await db.execute( text(ITEM_SELECT + " WHERE i.id = :id"), {"id": item_id}, @@ -1381,5 +1362,3 @@ async def create_commitment_from_reply( item=item, created=True, disposition=item.disposition, assignee_name=item.assignee.name if item.assignee else None, due_at=item.due_at) - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/tasks/core.py b/apps/services/gateway/gateway/routes/tasks/core.py index a0ad26b9b..30f584e4c 100644 --- a/apps/services/gateway/gateway/routes/tasks/core.py +++ b/apps/services/gateway/gateway/routes/tasks/core.py @@ -23,6 +23,18 @@ # why these keep their private names. from gateway.db import get_db as _get_db # noqa: F401 from gateway.db import get_session_factory as _get_session_factory # noqa: F401 + +# The tenant-bound seam (MT-1c / H2). `_tenant_session` IS +# `acb_common.db.tenant_session`, aliased per-package for the same reason +# `_get_db` was: every request handler in this package imports it from here BY +# NAME, which is the seam the hermetic tests patch per module (mirrors +# `routes/projects/core.py`). The tenant comes from the request context — bound +# once in `_with_resolved_access` — so no call site passes one (H2). A call +# outside a bound request raises `TenantUnbound` rather than defaulting: fail +# closed, never "the usual org". `_get_db` above remains ONLY for the package's +# background consumers (broker_handlers, scheduler, calendar's rollover sweep), +# which are H4's to convert — a job must not inherit an ambient tenant. +from gateway.db import tenant_session as _tenant_session # noqa: F401 from pydantic import BaseModel from sqlalchemy import text diff --git a/apps/services/gateway/gateway/routes/tasks/hierarchy.py b/apps/services/gateway/gateway/routes/tasks/hierarchy.py index 18051c810..1a18a0f32 100644 --- a/apps/services/gateway/gateway/routes/tasks/hierarchy.py +++ b/apps/services/gateway/gateway/routes/tasks/hierarchy.py @@ -17,7 +17,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException -from gateway.routes.tasks.core import _get_db, _uid, router +from gateway.routes.tasks.core import _tenant_session, _uid, router from pydantic import BaseModel from sqlalchemy import text @@ -56,8 +56,7 @@ async def local_hierarchy(user: UserContext = Depends(get_current_user)): client assembles into a tree). SYNCED projects are excluded — their tree lives on the connected account.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: spaces = (await db.execute( text("""SELECT id, name FROM gtd_spaces WHERE user_id = :uid ORDER BY sort_key ASC NULLS LAST, name"""), @@ -89,8 +88,6 @@ async def local_hierarchy(user: UserContext = Depends(get_current_user)): has_next_action=bool(p.has_next_action), status=p.status) for p in projects], ) - finally: - await db.close() class CreateSpaceRequest(BaseModel): @@ -106,18 +103,14 @@ async def create_space( if not name: raise HTTPException(status_code=400, detail="Space needs a name") uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: sid = str(uuid4()) await db.execute( text("""INSERT INTO gtd_spaces (id, user_id, name) VALUES (:id, :uid, :name)"""), {"id": sid, "uid": uid, "name": name}, ) - await db.commit() return SpaceModel(id=sid, name=name) - finally: - await db.close() class CreateFolderRequest(BaseModel): @@ -134,8 +127,7 @@ async def create_folder( if not name: raise HTTPException(status_code=400, detail="Folder needs a name") uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: await _assert_space_owner(db, req.space_id, uid) fid = str(uuid4()) await db.execute( @@ -143,10 +135,7 @@ async def create_folder( VALUES (:id, :uid, :sid, :name)"""), {"id": fid, "uid": uid, "sid": req.space_id, "name": name}, ) - await db.commit() return FolderModel(id=fid, space_id=req.space_id, name=name) - finally: - await db.close() class CreateLocalProjectRequest(BaseModel): @@ -168,8 +157,7 @@ async def create_local_project( if not outcome: raise HTTPException(status_code=400, detail="Project needs an outcome") uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: space_id = req.space_id folder_id = req.folder_id if folder_id: @@ -195,12 +183,9 @@ async def create_local_project( "purpose": (req.purpose or "").strip() or None, "sid": space_id, "fid": folder_id}, ) - await db.commit() return LocalProjectNode( id=pid, outcome=outcome, space_id=space_id, folder_id=folder_id, has_next_action=False, status="ACTIVE") - finally: - await db.close() async def _assert_space_owner(db: Any, space_id: str, uid: str) -> None: diff --git a/apps/services/gateway/gateway/routes/tasks/items.py b/apps/services/gateway/gateway/routes/tasks/items.py index dd8ea4560..f8dbfc156 100644 --- a/apps/services/gateway/gateway/routes/tasks/items.py +++ b/apps/services/gateway/gateway/routes/tasks/items.py @@ -30,12 +30,12 @@ GtdProjectModel, PersonModel, _assert_account_owner, - _get_db, _key_store, _log, _parse_jsonb, _row_to_item, _row_to_project, + _tenant_session, _uid, router, ) @@ -210,8 +210,7 @@ async def capture_item( title = req.title.strip() if not title: raise HTTPException(status_code=400, detail="Empty capture") - db = await _get_db() - try: + async with _tenant_session() as db: item_id = str(uuid4()) await db.execute( text("""INSERT INTO gtd_items @@ -226,10 +225,7 @@ async def capture_item( "due": _parse_ts(req.due_at), "hard": bool(req.is_hard_date and req.due_at)}, ) - await db.commit() return _row_to_item(await _fetch_item(db, item_id, _uid(user))) - finally: - await db.close() @router.post("/items/batch", response_model=list[GtdItemModel], status_code=201) @@ -241,8 +237,7 @@ async def capture_batch( titles = [t.strip() for t in req.titles if t.strip()] if not titles: raise HTTPException(status_code=400, detail="No items to capture") - db = await _get_db() - try: + async with _tenant_session() as db: out = [] for title in titles: item_id = str(uuid4()) @@ -252,14 +247,11 @@ async def capture_batch( {"id": item_id, "uid": _uid(user), "title": title}, ) out.append(item_id) - await db.commit() rows = (await db.execute( text(ITEM_SELECT + " WHERE i.id::text = ANY(:ids)"), {"ids": out}, )).fetchall() by_id = {str(r.id): r for r in rows} return [_row_to_item(by_id[i]) for i in out if i in by_id] - finally: - await db.close() # ── Browse ─────────────────────────────────────────────────────────────────── @@ -303,8 +295,7 @@ async def list_items( clauses.append("i.source = 'LOCAL'") elif src == "synced": clauses.append("i.source <> 'LOCAL'") - db = await _get_db() - try: + async with _tenant_session() as db: # LOCAL (unprocessed / ours) rows ALWAYS sort first, so a large synced # mirror can never push our own captures past the row cap — the Inbox # invariant ("unprocessed items are always visible") holds regardless of @@ -319,8 +310,6 @@ async def list_items( params, )).fetchall() return [_row_to_item(r) for r in rows] - finally: - await db.close() @router.get("/calendar", response_model=list[GtdItemModel]) @@ -338,8 +327,7 @@ async def calendar_range( if frm is None or until is None: raise HTTPException( status_code=400, detail="from and to must be ISO datetimes") - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text(ITEM_SELECT + f""" WHERE i.user_id = :uid @@ -358,29 +346,23 @@ async def calendar_range( {"uid": _uid(user), "frm": frm, "until": until}, )).fetchall() return [_row_to_item(r) for r in rows] - finally: - await db.close() @router.get("/projects", response_model=list[GtdProjectModel]) async def list_projects(user: UserContext = Depends(get_current_user)): - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text(PROJECT_SELECT + " WHERE p.user_id = :uid " "ORDER BY p.source, p.created_at DESC"), {"uid": _uid(user)}, )).fetchall() return [_row_to_project(r) for r in rows] - finally: - await db.close() @router.get("/contexts") async def list_contexts(user: UserContext = Depends(get_current_user)): """The user's @ lists — seeded with the GTD defaults on first read.""" - db = await _get_db() - try: + async with _tenant_session() as db: for i, (name, icon) in enumerate(DEFAULT_CONTEXTS): await db.execute( text("""INSERT INTO gtd_contexts (user_id, name, icon, sort_order) @@ -388,24 +370,18 @@ async def list_contexts(user: UserContext = Depends(get_current_user)): ON CONFLICT (user_id, name) DO NOTHING"""), {"uid": _uid(user), "name": name, "icon": icon, "ord": i}, ) - await db.commit() rows = (await db.execute( text("""SELECT name, icon FROM gtd_contexts WHERE user_id = :uid ORDER BY sort_order, name"""), {"uid": _uid(user)}, )).fetchall() return {"contexts": [{"name": r.name, "icon": r.icon} for r in rows]} - finally: - await db.close() @router.get("/items/{item_id}", response_model=GtdItemModel) async def get_item(item_id: str, user: UserContext = Depends(get_current_user)): - db = await _get_db() - try: + async with _tenant_session() as db: return _row_to_item(await _fetch_item(db, item_id, _uid(user))) - finally: - await db.close() @router.delete("/items/{item_id}", status_code=204) @@ -416,8 +392,7 @@ async def delete_item(item_id: str, user: UserContext = Depends(get_current_user ClickUp task deleted — by an explicit purge (POST /items/{id}/purge) once the client's undo window has passed. Idempotent: re-deleting a tombstoned row just refreshes the timestamp.""" - db = await _get_db() - try: + async with _tenant_session() as db: res = (await db.execute( text("""UPDATE gtd_items SET deleted_at = now(), updated_at = now() @@ -426,9 +401,6 @@ async def delete_item(item_id: str, user: UserContext = Depends(get_current_user )).fetchone() if res is None: raise HTTPException(status_code=404, detail="Item not found") - await db.commit() - finally: - await db.close() @router.post("/items/{item_id}/restore", response_model=GtdItemModel) @@ -437,8 +409,7 @@ async def restore_item(item_id: str, user: UserContext = Depends(get_current_use exactly as it was (nothing was touched upstream). 404 if there's no soft-deleted row with this id (already purged, or never deleted).""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: res = (await db.execute( text("""UPDATE gtd_items SET deleted_at = NULL, updated_at = now() @@ -448,10 +419,7 @@ async def restore_item(item_id: str, user: UserContext = Depends(get_current_use )).fetchone() if res is None: raise HTTPException(status_code=404, detail="No deleted item to restore") - await db.commit() return _row_to_item(await _fetch_item(db, item_id, uid)) - finally: - await db.close() @router.post("/items/{item_id}/purge", status_code=204) @@ -466,8 +434,7 @@ async def purge_item(item_id: str, user: UserContext = Depends(get_current_user) never blocks the local purge (the user asked to delete — we don't strand the task locally because ClickUp hiccuped).""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: row = (await db.execute( text(ITEM_SELECT + " WHERE i.id = :id AND i.user_id = :uid"), {"id": item_id, "uid": uid}, @@ -481,9 +448,6 @@ async def purge_item(item_id: str, user: UserContext = Depends(get_current_user) text("DELETE FROM gtd_items WHERE id = :id AND user_id = :uid"), {"id": item_id, "uid": uid}, ) - await db.commit() - finally: - await db.close() async def _delete_upstream(db: Any, row: Any, uid: str) -> None: @@ -650,8 +614,7 @@ async def patch_item( user: UserContext = Depends(get_current_user), ): from gateway.routes.tasks.settings import gtd_workflow_stages - db = await _get_db() - try: + async with _tenant_session() as db: # A board move (workflow_stage) that crosses the DONE boundary also flips # the disposition, so the card lands where the drop implies: # • dropped on the LAST stage → mark DONE (completed_at + ClickUp close) @@ -707,14 +670,14 @@ async def patch_item( propagate_task_done_to_thread) with contextlib.suppress(Exception): # best-effort; close stands await propagate_task_done_to_thread(db, before) - await db.commit() - # Back-sync the edit to the connected tool (SYNCED tasks only). Runs - # AFTER the local commit and is best-effort: the user's edit is already - # saved, so an upstream hiccup logs but never loses the edit. + # Back-sync the edit to the connected tool (SYNCED tasks only). Runs + # AFTER the local edit committed (the block above) and is best-effort: + # the user's edit is already saved, so an upstream hiccup logs but never + # loses it. A second transaction, not a mid-block commit — a commit inside + # the tenant block would drop the GUC for everything after it (H2). + async with _tenant_session() as db: await _push_patch_upstream(db, before, patch, _uid(user)) return _row_to_item(await _fetch_item(db, item_id, _uid(user))) - finally: - await db.close() async def _status_for_stage( @@ -800,7 +763,9 @@ async def _push_patch_upstream( ) -> None: """Back-sync a PATCH to the connected PM tool for a SYNCED, already-pushed task. No-op for LOCAL/pending items. Best-effort — never raises to the - caller (a failed upstream write must not undo the saved local edit).""" + caller (a failed upstream write must not undo the saved local edit). + Does NOT commit — the caller's transaction owns the mirror write (H2: + a mid-block commit would drop the tenant GUC for statements after it).""" if before.source == "LOCAL" or not before.provider_task_id \ or not before.account_id: return @@ -836,7 +801,6 @@ async def _push_patch_upstream( text(f"UPDATE gtd_items SET {', '.join(upd)} WHERE id = :id"), uparams, ) - await db.commit() except Exception as exc: # best-effort back-sync — never fail the local edit _log.warning("tasks.patch.backsync_failed", item_id=str(before.id)[:12], error=str(exc)[:160]) @@ -862,8 +826,7 @@ async def merge_into_existing( if str(req.target_id) == str(item_id): raise HTTPException(status_code=400, detail="Cannot merge an item into itself") - db = await _get_db() - try: + async with _tenant_session() as db: source = await _fetch_item(db, item_id, uid) target = await _fetch_item(db, req.target_id, uid) if not source or not target: @@ -889,12 +852,12 @@ async def merge_into_existing( WHERE id = :id AND user_id = :uid"""), {"id": item_id, "uid": uid}, ) - await db.commit() - # Best-effort: push the enriched description upstream to the tool. + # The merge is committed above; the upstream push runs AFTER it, in its own + # transaction, and is best-effort — a provider hiccup never undoes it (H2: + # a mid-block commit would drop the tenant GUC). + async with _tenant_session() as db: await _push_patch_upstream(db, target, ItemPatch(notes=merged), uid) return _row_to_item(await _fetch_item(db, req.target_id, uid)) - finally: - await db.close() class FileUnderRequest(BaseModel): @@ -917,8 +880,7 @@ async def file_under_parent( if str(req.parent_id) == str(item_id): raise HTTPException(status_code=400, detail="Cannot file an item under itself") - db = await _get_db() - try: + async with _tenant_session() as db: await _fetch_item(db, item_id, uid) # 404 before any writes parent = await _fetch_item(db, req.parent_id, uid) if parent.parent_item_id: @@ -942,10 +904,7 @@ async def file_under_parent( "sync": "pending" if parent.source != "LOCAL" else "local", "id": item_id, "uid": uid}, ) - await db.commit() return _row_to_item(await _fetch_item(db, req.parent_id, uid)) - finally: - await db.close() # ── Archive ────────────────────────────────────────────────────────────────── @@ -965,8 +924,7 @@ async def archive_item( a SYNCED task the archive mirrors to the connected tool (best-effort) so the app and ClickUp stay consistent; restoring un-archives it upstream too.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: res = (await db.execute( text("""UPDATE gtd_items SET archived_at = CASE WHEN :on THEN now() ELSE NULL END, @@ -976,12 +934,13 @@ async def archive_item( )).fetchone() if not res: raise HTTPException(status_code=404, detail="Item not found") - await db.commit() row = await _fetch_item(db, item_id, uid) + # The local archive is committed above; the upstream mirror runs AFTER it, + # in its own transaction, and is best-effort (H2 restructure of the old + # commit-then-continue shape). + async with _tenant_session() as db: await _archive_upstream(db, [row], uid, req.archived) - return _row_to_item(row) - finally: - await db.close() + return _row_to_item(row) @router.post("/items/bulk", response_model=list[GtdItemModel]) @@ -992,8 +951,7 @@ async def bulk_dispose( if req.disposition not in DISPOSITIONS: raise HTTPException(status_code=400, detail=f"Bad disposition: {req.disposition}") - db = await _get_db() - try: + async with _tenant_session() as db: await db.execute( text("""UPDATE gtd_items SET disposition = :disp, @@ -1004,14 +962,11 @@ async def bulk_dispose( WHERE id::text = ANY(:ids) AND user_id = :uid"""), {"disp": req.disposition, "ids": req.ids, "uid": _uid(user)}, ) - await db.commit() rows = (await db.execute( text(ITEM_SELECT + " WHERE i.id::text = ANY(:ids) AND i.user_id = :uid"), {"ids": req.ids, "uid": _uid(user)}, )).fetchall() return [_row_to_item(r) for r in rows] - finally: - await db.close() class BulkArchiveRequest(BaseModel): @@ -1029,8 +984,7 @@ async def bulk_archive( and, for each SYNCED (ClickUp) task, mirrors the archive to the connected tool (best-effort, after the local commit) so the app and ClickUp agree.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: await db.execute( text("""UPDATE gtd_items SET archived_at = CASE WHEN :on THEN now() ELSE NULL END, @@ -1038,15 +992,15 @@ async def bulk_archive( WHERE id::text = ANY(:ids) AND user_id = :uid"""), {"ids": req.ids, "on": req.archived, "uid": uid}, ) - await db.commit() rows = (await db.execute( text(ITEM_SELECT + " WHERE i.id::text = ANY(:ids) AND i.user_id = :uid"), {"ids": req.ids, "uid": uid}, )).fetchall() + # The local archive is committed above; the upstream mirror runs AFTER it, + # in its own transaction, and is best-effort per row. + async with _tenant_session() as db: await _archive_upstream(db, list(rows), uid, req.archived) - return [_row_to_item(r) for r in rows] - finally: - await db.close() + return [_row_to_item(r) for r in rows] # ── Clarify → organize (the decision applier) ──────────────────────────────── @@ -1101,8 +1055,7 @@ async def organize_item( disposition = "WAITING" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: src_item = await _fetch_item(db, item_id, uid) # 404 before any writes source, sync_state = "LOCAL", "local" @@ -1201,30 +1154,30 @@ async def organize_item( await _create_subtasks( db, uid, item_id, req.subtasks, source, req.account_id, project_id, sync_state) - await db.commit() - - # Teach the task-manager's clarification memory from the COMMITTED - # decision (§9, Phase 4) — the real outcome, not a proposal. Fire-and- - # forget + best-effort, so it never slows or breaks organize. - from gateway.routes.tasks.task_memory import remember_decision_background - remember_decision_background( - title=getattr(src_item, "title", "") or "", - disposition=disposition, - next_action=(req.next_action or "").strip() or None, - owner=(req.assignee.name if req.assignee else None), - project=(req.outcome.strip() if req.kind == "project" - and req.outcome else None), - context=req.context) - - # Delegating to a connected tool auto-pushes so the teammate actually - # sees it (parity with POST /items/{id}/delegate). Extracted so the tail - # stays flat. + + # Teach the task-manager's clarification memory from the COMMITTED + # decision (§9, Phase 4) — the block above has committed. Fire-and- + # forget + best-effort, so it never slows or breaks organize. + from gateway.routes.tasks.task_memory import remember_decision_background + remember_decision_background( + title=getattr(src_item, "title", "") or "", + disposition=disposition, + next_action=(req.next_action or "").strip() or None, + owner=(req.assignee.name if req.assignee else None), + project=(req.outcome.strip() if req.kind == "project" + and req.outcome else None), + context=req.context) + + # Delegating to a connected tool auto-pushes so the teammate actually + # sees it (parity with POST /items/{id}/delegate). Runs AFTER the local + # decision committed, in its own transaction — a push hiccup can never + # undo the saved clarify (H2 restructure of the commit-then-continue + # shape). + async with _tenant_session() as db: pushed = await _maybe_push_delegated( db, item_id, uid, delegated=delegated, source=source, project_id=project_id) return pushed or _row_to_item(await _fetch_item(db, item_id, uid)) - finally: - await db.close() async def _maybe_push_delegated( @@ -1287,8 +1240,7 @@ async def list_subtasks( ): """The child subtasks of a task, in manual order (the detail panel list).""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text(ITEM_SELECT + " WHERE i.parent_item_id = :pid " "AND i.user_id = :uid " @@ -1296,8 +1248,6 @@ async def list_subtasks( {"pid": item_id, "uid": uid}, )).fetchall() return [_row_to_item(r) for r in rows] - finally: - await db.close() @router.post("/items/{item_id}/subtasks", response_model=list[GtdItemModel]) @@ -1313,8 +1263,7 @@ async def add_subtasks( titles = [t.strip() for t in req.titles if t.strip()] if not titles: raise HTTPException(status_code=400, detail="No subtasks to add") - db = await _get_db() - try: + async with _tenant_session() as db: parent = await _fetch_item(db, item_id, uid) # Append after the last existing child so order is preserved. last = (await db.execute( @@ -1338,7 +1287,6 @@ async def add_subtasks( "sync": "pending" if parent.source != "LOCAL" else "local", "rank": base_rank + offset * 1000.0}, ) - await db.commit() rows = (await db.execute( text(ITEM_SELECT + " WHERE i.parent_item_id = :pid " "AND i.user_id = :uid " @@ -1346,8 +1294,6 @@ async def add_subtasks( {"pid": item_id, "uid": uid}, )).fetchall() return [_row_to_item(r) for r in rows] - finally: - await db.close() # ── The approved push (staged → provider) ──────────────────────────────────── @@ -1355,8 +1301,11 @@ async def add_subtasks( async def _push_pending_item(db: Any, item_id: str, uid: str) -> Any: """Create a staged (sync_state='pending') task in its destination workspace and mark it synced. Shared by the manual Push and the delegate-promotion - path. Commits and returns the refreshed row. Raises 400 with a reason when - the item isn't in a pushable state (no account, no provider project).""" + path. Returns the refreshed row. Does NOT commit — every caller runs it as + its own `_tenant_session` block, whose clean exit commits (H2: a mid-block + commit would drop the tenant GUC for the refresh read). Raises 400 with a + reason when the item isn't in a pushable state (no account, no provider + project).""" row = await _fetch_item(db, item_id, uid) if row.sync_state != "pending" or not row.account_id: raise HTTPException(status_code=400, @@ -1416,7 +1365,6 @@ async def _push_pending_item(db: Any, item_id: str, uid: str) -> Any: if parent_tid: await _push_child_subtasks( db, item_id, uid, provider, project_ref, parent_tid) - await db.commit() return _row_to_item(await _fetch_item(db, item_id, uid)) @@ -1428,11 +1376,8 @@ async def push_item( """Create the staged task in its destination workspace — an explicit, user-initiated apply (C-04: no autonomous provider writes).""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: return await _push_pending_item(db, item_id, uid) - finally: - await db.close() class DelegateRequest(BaseModel): @@ -1460,8 +1405,7 @@ async def delegate_item( Already-synced tasks don't use this path: a plain assignee PATCH back-syncs to their existing ClickUp task.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: row = await _fetch_item(db, item_id, uid) if row.source == "SYNCED": raise HTTPException( @@ -1510,11 +1454,12 @@ async def delegate_item( VALUES (:iid, :who, now())"""), {"iid": item_id, "who": json.dumps(req.assignee.model_dump())}, ) - await db.commit() - # Now create it in ClickUp assigned to the teammate. + # The re-home + waiting record are committed above; now create it in + # ClickUp assigned to the teammate, in a second transaction — a push + # failure leaves the delegation saved as pending with the manual Push + # affordance, exactly as before (H2 restructure). + async with _tenant_session() as db: return await _push_pending_item(db, item_id, uid) - finally: - await db.close() async def _push_child_subtasks( @@ -1567,8 +1512,7 @@ async def item_detail( empty sections with an ``error`` note rather than failing the panel.""" uid = _uid(user) empty = {"comments": [], "attachments": [], "subtasks": []} - db = await _get_db() - try: + async with _tenant_session() as db: row = await _fetch_item(db, item_id, uid) if not row: raise HTTPException(status_code=404, detail="Item not found") @@ -1576,17 +1520,15 @@ async def item_detail( or not row.account_id: return empty account = await _assert_account_owner(db, str(row.account_id), uid) - creds = json.loads(_key_store().decrypt(account.credentials_encrypted)) - provider = build_provider( + creds = json.loads(_key_store().decrypt(account.credentials_encrypted)) + provider = build_provider( account.provider, creds, account.workspace_id, str(account.id)) - try: - return await provider.get_task_detail(str(row.provider_task_id)) - except Exception as exc: # provider hiccup — panel still renders - _log.warning("tasks.item_detail.failed", - item_id=item_id[:12], error=str(exc)[:160]) - return {**empty, "error": "Could not load live detail"} - finally: - await db.close() + try: + return await provider.get_task_detail(str(row.provider_task_id)) + except Exception as exc: # provider hiccup — panel still renders + _log.warning("tasks.item_detail.failed", + item_id=item_id[:12], error=str(exc)[:160]) + return {**empty, "error": "Could not load live detail"} @router.get("/items/{item_id}/stage-options") @@ -1600,23 +1542,20 @@ async def item_stage_options( real pipeline), not the whole-workspace union that the settings mapping needs. Empty for a LOCAL / not-yet-pushed task (the picker falls back).""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: row = await _fetch_item(db, item_id, uid) if row.source == "LOCAL" or not row.provider_task_id \ or not row.account_id: return {"statuses": []} account = await _assert_account_owner(db, str(row.account_id), uid) - creds = json.loads(_key_store().decrypt(account.credentials_encrypted)) - provider = build_provider( + creds = json.loads(_key_store().decrypt(account.credentials_encrypted)) + provider = build_provider( account.provider, creds, account.workspace_id, str(account.id)) - try: - statuses = await provider.list_statuses_for_task( - str(row.provider_task_id)) - return {"statuses": [str(s) for s in statuses if s]} - except Exception as exc: # provider hiccup — picker falls back - _log.warning("tasks.stage_options.failed", - item_id=item_id[:12], error=str(exc)[:160]) - return {"statuses": []} - finally: - await db.close() + try: + statuses = await provider.list_statuses_for_task( + str(row.provider_task_id)) + return {"statuses": [str(s) for s in statuses if s]} + except Exception as exc: # provider hiccup — picker falls back + _log.warning("tasks.stage_options.failed", + item_id=item_id[:12], error=str(exc)[:160]) + return {"statuses": []} diff --git a/apps/services/gateway/gateway/routes/tasks/people.py b/apps/services/gateway/gateway/routes/tasks/people.py index b65b63070..665c6ee83 100644 --- a/apps/services/gateway/gateway/routes/tasks/people.py +++ b/apps/services/gateway/gateway/routes/tasks/people.py @@ -30,7 +30,7 @@ from gateway.routes.tasks.attachments import _safe_name, _storage_dir from gateway.routes.tasks.core import ( PEOPLE_STATUSES, - _get_db, + _tenant_session, _uid, can_read_hr_fields, require_people_write, @@ -161,16 +161,13 @@ async def list_people( match += " OR EXISTS (SELECT 1 FROM unnest(skills) s WHERE s ILIKE :q)" clauses.append(match + ")") params["q"] = f"%{q.strip()}%" - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text("SELECT * FROM gtd_people WHERE " + " AND ".join(clauses) + " ORDER BY department, name"), params, )).fetchall() return [_row_to_person(r, include_hr=hr) for r in rows] - finally: - await db.close() async def fetch_people_for_clarify(db: Any) -> list[dict[str, Any]]: @@ -340,8 +337,7 @@ async def create_person( skills_source = {s: "manual" for s in skills} available = _available(body.capacity_hours_per_week, body.current_load_hours_per_week) pid = str(uuid4()) - db = await _get_db() - try: + async with _tenant_session() as db: # Duplicate NAMES are allowed, and refusing them was the bug. # Migration 148 dropped `UNIQUE(name)` on the argument that two real # people share a name and one of them was being locked out of the @@ -377,12 +373,14 @@ async def create_person( "capacity": body.capacity_hours_per_week, "load": body.current_load_hours_per_week, "available": available, "clickup_user_id": body.clickup_user_id, "updated_by": _uid(user)}) - await db.commit() + person = await _get_person_row(db, pid) + # The insert is committed above; the capability re-embed runs AFTER it in + # its own transaction (best-effort — an embedding hiccup never fails the + # write that already committed; H2 restructure). + async with _tenant_session() as db: await _reembed_capability(db, pid) - # include_hr: the route gate already proved this caller is an admin. - return _row_to_person(await _get_person_row(db, pid), include_hr=True) - finally: - await db.close() + # include_hr: the route gate already proved this caller is an admin. + return _row_to_person(person, include_hr=True) @router.patch("/people/{person_id}", response_model=OrgPersonModel, @@ -401,8 +399,7 @@ async def update_person( _validate_status(fields["status"]) if "email" in fields: fields["email"] = _clean_email(fields["email"]) - db = await _get_db() - try: + async with _tenant_session() as db: row = await _get_person_row(db, person_id) # `exclude_id` is why this is not the create-path check: re-saving a # person without touching their address must not report them as their @@ -449,12 +446,12 @@ async def update_person( await db.execute( text(f"UPDATE gtd_people SET {', '.join(set_parts)} WHERE id = :id"), params) - await db.commit() + person = await _get_person_row(db, person_id) + # The edit is committed above; the capability re-embed runs AFTER it in its + # own transaction (best-effort; H2 restructure). + async with _tenant_session() as db: await _reembed_capability(db, person_id) - return _row_to_person( - await _get_person_row(db, person_id), include_hr=True) - finally: - await db.close() + return _row_to_person(person, include_hr=True) class ResumeIngestResult(BaseModel): @@ -487,8 +484,7 @@ async def ingest_resume( if len(content) > _RESUME_MAX_BYTES: raise HTTPException(status_code=413, detail="Résumé too large (max 15 MB).") - db = await _get_db() - try: + async with _tenant_session() as db: row = await _get_person_row(db, person_id) # Vocabulary = every skill the org already knows (broadens keyword hits). vocab_rows = (await db.execute(text( @@ -540,15 +536,15 @@ async def ingest_resume( "summary": parsed.get("experience_summary"), "years": parsed.get("years_experience"), "domain": parsed.get("domain"), "by": _uid(user), "id": person_id}) - await db.commit() - # New skills / résumé depth change the capability text → re-embed. + person = await _get_person_row(db, person_id) + # The résumé + merge are committed above. New skills / résumé depth change + # the capability text → re-embed, AFTER the commit, in its own transaction + # (best-effort; H2 restructure). + async with _tenant_session() as db: await _reembed_capability(db, person_id) - return ResumeIngestResult( - resume_id=rid, added_skills=added, extracted=extracted, - person=_row_to_person( - await _get_person_row(db, person_id), include_hr=True)) - finally: - await db.close() + return ResumeIngestResult( + resume_id=rid, added_skills=added, extracted=extracted, + person=_row_to_person(person, include_hr=True)) async def _reembed_capability(db: Any, person_id: str) -> None: diff --git a/apps/services/gateway/gateway/routes/tasks/planning.py b/apps/services/gateway/gateway/routes/tasks/planning.py index 1e4a27483..75518b5ae 100644 --- a/apps/services/gateway/gateway/routes/tasks/planning.py +++ b/apps/services/gateway/gateway/routes/tasks/planning.py @@ -31,9 +31,9 @@ from gateway.routes.tasks.core import ( PROJECT_SELECT, _assert_account_owner, - _get_db, _key_store, _log, + _tenant_session, _uid, router, ) @@ -250,8 +250,7 @@ async def plan_project( if not name: raise HTTPException(status_code=400, detail="A project name is required.") uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: from gateway.routes.tasks.settings import gtd_models model = (await gtd_models(db, uid))["clarify"] people, projects_brief = await _plan_context( @@ -264,8 +263,6 @@ async def plan_project( detail="Couldn't draft a plan right now — try again, or add " "more detail to the brief.") return plan - finally: - await db.close() def _due_from_offset(offset: int | None) -> datetime | None: @@ -300,13 +297,10 @@ async def apply_plan( raise HTTPException(status_code=400, detail="The plan has no tasks.") if req.target not in ("local", "clickup"): raise HTTPException(status_code=400, detail="target must be local|clickup") - db = await _get_db() - try: + async with _tenant_session() as db: if req.target == "clickup": return await _apply_clickup(db, uid, req, tasks) return await _apply_local(db, uid, req.plan, tasks) - finally: - await db.close() async def _apply_local( @@ -314,7 +308,8 @@ async def _apply_local( ) -> ApplyResult: """Create a LOCAL project + a NEXT gtd_item per task + parent_item_id subtasks. Phase names ride on the task title prefix (kept simple — no phase - table). All in one transaction.""" + table). All in one transaction — the caller's `_tenant_session` block + commits on clean exit (H2), so this helper issues no commit of its own.""" project_id = str(uuid4()) await db.execute(text( """INSERT INTO gtd_projects @@ -356,7 +351,6 @@ async def _apply_local( "proj": project_id, "rank": srank}) subtasks_created += 1 srank += 1000.0 - await db.commit() return ApplyResult(project_id=project_id, tasks_created=tasks_created, subtasks_created=subtasks_created, target="local") @@ -435,7 +429,6 @@ async def _apply_clickup( list_ref, {"title": sub, "parent": ptid}) if sres.get("provider_task_id"): subtasks_created += 1 - await db.commit() return ApplyResult( project_id=project_id, provider_ref=list_ref, tasks_created=tasks_created, subtasks_created=subtasks_created, diff --git a/apps/services/gateway/gateway/routes/tasks/scheduler.py b/apps/services/gateway/gateway/routes/tasks/scheduler.py index cd114f45a..fe6401bcb 100644 --- a/apps/services/gateway/gateway/routes/tasks/scheduler.py +++ b/apps/services/gateway/gateway/routes/tasks/scheduler.py @@ -39,6 +39,17 @@ from typing import Any from acb_common import get_logger + +# ⚠️ H4, DELIBERATELY NOT H2 (`saas_multitenancy_handover.md`): this module is +# a BACKGROUND CONSUMER, not a request handler — every loop below runs from +# `asyncio.create_task` long after any request (and its tenant binding) is +# gone. The runbook's rule for that category is "do not let a job inherit an +# ambient tenant", so converting these sites to the ambient `tenant_session()` +# would be exactly the inheritance it forbids (and would raise `TenantUnbound` +# anyway). They stay on the unbound `get_db()` until H4 threads an EXPLICIT +# tenant — from the `task_accounts` row being synced — into +# `tenant_session(org_id)`. Sequencing is safe: RLS phase 4 (which would starve +# these reads) is gated on H2+H4 both being complete. from gateway.routes.tasks.core import _get_db from sqlalchemy import text @@ -65,7 +76,7 @@ async def _run_one_cycle(account_id: str, *, refresh_schema: bool) -> None: """Pull tasks for one account (and, on schema cycles, re-fetch its full provider schema). Each step is isolated so one failing doesn't skip the other; the pull itself records ``sync_status``/``sync_error`` on the row.""" - from gateway.routes.tasks.accounts import _refresh_schema + from gateway.routes.tasks.accounts import _reconcile_people, _refresh_schema from gateway.routes.tasks.sync import _sync_account db = await _get_db() @@ -103,13 +114,27 @@ async def _run_one_cycle(account_id: str, *, refresh_schema: bool) -> None: # clarify pickers (and the agent) rely on stay current. if refresh_schema: try: + # H2 note: `_refresh_schema` no longer commits or reconciles + # itself (request handlers run it inside a tenant transaction), + # so this unbound caller commits explicitly and sequences the + # roster reconcile — same effective behaviour as before. await _refresh_schema(db, account_id, account.user_id) + await db.commit() _log.info("tasks.scheduler.schema_refreshed", account_id=account_id[:12]) except Exception as exc: await db.rollback() _log.warning("tasks.scheduler.schema_refresh_failed", account_id=account_id[:12], error=str(exc)[:160]) + else: + try: + await _reconcile_people(db, account.user_id) + await db.commit() + except Exception as exc: # best-effort, like the helper itself + await db.rollback() + _log.warning("tasks.scheduler.reconcile_failed", + account_id=account_id[:12], + error=str(exc)[:160]) finally: await db.close() diff --git a/apps/services/gateway/gateway/routes/tasks/settings.py b/apps/services/gateway/gateway/routes/tasks/settings.py index b5c543eec..bee93e777 100644 --- a/apps/services/gateway/gateway/routes/tasks/settings.py +++ b/apps/services/gateway/gateway/routes/tasks/settings.py @@ -25,7 +25,13 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends -from gateway.routes.tasks.core import _get_db, _log, _parse_jsonb, _uid, router +from gateway.routes.tasks.core import ( + _log, + _parse_jsonb, + _tenant_session, + _uid, + router, +) from pydantic import BaseModel from sqlalchemy import text @@ -419,11 +425,8 @@ def _stages(val: Any) -> list[str]: @router.get("/settings", response_model=GtdSettingsModel) async def get_gtd_settings(user: UserContext = Depends(get_current_user)): - db = await _get_db() - try: + async with _tenant_session() as db: return await _load(db, _uid(user)) - finally: - await db.close() class StatusCatalogEntry(BaseModel): @@ -445,8 +448,7 @@ async def status_catalog(user: UserContext = Depends(get_current_user)): its auto-guessed stage (``mapped=False``) so the settings table is never blank — the user just confirms/adjusts. Powers the status-mapping UI.""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: settings = await _load(db, uid) rows = (await db.execute(text( "SELECT schema_cache FROM task_accounts WHERE user_id = :uid"), @@ -489,8 +491,6 @@ async def status_catalog(user: UserContext = Depends(get_current_user)): unmapped += 1 return StatusCatalogResponse( stages=stages, entries=entries, unmapped=unmapped) - finally: - await db.close() @router.put("/settings", response_model=GtdSettingsModel) @@ -501,42 +501,41 @@ async def put_gtd_settings( """Partial update — only the provided fields change (upsert).""" uid = _uid(user) fields = {k: v for k, v in patch.model_dump().items() if v is not None} - db = await _get_db() - try: - import json - if "workflow_stages" in fields: - # Sanitize (trim, drop empties, cap) and JSON-encode for the JSONB - # column. Guarantee a non-empty list with a "done" stage. - stages = [str(s).strip() for s in fields["workflow_stages"] - if str(s).strip()][:24] - if not stages: - stages = list(DEFAULT_WORKFLOW_STAGES) - fields["workflow_stages"] = json.dumps(stages) - _jsonb_cols = {"workflow_stages", "status_stage_map", "energy_windows", - "day_templates"} - if "day_templates" in fields: - fields["day_templates"] = json.dumps( - _day_templates(fields["day_templates"])) - if "status_stage_map" in fields: - # Normalize keys (lower/trim) + drop empties; JSON-encode for JSONB. - raw = fields["status_stage_map"] or {} - clean = {str(k).strip().lower(): str(v).strip() - for k, v in raw.items() - if str(k).strip() and str(v).strip()} - fields["status_stage_map"] = json.dumps(clean) - if "energy_windows" in fields: - # Validate + JSON-encode for the JSONB column. - fields["energy_windows"] = json.dumps( - _energy_windows(fields["energy_windows"])) - if fields: - # JSONB columns need an explicit ::jsonb cast on the bind param. - # The space before the cast matters — SQLAlchemy's bind-param - # scanner mis-parses a bind name immediately followed by a - # Postgres "::" cast (drops the last character of the name, - # leaves literal cast text the driver can't parse); see - # tests/unit/test_sql_bindparam_jsonb_cast.py. - def _ph(k: str) -> str: - return f":{k} ::jsonb" if k in _jsonb_cols else f":{k}" + import json + if "workflow_stages" in fields: + # Sanitize (trim, drop empties, cap) and JSON-encode for the JSONB + # column. Guarantee a non-empty list with a "done" stage. + stages = [str(s).strip() for s in fields["workflow_stages"] + if str(s).strip()][:24] + if not stages: + stages = list(DEFAULT_WORKFLOW_STAGES) + fields["workflow_stages"] = json.dumps(stages) + _jsonb_cols = {"workflow_stages", "status_stage_map", "energy_windows", + "day_templates"} + if "day_templates" in fields: + fields["day_templates"] = json.dumps( + _day_templates(fields["day_templates"])) + if "status_stage_map" in fields: + # Normalize keys (lower/trim) + drop empties; JSON-encode for JSONB. + raw = fields["status_stage_map"] or {} + clean = {str(k).strip().lower(): str(v).strip() + for k, v in raw.items() + if str(k).strip() and str(v).strip()} + fields["status_stage_map"] = json.dumps(clean) + if "energy_windows" in fields: + # Validate + JSON-encode for the JSONB column. + fields["energy_windows"] = json.dumps( + _energy_windows(fields["energy_windows"])) + if fields: + # JSONB columns need an explicit ::jsonb cast on the bind param. + # The space before the cast matters — SQLAlchemy's bind-param + # scanner mis-parses a bind name immediately followed by a + # Postgres "::" cast (drops the last character of the name, + # leaves literal cast text the driver can't parse); see + # tests/unit/test_sql_bindparam_jsonb_cast.py. + def _ph(k: str) -> str: + return f":{k} ::jsonb" if k in _jsonb_cols else f":{k}" + async with _tenant_session() as db: cols = ", ".join(fields) vals = ", ".join(_ph(k) for k in fields) sets = ", ".join(f"{k} = EXCLUDED.{k}" for k in fields) @@ -546,17 +545,18 @@ def _ph(k: str) -> str: ON CONFLICT (user_id) DO UPDATE SET {sets}, updated_at = now()"""), {"uid": uid, **fields}) - await db.commit() - # A background_sync toggle must (re)start or stop this user's - # workspace loops at runtime — otherwise the change only takes - # effect on the next gateway restart. - if "background_sync" in fields: + # A background_sync toggle must (re)start or stop this user's + # workspace loops at runtime — otherwise the change only takes + # effect on the next gateway restart. Runs AFTER the block above + # committed: the scheduler re-reads gtd_settings on its own session + # and must see the new value (H2 restructure). + if "background_sync" in fields: + async with _tenant_session() as db: await _apply_background_sync_toggle( db, uid, bool(fields["background_sync"])) + async with _tenant_session() as db: return await _load(db, uid) - finally: - await db.close() async def _apply_background_sync_toggle( diff --git a/apps/services/gateway/gateway/routes/tasks/sync.py b/apps/services/gateway/gateway/routes/tasks/sync.py index ccd2b9136..632a738ac 100644 --- a/apps/services/gateway/gateway/routes/tasks/sync.py +++ b/apps/services/gateway/gateway/routes/tasks/sync.py @@ -45,10 +45,10 @@ from fastapi import Depends, HTTPException from gateway.routes.tasks.core import ( _assert_account_owner, - _get_db, _key_store, _log, _parse_jsonb, + _tenant_session, _uid, router, ) @@ -176,7 +176,13 @@ def _dt(ms: Any) -> datetime | None: async def _sync_account(db: Any, account: Any, *, full: bool) -> AccountSyncResult: - """Pull one account's tasks and upsert the mirror. Commits on success.""" + """Pull one account's tasks and upsert the mirror. Commits on success. + + H2 note: the trailing commit is this helper's LAST database action, so a + request handler may run it as the sole occupant of a `_tenant_session` + block — every statement above the commit runs under the tenant GUC, and + the wrapper's own commit on clean exit is then an empty no-op. Do not add + statements after the commit, and do not call this mid-block.""" result = AccountSyncResult(account_id=str(account.id), label=account.label or "") creds = json.loads(_key_store().decrypt(account.credentials_encrypted)) @@ -394,8 +400,7 @@ async def sync_status(user: UserContext = Depends(get_current_user)): background loop hasn't refreshed it in time, e.g. loop not yet running). Plus the live scheduler state (which account loops are running).""" uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text("""SELECT id, label, provider, sync_enabled, sync_interval_secs, sync_status, sync_error, last_synced_at, @@ -404,8 +409,6 @@ async def sync_status(user: UserContext = Depends(get_current_user)): ORDER BY created_at"""), {"uid": uid}, )).fetchall() - finally: - await db.close() try: from gateway.routes.tasks.scheduler import get_scheduler_status @@ -453,8 +456,7 @@ async def sync_tasks( other accounts' syncs. """ uid = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: if req.account_id: rows = [await _assert_account_owner(db, req.account_id, uid)] else: @@ -468,32 +470,34 @@ async def sync_tasks( raise HTTPException(status_code=400, detail="No sync-enabled accounts to sync") - results: list[AccountSyncResult] = [] - for account in rows: + # One transaction per step, per account (H2 restructure of the old + # commit-as-you-go shape): the 'syncing' marker must be visible before the + # potentially slow provider pull, and an account's failure must roll back + # only its own pull while still recording the error. + results: list[AccountSyncResult] = [] + for account in rows: + async with _tenant_session() as db: await db.execute( text("""UPDATE task_accounts SET sync_status = 'syncing', updated_at = now() WHERE id = :id"""), {"id": str(account.id)}, ) - await db.commit() - try: + try: + async with _tenant_session() as db: results.append(await _sync_account(db, account, full=req.full)) - except Exception as exc: - await db.rollback() - msg = str(getattr(exc, "detail", None) or exc)[:500] - _log.warning("tasks.sync.account_failed", - account_id=str(account.id)[:12], error=msg) + except Exception as exc: + msg = str(getattr(exc, "detail", None) or exc)[:500] + _log.warning("tasks.sync.account_failed", + account_id=str(account.id)[:12], error=msg) + async with _tenant_session() as db: await db.execute( text("""UPDATE task_accounts SET sync_status = 'error', sync_error = :e, updated_at = now() WHERE id = :id"""), {"id": str(account.id), "e": msg}, ) - await db.commit() - results.append(AccountSyncResult( - account_id=str(account.id), - label=account.label or "", error=msg, - )) - return results - finally: - await db.close() + results.append(AccountSyncResult( + account_id=str(account.id), + label=account.label or "", error=msg, + )) + return results diff --git a/tests/unit/test_db_engine_seam.py b/tests/unit/test_db_engine_seam.py index 77e51ba35..229c553ed 100644 --- a/tests/unit/test_db_engine_seam.py +++ b/tests/unit/test_db_engine_seam.py @@ -311,7 +311,11 @@ def test_acb_auth_shares_the_pool() -> None: #: The unconverted remainder OUTSIDE routes/projects at the time the Projects #: slice landed (2026-08-10). Lower it as packages convert; never raise it. -H2_BASELINE_ELSEWHERE = 494 +#: 494 → 421 (2026-08-10): routes/tasks converted — 73 request-handler sites +#: now on `_tenant_session`; the 6 that remain there are background consumers +#: (broker_handlers, scheduler, calendar's rollover sweep), each carrying an +#: H4 comment naming why ambient tenant inheritance is forbidden for them. +H2_BASELINE_ELSEWHERE = 421 def _get_db_sites() -> dict[str, int]: diff --git a/tests/unit/test_people_write.py b/tests/unit/test_people_write.py index 45b26beb9..b4d7210b3 100644 --- a/tests/unit/test_people_write.py +++ b/tests/unit/test_people_write.py @@ -28,6 +28,7 @@ import asyncio import re +from contextlib import asynccontextmanager from pathlib import Path from types import SimpleNamespace from typing import Any @@ -123,10 +124,16 @@ def db() -> FakeDB: def bind(monkeypatch, database: FakeDB) -> None: - async def _get_db(): - return database - - monkeypatch.setattr(tasks_people, "_get_db", _get_db, raising=False) + # H2: routes/tasks/people acquires sessions through the tenant-bound + # `_tenant_session` context manager (imported from core by name), so that + # is the seam this fake swaps in — commit-on-clean-exit like the real one. + @asynccontextmanager + async def _tenant_session(organization_id: str | None = None): + yield database + await database.commit() + + monkeypatch.setattr( + tasks_people, "_tenant_session", _tenant_session, raising=False) def _user(email: str, *grants: str) -> UserContext: diff --git a/tests/unit/test_tasks_gtd.py b/tests/unit/test_tasks_gtd.py index 5c7ffdf30..19b06135a 100644 --- a/tests/unit/test_tasks_gtd.py +++ b/tests/unit/test_tasks_gtd.py @@ -1160,9 +1160,14 @@ def test_organize_synced_delegate_auto_pushes_to_the_tool(): organize_src = inspect.getsource(tasks_items.organize_item) push_src = inspect.getsource(tasks_items._maybe_push_delegated) # organize commits the local clarify first, THEN runs the auto-push helper. + # H2 shape: the local write happens in the FIRST `_tenant_session` block + # (committed on its clean exit) and the push runs in a SECOND block — so + # the helper call must come after a later `async with _tenant_session()`. assert "_maybe_push_delegated(" in organize_src - assert organize_src.index("await db.commit()") < organize_src.index( - "_maybe_push_delegated(") + first_block = organize_src.index("async with _tenant_session()") + second_block = organize_src.index( + "async with _tenant_session()", first_block + 1) + assert second_block < organize_src.index("_maybe_push_delegated(") # The helper only pushes a SYNCED delegation with a chosen project, and a # push failure is tolerated (deferred), not fatal to the clarify. assert 'delegated and source == "SYNCED" and project_id' in push_src diff --git a/tests/unit/test_tasks_people_scoping.py b/tests/unit/test_tasks_people_scoping.py index 10116b2ea..4b91b1443 100644 --- a/tests/unit/test_tasks_people_scoping.py +++ b/tests/unit/test_tasks_people_scoping.py @@ -8,10 +8,10 @@ Convention: the ``test_admin_groups.py`` / ``test_app_grants.py`` shape — no TestClient, no live Postgres. The read routes are called as plain async -functions with the DB seam (``people._get_db``) monkeypatched on the SUT -submodule; the write gates are exercised by executing the route's own -permission dependency, so a route that loses its gate fails here rather than -in production. +functions with the DB seam (``people._tenant_session``, the H2 tenant-bound +context manager) monkeypatched on the SUT submodule; the write gates are +exercised by executing the route's own permission dependency, so a route that +loses its gate fails here rather than in production. What is locked: @@ -30,6 +30,7 @@ from __future__ import annotations import inspect +from contextlib import asynccontextmanager from types import SimpleNamespace from typing import Any @@ -137,12 +138,16 @@ class _FakeDB: def __init__(self, rows: list[Any]): self.rows = rows self.statements: list[str] = [] + self.committed = 0 self.closed = False async def execute(self, statement: Any, params: Any = None) -> _Result: self.statements.append(str(statement)) return _Result(self.rows) + async def commit(self) -> None: + self.committed += 1 + async def close(self) -> None: self.closed = True @@ -151,10 +156,14 @@ async def close(self) -> None: def fake_db(monkeypatch: pytest.MonkeyPatch) -> _FakeDB: db = _FakeDB([_person_row()]) - async def _get_db() -> _FakeDB: - return db + # H2: the seam is the tenant-bound context manager, patched per module — + # commit-on-clean-exit mirrors the real wrapper (see _projects_fakes). + @asynccontextmanager + async def _tenant_session(organization_id: str | None = None): + yield db + await db.commit() - monkeypatch.setattr(people, "_get_db", _get_db) + monkeypatch.setattr(people, "_tenant_session", _tenant_session) return db From 59f15fe9c4e6ef49d3fc8928c563065d6bae44db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 07:35:20 +0000 Subject: [PATCH 05/10] =?UTF-8?q?feat(tenancy):=20H2=20=E2=80=94=20routes/?= =?UTF-8?q?workflows=20converted=20to=20tenant=5Fsession=20(slice)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 26 of 43 get_db() sites converted to `async with _tenant_session() as db:` (crud 7, publish 4, modules 6, runs 5, copilot 2, catalog 1, search 1) — every member-reached handler in the package. The 17 that remain are the unattended half, each H4-annotated in place: service.py (13 — run lifecycle, engine module loads, F13 programmatic entries; start_run/resume_run are dual-use and must not inherit an ambient tenant), scheduler.py (2 — the cron loop), triggers.py (1 — event sink), hooks.py (1 — hook_token service identity; tenant derives from the workflow row's organization). core.py gains the `_tenant_session` alias of the shared seam (gateway.db.tenant_session); submodules import it by name. update_workflow's mid-block commit is gone — its re-reads now see the same transaction's own writes. Hermetic fakes re-pointed to @asynccontextmanager patches with commit-on-clean-exit. H2_BASELINE_ELSEWHERE banked 494 → 468. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../gateway/routes/workflows/catalog.py | 7 +-- .../gateway/routes/workflows/copilot.py | 13 ++---- .../gateway/gateway/routes/workflows/core.py | 30 ++++++++++--- .../gateway/gateway/routes/workflows/crud.py | 44 +++++-------------- .../gateway/gateway/routes/workflows/hooks.py | 6 +++ .../gateway/routes/workflows/modules.py | 35 +++------------ .../gateway/routes/workflows/publish.py | 26 +++-------- .../gateway/gateway/routes/workflows/runs.py | 39 +++++++--------- .../gateway/routes/workflows/scheduler.py | 6 +++ .../gateway/routes/workflows/search.py | 16 ++++--- .../gateway/routes/workflows/service.py | 15 +++++++ .../gateway/routes/workflows/triggers.py | 6 +++ tests/unit/test_db_engine_seam.py | 5 ++- .../unit/test_workflows_automation_health.py | 18 ++++++++ tests/unit/test_workflows_copilot.py | 10 +++-- tests/unit/test_workflows_crud_lifecycle.py | 15 +++++-- .../test_workflows_trigger_reliability.py | 9 ++-- 17 files changed, 159 insertions(+), 141 deletions(-) diff --git a/apps/services/gateway/gateway/routes/workflows/catalog.py b/apps/services/gateway/gateway/routes/workflows/catalog.py index e8882fef2..79003c895 100644 --- a/apps/services/gateway/gateway/routes/workflows/catalog.py +++ b/apps/services/gateway/gateway/routes/workflows/catalog.py @@ -12,7 +12,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends -from gateway.routes.workflows.core import _get_db, _log, iso, router +from gateway.routes.workflows.core import _log, _tenant_session, iso, router from gateway.routes.workflows.tools import list_tools from sqlalchemy import text @@ -249,8 +249,7 @@ def _tool_entry(spec: Any) -> dict[str, Any]: async def get_catalog( user: UserContext = Depends(get_current_user), ) -> dict[str, Any]: - db = await _get_db() - try: + async with _tenant_session() as db: modules = ( await db.execute( text( @@ -260,8 +259,6 @@ async def get_catalog( ), ) ).fetchall() - finally: - await db.close() from gateway.routes.workflows.core import parse_jsonb return { diff --git a/apps/services/gateway/gateway/routes/workflows/copilot.py b/apps/services/gateway/gateway/routes/workflows/copilot.py index 939f1af44..8dab69abe 100644 --- a/apps/services/gateway/gateway/routes/workflows/copilot.py +++ b/apps/services/gateway/gateway/routes/workflows/copilot.py @@ -20,8 +20,8 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException from gateway.routes.workflows.core import ( - _get_db, _log, + _tenant_session, _uid, load_workflow_or_404, parse_jsonb, @@ -268,13 +268,10 @@ async def workflow_copilot( body: CopilotRequest, user: UserContext = Depends(get_current_user), ) -> dict[str, Any]: - db = await _get_db() - try: + async with _tenant_session() as db: row = await load_workflow_or_404(db, workflow_id) graph = parse_jsonb(row.graph, {"nodes": [], "edges": []}) context = await _capability_context(body.message, db) - finally: - await db.close() messages: list[dict[str, str]] = [ {"role": "system", "content": COPILOT_SYSTEM_PROMPT}, @@ -375,8 +372,7 @@ async def _apply_round( graph = None if not problems and (new_modules or graph is not None): - db = await _get_db() - try: + async with _tenant_session() as db: for module in new_modules: module_id = await _save_module( db, @@ -392,9 +388,6 @@ async def _apply_round( text("SELECT id, name FROM workflow_modules WHERE status = 'ready'"), ) ).fetchall() - await db.commit() - finally: - await db.close() name_to_id = {r.name: str(r.id) for r in existing} | created if graph is not None: diff --git a/apps/services/gateway/gateway/routes/workflows/core.py b/apps/services/gateway/gateway/routes/workflows/core.py index 5e54ce2c8..e5b6e7e76 100644 --- a/apps/services/gateway/gateway/routes/workflows/core.py +++ b/apps/services/gateway/gateway/routes/workflows/core.py @@ -24,6 +24,15 @@ # The shared gateway engine (BO-10) — see the DB section below. from gateway.db import get_db as _get_db # noqa: F401 from gateway.db import get_session_factory as _get_session_factory # noqa: F401 + +# The tenant-bound half of the seam (MT-1c / H2). `_tenant_session` IS +# `acb_common.db.tenant_session`, aliased per-package exactly like +# `routes/projects/core.py`: member-facing submodules import it from here BY +# NAME, and the hermetic tests patch it on the module under test. The tenant +# comes from the request context (bound in `_with_resolved_access`); a call +# outside a bound request raises `TenantUnbound` — fail closed, never "the +# usual org". +from gateway.db import tenant_session as _tenant_session # noqa: F401 from sqlalchemy import text _log = get_logger("gateway.workflows") @@ -47,11 +56,22 @@ # ── DB (the one shared gateway engine — gateway/db.py, BO-10) ──────────────── # # This package used to build its own engine here with its own 5+10 pool. It now -# has none: `_get_db` / `_get_session_factory` at the top of this module are -# re-exports of the shared seam. The private names are kept so that every -# `from .core import _get_db` in this package — and every test that -# monkeypatches `_get_db` on the sibling module it is imported into — keeps -# working unchanged. +# has none: `_get_db` / `_get_session_factory` / `_tenant_session` at the top +# of this module are re-exports of the shared seam. +# +# Two names, one pool, and the split is the H2 conversion boundary: +# +# * `_tenant_session` — every MEMBER-REACHED handler (crud, publish, catalog, +# search, modules, copilot, runs) acquires its session as +# `async with _tenant_session() as db:`; the wrapper owns the transaction +# (commit on clean exit, rollback on raise) and binds `app.tenant_id` from +# the request context. +# * `_get_db` — still here for the UNATTENDED half of this package (service, +# scheduler, triggers, hooks): engine runs, cron ticks, event dispatch and +# token-authenticated webhooks have no member session to inherit a tenant +# from, and H4 owns threading an explicit one through (see the H4 notes in +# each of those modules). The H2 ratchet in `test_db_engine_seam.py` counts +# these sites; they only go down. # ── Small helpers ──────────────────────────────────────────────────────────── diff --git a/apps/services/gateway/gateway/routes/workflows/crud.py b/apps/services/gateway/gateway/routes/workflows/crud.py index ee030ddf5..3a1bb328b 100644 --- a/apps/services/gateway/gateway/routes/workflows/crud.py +++ b/apps/services/gateway/gateway/routes/workflows/crud.py @@ -15,7 +15,7 @@ from gateway.routes.workflows.core import ( HOOK_PATH, MAX_GRAPH_BYTES, - _get_db, + _tenant_session, _uid, hook_url, iso, @@ -74,8 +74,7 @@ def _row_summary(row: Any) -> dict[str, Any]: async def list_workflows( user: UserContext = Depends(get_current_user), ) -> list[dict[str, Any]]: - db = await _get_db() - try: + async with _tenant_session() as db: rows = ( await db.execute( text( @@ -92,8 +91,6 @@ async def list_workflows( ) ) ).fetchall() - finally: - await db.close() return [ { **_row_summary(r), @@ -110,8 +107,7 @@ async def create_workflow( body: WorkflowCreate, user: UserContext = Depends(get_current_user), ) -> dict[str, Any]: - db = await _get_db() - try: + async with _tenant_session() as db: row = ( await db.execute( text( @@ -127,9 +123,6 @@ async def create_workflow( }, ) ).fetchone() - await db.commit() - finally: - await db.close() return { **_row_summary(row), "graph": parse_jsonb(row.graph, {}), @@ -150,8 +143,7 @@ async def duplicate_workflow( workflows, and the copy starts as a draft with no versions). The webhook hook token is always regenerated: it is a credential, never cloned. """ - db = await _get_db() - try: + async with _tenant_session() as db: row = await load_workflow_or_404(db, workflow_id) triggers = await load_triggers(db, workflow_id) name = f"{row.name} (copy)"[:120] @@ -187,9 +179,6 @@ async def duplicate_workflow( "enabled": t["enabled"], }, ) - await db.commit() - finally: - await db.close() return { **_row_summary(new), "graph": parse_jsonb(new.graph, {}), @@ -207,8 +196,7 @@ async def get_workflow( workflow_id: str, user: UserContext = Depends(get_current_user), ) -> dict[str, Any]: - db = await _get_db() - try: + async with _tenant_session() as db: row = await load_workflow_or_404(db, workflow_id) triggers = await load_triggers(db, workflow_id) versions = ( @@ -221,8 +209,6 @@ async def get_workflow( {"id": workflow_id}, ) ).fetchall() - finally: - await db.close() return { **_row_summary(row), "graph": parse_jsonb(row.graph, {"nodes": [], "edges": []}), @@ -257,8 +243,7 @@ async def update_workflow( raise HTTPException(status_code=413, detail="Graph too large") if body.triggers is not None: _validate_trigger_specs(body.triggers) - db = await _get_db() - try: + async with _tenant_session() as db: row = await load_workflow_or_404(db, workflow_id) sets, params = [], {"id": workflow_id} if body.name is not None: @@ -321,11 +306,11 @@ async def update_workflow( ), }, ) - await db.commit() + # No mid-block commit: the wrapper owns the one transaction (a commit + # here would end it and drop the tenant GUC for the reads below). The + # re-reads see this transaction's own writes, same rows as before. row = await load_workflow_or_404(db, workflow_id) triggers = await load_triggers(db, workflow_id) - finally: - await db.close() return { **_row_summary(row), "graph": parse_jsonb(row.graph, {"nodes": [], "edges": []}), @@ -339,16 +324,12 @@ async def delete_workflow( workflow_id: str, user: UserContext = Depends(get_current_user), ) -> None: - db = await _get_db() - try: + async with _tenant_session() as db: await load_workflow_or_404(db, workflow_id) await db.execute( text("DELETE FROM workflows WHERE id = :id"), {"id": workflow_id}, ) - await db.commit() - finally: - await db.close() @router.post("/{workflow_id}/validate") @@ -357,16 +338,13 @@ async def validate_workflow( user: UserContext = Depends(get_current_user), ) -> dict[str, Any]: """Design-time validation for editor badges (spec F4, §3.2 rung 3).""" - db = await _get_db() - try: + async with _tenant_session() as db: row = await load_workflow_or_404(db, workflow_id) ready_modules = ( await db.execute( text("SELECT id FROM workflow_modules WHERE status = 'ready'"), ) ).fetchall() - finally: - await db.close() from gateway.routes.workflows.catalog import known_agent_names from gateway.routes.workflows.tools import ( destructive_action_names, diff --git a/apps/services/gateway/gateway/routes/workflows/hooks.py b/apps/services/gateway/gateway/routes/workflows/hooks.py index 02b3ada25..f2aa92904 100644 --- a/apps/services/gateway/gateway/routes/workflows/hooks.py +++ b/apps/services/gateway/gateway/routes/workflows/hooks.py @@ -19,6 +19,12 @@ from typing import Any from fastapi import HTTPException, Request + +# H4/H6: service-identity route, not a member request — the caller +# authenticates by the per-workflow `hook_token`, so no user resolves and no +# tenant is bound in context; the tenant derives from the workflow row the +# token names (its `organization_id`), which H4 threads through explicitly +# (`tenant_session(org_id)`). Until then this stays on the unbound `get_db()`. from gateway.routes.workflows.core import ( HOOK_RATE_LIMIT_PER_MINUTE, MAX_TRIGGER_PAYLOAD_BYTES, diff --git a/apps/services/gateway/gateway/routes/workflows/modules.py b/apps/services/gateway/gateway/routes/workflows/modules.py index bf2da62e9..afe8d6b6c 100644 --- a/apps/services/gateway/gateway/routes/workflows/modules.py +++ b/apps/services/gateway/gateway/routes/workflows/modules.py @@ -15,8 +15,8 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException from gateway.routes.workflows.core import ( - _get_db, _log, + _tenant_session, _uid, iso, parse_jsonb, @@ -119,15 +119,12 @@ def _module_row(row: Any, *, with_code: bool = True) -> dict[str, Any]: async def list_modules( user: UserContext = Depends(get_current_user), ) -> list[dict[str, Any]]: - db = await _get_db() - try: + async with _tenant_session() as db: rows = ( await db.execute( text("SELECT * FROM workflow_modules ORDER BY updated_at DESC"), ) ).fetchall() - finally: - await db.close() return [_module_row(r, with_code=False) for r in rows] @@ -138,8 +135,7 @@ async def create_module( ) -> dict[str, Any]: _assert_valid_status(body.status) _assert_valid_code(body.code) - db = await _get_db() - try: + async with _tenant_session() as db: existing = ( await db.execute( text("SELECT 1 FROM workflow_modules WHERE name = :name"), @@ -174,9 +170,6 @@ async def create_module( }, ) ).fetchone() - await db.commit() - finally: - await db.close() return _module_row(row) @@ -185,16 +178,13 @@ async def get_module( module_id: str, user: UserContext = Depends(get_current_user), ) -> dict[str, Any]: - db = await _get_db() - try: + async with _tenant_session() as db: row = ( await db.execute( text("SELECT * FROM workflow_modules WHERE id = :id"), {"id": module_id}, ) ).fetchone() - finally: - await db.close() if row is None: raise HTTPException(status_code=404, detail="Module not found") return _module_row(row) @@ -221,17 +211,13 @@ async def update_module( if value is not None: sets.append(f"{column} = :{column} ::jsonb") params[column] = json.dumps(value, default=str) - db = await _get_db() - try: + async with _tenant_session() as db: row = ( await db.execute( text(f"UPDATE workflow_modules SET {', '.join(sets)} WHERE id = :id RETURNING *"), params, ) ).fetchone() - await db.commit() - finally: - await db.close() if row is None: raise HTTPException(status_code=404, detail="Module not found") return _module_row(row) @@ -242,15 +228,11 @@ async def delete_module( module_id: str, user: UserContext = Depends(get_current_user), ) -> None: - db = await _get_db() - try: + async with _tenant_session() as db: await db.execute( text("DELETE FROM workflow_modules WHERE id = :id"), {"id": module_id}, ) - await db.commit() - finally: - await db.close() @router.post("/modules/test") @@ -278,16 +260,13 @@ async def test_saved_module( body: ModuleTest, user: UserContext = Depends(get_current_user), ) -> dict[str, Any]: - db = await _get_db() - try: + async with _tenant_session() as db: row = ( await db.execute( text("SELECT code FROM workflow_modules WHERE id = :id"), {"id": module_id}, ) ).fetchone() - finally: - await db.close() if row is None: raise HTTPException(status_code=404, detail="Module not found") try: diff --git a/apps/services/gateway/gateway/routes/workflows/publish.py b/apps/services/gateway/gateway/routes/workflows/publish.py index 338f0754f..a8ad16abe 100644 --- a/apps/services/gateway/gateway/routes/workflows/publish.py +++ b/apps/services/gateway/gateway/routes/workflows/publish.py @@ -27,8 +27,8 @@ from acb_auth import UserContext, get_current_user, require_permission from fastapi import Depends, HTTPException from gateway.routes.workflows.core import ( - _get_db, _log, + _tenant_session, _uid, iso, load_workflow_or_404, @@ -51,8 +51,7 @@ async def publish_workflow( workflow_id: str, user: UserContext = Depends(get_current_user), ) -> dict[str, Any]: - db = await _get_db() - try: + async with _tenant_session() as db: row = await load_workflow_or_404(db, workflow_id) graph = parse_jsonb(row.graph, {"nodes": [], "edges": []}) ready_modules = ( @@ -112,9 +111,6 @@ async def publish_workflow( ), {"id": workflow_id, "v": version}, ) - await db.commit() - finally: - await db.close() return { "workflow_id": workflow_id, "version": version, @@ -142,8 +138,7 @@ async def rollback_workflow( instead. The draft edit-model (``workflows.graph``) is untouched: runs execute versions, never the draft, and unpublished edits must survive. """ - db = await _get_db() - try: + async with _tenant_session() as db: row = await load_workflow_or_404(db, workflow_id) vrow = ( await db.execute( @@ -191,9 +186,6 @@ async def rollback_workflow( text("SELECT id FROM workflow_modules WHERE status = 'ready'"), ) ).fetchall() - await db.commit() - finally: - await db.close() warnings: list[dict[str, Any]] = [] try: @@ -236,8 +228,7 @@ async def disable_workflow( own, so the gallery answers "why is this off?" identically whether a human or the platform switched it off. """ - db = await _get_db() - try: + async with _tenant_session() as db: await load_workflow_or_404(db, workflow_id) reason = f"Disabled by {_uid(user)}" await db.execute( @@ -249,9 +240,6 @@ async def disable_workflow( ), {"id": workflow_id, "reason": reason}, ) - await db.commit() - finally: - await db.close() return {"workflow_id": workflow_id, "status": "disabled", "disabled_reason": reason} @@ -274,8 +262,7 @@ async def enable_workflow( It carries the same authority as publish because it arms the automation just as surely. """ - db = await _get_db() - try: + async with _tenant_session() as db: row = await load_workflow_or_404(db, workflow_id) if not row.latest_version: raise HTTPException( @@ -298,9 +285,6 @@ async def enable_workflow( ), {"id": workflow_id}, ) - await db.commit() - finally: - await db.close() _log.info("workflows.re_enabled", workflow_id=workflow_id, by=_uid(user)) return { "workflow_id": workflow_id, diff --git a/apps/services/gateway/gateway/routes/workflows/runs.py b/apps/services/gateway/gateway/routes/workflows/runs.py index 093772791..3cbb57f9d 100644 --- a/apps/services/gateway/gateway/routes/workflows/runs.py +++ b/apps/services/gateway/gateway/routes/workflows/runs.py @@ -16,7 +16,7 @@ from fastapi.responses import StreamingResponse from gateway.routes.workflows.core import ( MAX_RUNS_PAGE, - _get_db, + _tenant_session, _uid, iso, load_workflow_or_404, @@ -48,8 +48,7 @@ async def run_workflow( body: RunRequest, user: UserContext = Depends(get_current_user), ) -> dict[str, Any]: - db = await _get_db() - try: + async with _tenant_session() as db: row = await load_workflow_or_404(db, workflow_id) if body.draft: ready_modules = ( @@ -86,8 +85,8 @@ async def run_workflow( raise HTTPException(status_code=500, detail="Published version missing") variables = parse_jsonb(row.variables, {}) name = row.name - finally: - await db.close() + # The session is closed before `start_run`: the run acquires its own + # (unbound, H4) sessions and outlives this request's transaction. try: run_id = await start_run( workflow_id=workflow_id, @@ -111,8 +110,7 @@ async def list_runs( user: UserContext = Depends(get_current_user), ) -> list[dict[str, Any]]: limit = max(1, min(limit, MAX_RUNS_PAGE)) - db = await _get_db() - try: + async with _tenant_session() as db: rows = ( await db.execute( text( @@ -124,8 +122,6 @@ async def list_runs( {"wid": workflow_id, "limit": limit}, ) ).fetchall() - finally: - await db.close() return [ { "id": str(r.id), @@ -146,16 +142,13 @@ async def get_run( run_id: str, user: UserContext = Depends(get_current_user), ) -> dict[str, Any]: - db = await _get_db() - try: + async with _tenant_session() as db: row = ( await db.execute( text("SELECT * FROM workflow_runs WHERE id = :id"), {"id": run_id}, ) ).fetchone() - finally: - await db.close() if row is None: raise HTTPException(status_code=404, detail="Run not found") status = row.status @@ -197,8 +190,10 @@ async def _generate(): hub = hub_for(run_id) if hub is None: # Finished (or lost) run: serve the stored summary once and close. - db = await _get_db() - try: + # The tenant scope is still bound here: `TenantScopeMiddleware` is + # pure ASGI, so the streamed body is produced inside the request's + # scope, not after it. + async with _tenant_session() as db: row = ( await db.execute( text( @@ -207,8 +202,6 @@ async def _generate(): {"id": run_id}, ) ).fetchone() - finally: - await db.close() if row is None: yield _sse({"event": "error", "error": "run not found"}) return @@ -262,10 +255,13 @@ async def _reconcile_rejected_pause( run_id: str, status: str, error: str | None ) -> tuple[str, str | None]: """If the pause's approvals-inbox proposal was rejected, the run is over: - mark it cancelled (and the pause rejected). Best-effort on read.""" + mark it cancelled (and the pause rejected). Best-effort on read. + + The best-effort `except` wraps the `async with`: an exception mid-write + rolls the transaction back and the caller keeps the stored status, same + as before the H2 conversion.""" try: - db = await _get_db() - try: + async with _tenant_session() as db: pause = ( await db.execute( text( @@ -303,9 +299,6 @@ async def _reconcile_rejected_pause( ), {"id": run_id, "error": "approval rejected"}, ) - await db.commit() return "cancelled", "approval rejected" - finally: - await db.close() except Exception: return status, error diff --git a/apps/services/gateway/gateway/routes/workflows/scheduler.py b/apps/services/gateway/gateway/routes/workflows/scheduler.py index 9955dcb3e..d5d66ea1b 100644 --- a/apps/services/gateway/gateway/routes/workflows/scheduler.py +++ b/apps/services/gateway/gateway/routes/workflows/scheduler.py @@ -24,6 +24,12 @@ from datetime import UTC, datetime from typing import Any +# H4: the cron loop — `_scan_once` / `scan_due_waits` run in a supervised +# background task with no request and no member session, so there is no bound +# tenant and the ambient `tenant_session()` would raise `TenantUnbound` (and +# inheriting one would be exactly what H4 forbids). Stays on the unbound +# `get_db()` until H4 binds an explicit tenant per workflow row +# (`tenant_session(org_id)`). from gateway.routes.workflows.core import _get_db, _log, parse_jsonb from gateway.routes.workflows.service import ( RunRejected, diff --git a/apps/services/gateway/gateway/routes/workflows/search.py b/apps/services/gateway/gateway/routes/workflows/search.py index d91b19858..1df20e9a9 100644 --- a/apps/services/gateway/gateway/routes/workflows/search.py +++ b/apps/services/gateway/gateway/routes/workflows/search.py @@ -25,7 +25,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends -from gateway.routes.workflows.core import _get_db, _log, router +from gateway.routes.workflows.core import _log, _tenant_session, router from sqlalchemy import text DEFAULT_LIMIT = 20 @@ -105,10 +105,16 @@ def collect_catalog_entries() -> list[CatalogEntry]: async def _module_entries() -> list[CatalogEntry]: - """Modules from the library — best-effort (search works without the DB).""" + """Modules from the library — best-effort (search works without the DB). + + Reached only from member requests (the `/catalog/search` route and the + copilot's shortlist), so the tenant is bound in context. The best-effort + `except` stays OUTSIDE the `async with`: a failure mid-read rolls back and + is swallowed here, exactly as before — including `TenantUnbound` from a + caller outside a bound request, which degrades to "no module entries". + """ try: - db = await _get_db() - try: + async with _tenant_session() as db: rows = ( await db.execute( text( @@ -117,8 +123,6 @@ async def _module_entries() -> list[CatalogEntry]: ), ) ).fetchall() - finally: - await db.close() except Exception as exc: _log.warning("workflows.search_modules_failed", error=str(exc)[:120]) return [] diff --git a/apps/services/gateway/gateway/routes/workflows/service.py b/apps/services/gateway/gateway/routes/workflows/service.py index eead05dc3..60d5d99d3 100644 --- a/apps/services/gateway/gateway/routes/workflows/service.py +++ b/apps/services/gateway/gateway/routes/workflows/service.py @@ -31,6 +31,21 @@ from dataclasses import dataclass, field from typing import Any +# H4, DELIBERATELY NOT H2 (`saas_multitenancy_handover.md`): every session in +# this module belongs to the UNATTENDED run lifecycle — engine node loads +# (`_get_module_code`), run rows written by supervised background tasks that +# outlive the request that may have started them (`start_run`, `_finish_run`, +# `_hold_at_gate`), the startup sweep (`reconcile_orphaned_runs`), the health +# policy (`evaluate_automation_health`), and the programmatic/agent entry +# points (F13). Several are DUAL-USE — reached from a member's manual Run AND +# from the scheduler/webhook/event paths (`start_run`, `resume_run`) — and the +# H2 rule for that is LEAVE: a run must not behave differently depending on +# who happened to trigger it, and inheriting the ambient request tenant is +# exactly what H4 forbids. They stay on the unbound `get_db()` until H4 +# threads an explicit tenant (`tenant_session(org_id)` from the workflow row) +# through the run lifecycle. `_pm_task_updater` / `_pm_lifecycle_sweeper` are +# the Projects automation seam and are likewise H4 — do not change their +# session acquisition here. from gateway.routes.workflows.core import ( _get_db, _log, diff --git a/apps/services/gateway/gateway/routes/workflows/triggers.py b/apps/services/gateway/gateway/routes/workflows/triggers.py index 6ee18456c..4127e5507 100644 --- a/apps/services/gateway/gateway/routes/workflows/triggers.py +++ b/apps/services/gateway/gateway/routes/workflows/triggers.py @@ -20,6 +20,12 @@ from typing import Any +# H4: event consumer, not a request handler — `dispatch_event` fires from the +# webhook receivers' sink fan-out with no member session, so there is no bound +# tenant to inherit and inheriting an ambient one is exactly what H4 forbids. +# Stays on the unbound `get_db()` until H4 threads an explicit tenant through +# the event payload (`tenant_session(org_id)` derived from the workflow row's +# organization). from gateway.routes.workflows.core import _get_db, _log, parse_jsonb from gateway.routes.workflows.service import ( RunRejected, diff --git a/tests/unit/test_db_engine_seam.py b/tests/unit/test_db_engine_seam.py index 77e51ba35..47c402edc 100644 --- a/tests/unit/test_db_engine_seam.py +++ b/tests/unit/test_db_engine_seam.py @@ -311,7 +311,10 @@ def test_acb_auth_shares_the_pool() -> None: #: The unconverted remainder OUTSIDE routes/projects at the time the Projects #: slice landed (2026-08-10). Lower it as packages convert; never raise it. -H2_BASELINE_ELSEWHERE = 494 +#: 494 → 468 with the routes/workflows slice (26 member-reached sites +#: converted; the 17 that remain there are the unattended half — service, +#: scheduler, triggers, hooks — each H4-annotated in place). +H2_BASELINE_ELSEWHERE = 468 def _get_db_sites() -> dict[str, int]: diff --git a/tests/unit/test_workflows_automation_health.py b/tests/unit/test_workflows_automation_health.py index 080e6dbec..b02d4bb05 100644 --- a/tests/unit/test_workflows_automation_health.py +++ b/tests/unit/test_workflows_automation_health.py @@ -16,6 +16,7 @@ from __future__ import annotations import asyncio +from contextlib import asynccontextmanager from types import SimpleNamespace from typing import Any @@ -61,6 +62,23 @@ async def close(self) -> None: def _install(monkeypatch, module, db: _ScriptedDb) -> None: + """Patch whichever DB seam the module holds after H2. + + ``publish`` is member-reached and converted: its seam is the + ``_tenant_session`` context manager (commit on clean exit, like the real + wrapper). ``service`` is the unattended run lifecycle and deliberately + still acquires via ``_get_db`` (H4) — the health check commits its own + writes there.""" + if hasattr(module, "_tenant_session"): + + @asynccontextmanager + async def fake_tenant_session(): + yield db + await db.commit() + + monkeypatch.setattr(module, "_tenant_session", fake_tenant_session) + return + async def fake_get_db() -> Any: return db diff --git a/tests/unit/test_workflows_copilot.py b/tests/unit/test_workflows_copilot.py index bbf9a7eb7..c2106542d 100644 --- a/tests/unit/test_workflows_copilot.py +++ b/tests/unit/test_workflows_copilot.py @@ -13,6 +13,7 @@ import asyncio import uuid +from contextlib import asynccontextmanager from types import SimpleNamespace from typing import Any @@ -167,10 +168,13 @@ async def close(self) -> None: def fake_db(monkeypatch): store: dict[str, SimpleNamespace] = {} - async def _get_db() -> _FakeSession: - return _FakeSession(store) + @asynccontextmanager + async def _tenant_session(): + session = _FakeSession(store) + yield session + await session.commit() - monkeypatch.setattr(copilot_mod, "_get_db", _get_db) + monkeypatch.setattr(copilot_mod, "_tenant_session", _tenant_session) # Deterministic agent registry for validation. import gateway.routes.workflows.catalog as catalog_mod diff --git a/tests/unit/test_workflows_crud_lifecycle.py b/tests/unit/test_workflows_crud_lifecycle.py index ec33144a5..500aaf45d 100644 --- a/tests/unit/test_workflows_crud_lifecycle.py +++ b/tests/unit/test_workflows_crud_lifecycle.py @@ -9,6 +9,7 @@ import asyncio import json +from contextlib import asynccontextmanager from types import SimpleNamespace from typing import Any @@ -50,10 +51,18 @@ async def close(self) -> None: def _install(monkeypatch, module, db: _ScriptedDb) -> None: - async def fake_get_db() -> Any: - return db + """Swap the module's `_tenant_session` for the fake (H2 shape). - monkeypatch.setattr(module, "_get_db", fake_get_db) + Commit-on-clean-exit mirrors the real wrapper, so the one-transaction + contract (`db.committed`) stays observable; an exception propagates + without committing, mirroring the wrapper's rollback.""" + + @asynccontextmanager + async def fake_tenant_session(): + yield db + await db.commit() + + monkeypatch.setattr(module, "_tenant_session", fake_tenant_session) def _wf_row(**over: Any) -> SimpleNamespace: diff --git a/tests/unit/test_workflows_trigger_reliability.py b/tests/unit/test_workflows_trigger_reliability.py index 62221cf42..1326021a3 100644 --- a/tests/unit/test_workflows_trigger_reliability.py +++ b/tests/unit/test_workflows_trigger_reliability.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio +from contextlib import asynccontextmanager from datetime import UTC, datetime from types import SimpleNamespace from typing import Any @@ -368,10 +369,12 @@ def test_update_carries_the_baseline_onto_the_replacement_row(monkeypatch) -> No # load wf → previous triggers → DELETE → INSERT → reload wf → load triggers db = _ScriptedDb([wf, [old], None, None, wf, []]) - async def fake_get_db() -> Any: - return db + @asynccontextmanager + async def fake_tenant_session() -> Any: + yield db + await db.commit() - monkeypatch.setattr(crud, "_get_db", fake_get_db) + monkeypatch.setattr(crud, "_tenant_session", fake_tenant_session) body = crud.WorkflowUpdate( triggers=[ From 418bcace756a2c6e720bc7eb1cd2b8349584dbcf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 07:37:51 +0000 Subject: [PATCH 06/10] =?UTF-8?q?feat(tenancy):=20H2=20=E2=80=94=20routes/?= =?UTF-8?q?apps=20converted=20to=20tenant=5Fsession=20(slice)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 32 of 38 get_db() sites in gateway/routes/apps now acquire sessions via `async with _tenant_session() as db:` — the package alias of acb_common.db.tenant_session, exposed from _common.py and imported by name per submodule (the seam test_db_engine_seam.py asserts identity on). Explicit commits are gone: the wrapper owns the one commit on clean exit, so patch_app's re-read moved inside the same transaction and sync_workspace_to_store / _remember_tool_grant no longer commit themselves (their callers' blocks do). Six sites stay on the unbound seam, each with an H4 marker naming the tenant source (the app row's organization): - actions.py (4): execute_app_action + _run_storage_* are dual-audience — the HTTP route AND the orchestrator's in-process agent tools (orchestrator/app_tools.py) call them with no request and no bound tenant; ambient inheritance there is what the H2 runbook forbids. - _common.py (1): record_app_audit is reached from that same agent path; under the ambient seam its except-all would silently drop audit rows. - tools.py (1): _apply_publish_review is a broker-invoked action handler running on proposal approval, outside the publishing request. Ratchet banked: H2_BASELINE_ELSEWHERE 494 -> 462, plus a per-file exact- count pin (H2_APPS_EXEMPT_SITES + test_routes_apps_is_converted_and_ stays_converted) so the six exemptions can neither grow nor silently linger once retired. Hermetic fakes re-pointed to asynccontextmanager patches with commit-on-clean-exit (test_app_grants / test_app_tools / test_app_runtime_activity); test_app_actions keeps its _get_db patches because actions.py deliberately stays. NOT verified against live Postgres in this environment (no DB available); the handover requires one live smoke per package — cheapest converted read: GET /apps/pins. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../gateway/gateway/routes/apps/_common.py | 34 +++++++++++--- .../gateway/gateway/routes/apps/actions.py | 16 +++++++ .../gateway/gateway/routes/apps/durability.py | 37 +++++---------- .../gateway/gateway/routes/apps/files.py | 7 +-- .../gateway/gateway/routes/apps/grants.py | 25 ++-------- .../gateway/gateway/routes/apps/lifecycle.py | 40 ++++------------ .../gateway/gateway/routes/apps/pins.py | 19 ++------ .../gateway/gateway/routes/apps/publish.py | 26 +++-------- .../gateway/gateway/routes/apps/runtime.py | 39 ++++------------ .../gateway/gateway/routes/apps/tools.py | 25 ++++++---- tests/unit/test_app_grants.py | 29 +++++++++--- tests/unit/test_app_runtime_activity.py | 19 ++++++-- tests/unit/test_app_tools.py | 22 ++++++--- tests/unit/test_db_engine_seam.py | 46 ++++++++++++++++++- 14 files changed, 206 insertions(+), 178 deletions(-) diff --git a/apps/services/gateway/gateway/routes/apps/_common.py b/apps/services/gateway/gateway/routes/apps/_common.py index 857e93459..fa9dd9b05 100644 --- a/apps/services/gateway/gateway/routes/apps/_common.py +++ b/apps/services/gateway/gateway/routes/apps/_common.py @@ -28,6 +28,15 @@ # The shared gateway engine (BO-10) — see the DB section below. from gateway.db import get_db as _get_db from gateway.db import get_session_factory as _get_session_factory # noqa: F401 + +# The tenant-bound seam (BO-10 → MT-1c/H2). `_tenant_session` IS +# `acb_common.db.tenant_session`, aliased per-package for the same reason +# `_get_db` was: every submodule imports it from here BY NAME, which is the +# per-module seam the hermetic tests patch. The tenant comes from the request +# context — bound once in `_with_resolved_access` — so no call site passes +# one (H2). A call outside a bound request raises `TenantUnbound` rather than +# defaulting: fail closed, never "the usual org". +from gateway.db import tenant_session as _tenant_session # noqa: F401 from sqlalchemy import text _log = get_logger("gateway.apps") @@ -73,11 +82,11 @@ # ── DB (the one shared gateway engine — gateway/db.py, BO-10) ──────────────── # # This package used to build its own engine here with its own 10+20 pool. It now -# has none: `_get_db` / `_get_session_factory` at the top of this module are -# re-exports of the shared seam. The private names are kept so that every -# `from ._common import _get_db` in this package — and every test that -# monkeypatches `_get_db` on the sibling module it is imported into — keeps -# working unchanged. +# has none: `_get_db` / `_get_session_factory` / `_tenant_session` at the top of +# this module are re-exports of the shared seam. Since H2 the request handlers +# acquire sessions through `_tenant_session` only; `_get_db` remains ONLY for +# the named H4-deferred sites (`record_app_audit` below, and `actions.py`'s +# dual-audience dispatch), which `test_db_engine_seam.py` pins by exact count. # ── Auth gate ──────────────────────────────────────────────────────────────── @@ -498,7 +507,20 @@ async def record_app_audit( model: str = "", ) -> None: """Append one ``app_audit`` row. Best-effort: opens its own session so a - failed audit can neither raise nor poison the caller's transaction.""" + failed audit can neither raise nor poison the caller's transaction. + + ⚠️ H4, DELIBERATELY NOT H2 (`saas_multitenancy_handover.md`): this helper + is reached from OUTSIDE any request — `actions.execute_app_action` audits + every dispatch, and the orchestrator's agent tools call that function + in-process during an agent run with no request and no bound tenant + (`orchestrator/app_tools.py`). Converting this site to the ambient + `tenant_session()` would raise `TenantUnbound` there, and this except-all + wrapper would then silently DROP the audit row — a security trail. It + stays on the unbound `get_db()` until H4 threads the explicit tenant (the + app row's organization, resolvable from ``app_id``) through the call — + `tenant_session(org_id)`. The H2 ratchet in `test_db_engine_seam.py` + carries this site as a named, counted exemption. + """ try: db = await _get_db() try: diff --git a/apps/services/gateway/gateway/routes/apps/actions.py b/apps/services/gateway/gateway/routes/apps/actions.py index 6f43315d7..a4b9db318 100644 --- a/apps/services/gateway/gateway/routes/apps/actions.py +++ b/apps/services/gateway/gateway/routes/apps/actions.py @@ -91,6 +91,22 @@ from acb_auth import UserContext, UserRole from action_broker import AuthorityTier, propose, submit from fastapi import Depends, HTTPException + +# ⚠️ H4, DELIBERATELY NOT H2 (`saas_multitenancy_handover.md`): this module's +# four `_get_db()` sites (`execute_app_action` + the `_run_storage_*` helpers +# it dispatches to) stay on the UNBOUND seam on purpose. `execute_app_action` +# is the package's one dual-audience entry point: the HTTP route below calls +# it with a member's request identity (tenant bound by `_with_resolved_access`), +# but the orchestrator's agent tools call the SAME function in-process during +# an agent run (`orchestrator/app_tools.py::_make_action_tool`) with +# `UserContext(email=, role=AGENT)` — no request, no bound tenant. +# Converting these sites to the ambient `tenant_session()` would raise +# `TenantUnbound` for every agent-invoked app action, and letting the agent +# path inherit an ambient tenant is exactly what the H2 runbook forbids for +# non-request callers. H4 threads the EXPLICIT tenant (the app row's +# organization) through the dispatch — `tenant_session(org_id)` — for both +# audiences. The H2 ratchet in `test_db_engine_seam.py` carries these sites +# as named, counted exemptions. from gateway.routes.apps._common import ( MAX_STORAGE_ROWS_PER_APP, MAX_STORAGE_VALUE_BYTES, diff --git a/apps/services/gateway/gateway/routes/apps/durability.py b/apps/services/gateway/gateway/routes/apps/durability.py index 52866a44d..b1a39fdb3 100644 --- a/apps/services/gateway/gateway/routes/apps/durability.py +++ b/apps/services/gateway/gateway/routes/apps/durability.py @@ -27,8 +27,8 @@ MAX_WORKSPACE_FILES, WORKSPACE_SKIP_DIRS, _field, - _get_db, _log, + _tenant_session, _uid, app_workspace, get_app_or_404, @@ -151,7 +151,9 @@ def _sha256(content: str) -> str: async def sync_workspace_to_store(db: Any, row: Any) -> int: """Mirror the workspace into ``app_files``: upsert changed files (the sha skips untouched ones), delete rows whose path is gone from disk. Returns - the number of files now in the store. Commits on *db*.""" + the number of files now in the store. Does NOT commit — every caller + hands in a ``_tenant_session`` block, whose wrapper commits on clean exit + (H2: a mid-block commit would end the transaction and drop the GUC).""" app_id = str(_field(row, "id")) disk = await asyncio.get_event_loop().run_in_executor( None, _read_workspace_files, app_workspace(row), @@ -173,20 +175,18 @@ async def sync_workspace_to_store(db: Any, row: Any) -> int: text("DELETE FROM app_files WHERE app_id = :app_id AND path = :path"), {"app_id": app_id, "path": rel}, ) - await db.commit() return len(disk) async def sync_workspace_best_effort(row: Any) -> int | None: """``sync_workspace_to_store`` on its own session — never raises. The scaffold/publish write-throughs ride this so a mirror hiccup can't fail - the user-visible operation.""" + the user-visible operation. Awaited only on request paths, so the + ambient tenant is bound; a call outside one fails closed (and lands in + this except-all as a logged warning, keeping the best-effort contract).""" try: - db = await _get_db() - try: + async with _tenant_session() as db: return await sync_workspace_to_store(db, row) - finally: - await db.close() except Exception as exc: _log.warning( "apps.store_sync_failed", @@ -199,15 +199,11 @@ async def mirror_app_file(app_id: str, path: str, content: str) -> None: """Best-effort single-file upsert (own session, never raises) — the PUT write path's cheap write-through; full reconciliation stays with sync.""" try: - db = await _get_db() - try: + async with _tenant_session() as db: await db.execute(_UPSERT_SQL, { "app_id": app_id, "path": path, "content": content, "sha256": _sha256(content), }) - await db.commit() - finally: - await db.close() except Exception as exc: _log.warning( "apps.file_mirror_failed", @@ -394,12 +390,9 @@ async def sync_app( user: UserContext = Depends(require_app_user), ) -> dict[str, Any]: """Mirror the draft into ``app_files`` + drop a git checkpoint.""" - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user, edit=True) files = await sync_workspace_to_store(db, row) - finally: - await db.close() checkpoint = await asyncio.get_event_loop().run_in_executor( None, git_checkpoint, app_workspace(row), "checkpoint", ) @@ -416,12 +409,9 @@ async def list_app_checkpoints( user: UserContext = Depends(require_app_user), ) -> dict[str, list[dict[str, Any]]]: """The draft's checkpoint history (empty when git is unusable).""" - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user, edit=True) workspace = await ensure_workspace(db, row) - finally: - await db.close() checkpoints = await asyncio.get_event_loop().run_in_executor( None, list_checkpoints, workspace, ) @@ -440,8 +430,7 @@ async def restore_app_checkpoint( raise HTTPException( status_code=422, detail="sha must be 7-40 hex characters", ) - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user, edit=True) workspace = await ensure_workspace(db, row) new_sha = await asyncio.get_event_loop().run_in_executor( @@ -452,8 +441,6 @@ async def restore_app_checkpoint( status_code=409, detail="Checkpoint restore failed", ) await sync_workspace_to_store(db, row) - finally: - await db.close() await record_app_audit( app_id=str(row.id), user_email=_uid(user), kind="storage", detail={"op": "restore", "from": sha, "checkpoint": new_sha}, diff --git a/apps/services/gateway/gateway/routes/apps/files.py b/apps/services/gateway/gateway/routes/apps/files.py index 964122587..b3720cfa3 100644 --- a/apps/services/gateway/gateway/routes/apps/files.py +++ b/apps/services/gateway/gateway/routes/apps/files.py @@ -23,8 +23,8 @@ MAX_SOURCE_FILE_BYTES, MAX_WORKSPACE_FILES, WORKSPACE_SKIP_DIRS, - _get_db, _log, + _tenant_session, get_app_or_404, read_workspace_manifest, require_app_author, @@ -87,12 +87,9 @@ def _walk_files(workspace: Path) -> list[AppFileEntry]: async def _edit_workspace(slug: str, user: UserContext) -> tuple[Any, Path]: """Edit-gated row + workspace path — rehydrated from ``app_files`` first when the on-disk draft is missing (the durability choke point).""" - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user, edit=True) workspace = await ensure_workspace(db, row) - finally: - await db.close() return row, workspace diff --git a/apps/services/gateway/gateway/routes/apps/grants.py b/apps/services/gateway/gateway/routes/apps/grants.py index 023c7d1a2..432914073 100644 --- a/apps/services/gateway/gateway/routes/apps/grants.py +++ b/apps/services/gateway/gateway/routes/apps/grants.py @@ -26,8 +26,8 @@ from acb_auth import UserContext from fastapi import Depends, HTTPException from gateway.routes.apps._common import ( - _get_db, _log, + _tenant_session, _uid, get_app_or_404, iso, @@ -104,8 +104,7 @@ async def list_app_grants( slug: str, user: UserContext = Depends(require_app_user), ) -> list[GrantEntry]: - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user, edit=True) rows = (await db.execute( text( @@ -116,8 +115,6 @@ async def list_app_grants( ), {"app_id": str(row.id)}, )).fetchall() - finally: - await db.close() return [_to_entry(r) for r in rows] @@ -136,8 +133,7 @@ async def upsert_app_grant( ) if not is_valid_subject(body.subject): raise HTTPException(status_code=422, detail="Invalid subject") - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user, edit=True) rec = (await db.execute( text( @@ -153,9 +149,6 @@ async def upsert_app_grant( "role": body.role, "granted_by": _uid(user), }, )).fetchone() - await db.commit() - finally: - await db.close() await record_app_audit( app_id=str(row.id), user_email=_uid(user), kind="action", detail={"action": "grant_upsert", "subject": body.subject, @@ -174,8 +167,7 @@ async def revoke_app_grant( subject: str, user: UserContext = Depends(require_app_user), ) -> dict[str, Any]: - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user, edit=True) await db.execute( text( @@ -184,9 +176,6 @@ async def revoke_app_grant( ), {"app_id": str(row.id), "subject": subject}, ) - await db.commit() - finally: - await db.close() await record_app_audit( app_id=str(row.id), user_email=_uid(user), kind="action", detail={"action": "grant_revoke", "subject": subject}, @@ -212,8 +201,7 @@ async def consent_app( re-fetch + re-show the interstitial rather than silently recording consent to an outdated scope set. """ - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user) if row.live_version is None: raise HTTPException( @@ -241,9 +229,6 @@ async def consent_app( ), {"app_id": str(row.id), "email": email, "hash": live_hash}, ) - await db.commit() - finally: - await db.close() await record_app_audit( app_id=str(row.id), user_email=_uid(user), kind="action", detail={"action": "consent", "scope_set_hash": live_hash}, diff --git a/apps/services/gateway/gateway/routes/apps/lifecycle.py b/apps/services/gateway/gateway/routes/apps/lifecycle.py index ad715b862..7e95be2f0 100644 --- a/apps/services/gateway/gateway/routes/apps/lifecycle.py +++ b/apps/services/gateway/gateway/routes/apps/lifecycle.py @@ -18,8 +18,8 @@ from acb_auth import UserContext from fastapi import Depends, HTTPException from gateway.routes.apps._common import ( - _get_db, _log, + _tenant_session, _uid, app_workspace, apps_root, @@ -291,8 +291,7 @@ async def list_apps( user: UserContext = Depends(require_app_user), ) -> list[AppSummary]: """Every app the caller can see, newest-updated first.""" - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text("SELECT * FROM apps ORDER BY updated_at DESC"), )).fetchall() @@ -316,8 +315,6 @@ async def list_apps( GROUP BY app_id""" ), )).fetchall() - finally: - await db.close() by_app: dict[str, list[tuple[str, str]]] = {} for g in grant_rows: by_app.setdefault(str(g.app_id), []).append((g.subject, g.role)) @@ -347,8 +344,7 @@ async def create_app( if not name: raise HTTPException(status_code=422, detail="Name is required") owner = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: taken = { r.slug for r in (await db.execute( text("SELECT slug FROM apps"), @@ -381,9 +377,6 @@ async def create_app( "workspace_path": str(workspace), }, )).fetchone() - await db.commit() - finally: - await db.close() # Durability: mirror the fresh scaffold into app_files right away so a # brand-new draft survives a disk loss (best-effort, never fails create). await sync_workspace_best_effort(row) @@ -409,8 +402,7 @@ async def fork_app( no publish history of its own). """ owner = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: source_row, _source_grants = await get_app_or_404(db, slug, user) source_workspace = await ensure_workspace(db, source_row) files = await asyncio.get_event_loop().run_in_executor( @@ -456,9 +448,6 @@ async def fork_app( "workspace_path": str(new_workspace), }, )).fetchone() - await db.commit() - finally: - await db.close() await sync_workspace_best_effort(row) _log.info("apps.forked", source_slug=slug, slug=new_slug, owner=owner) publish_app_activity(new_slug, user=owner, action="created") @@ -474,8 +463,7 @@ async def get_app( (RFC §4.8), which is why this is the only caller of ``_to_detail`` that passes them: create/patch/list never need the extra queries. """ - db = await _get_db() - try: + async with _tenant_session() as db: row, grants = await get_app_or_404(db, slug, user) needs_consent: bool | None = None live_scopes: list[str] | None = None @@ -503,8 +491,6 @@ async def get_app( consent_row.consented_scope_hash if consent_row else None ) or "" needs_consent = consented_hash != live_scope_set_hash - finally: - await db.close() return _to_detail( row, user, grants, needs_consent=needs_consent, @@ -524,8 +510,7 @@ async def patch_app( raise HTTPException(status_code=422, detail="Invalid visibility") if "name" in fields and not str(fields["name"]).strip(): raise HTTPException(status_code=422, detail="Name cannot be empty") - db = await _get_db() - try: + async with _tenant_session() as db: row, grants = await get_app_or_404(db, slug, user, edit=True) if fields: sets = ", ".join(f"{k} = :{k}" for k in fields) @@ -536,12 +521,11 @@ async def patch_app( ), {**fields, "id": str(row.id)}, ) - await db.commit() + # Re-read inside the SAME transaction (H2: the wrapper owns the one + # commit, on clean exit) — the UPDATE above is visible to it. row = (await db.execute( text("SELECT * FROM apps WHERE id = :id"), {"id": str(row.id)}, )).fetchone() - finally: - await db.close() manifest_fields = { k: v for k, v in fields.items() if k in ("name", "icon", "description") @@ -560,8 +544,7 @@ async def delete_app( ) -> dict[str, Any]: """Soft-archive (edit-gated); a second delete by the OWNER of an already archived app removes the row (cascades) and its workspace folder.""" - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user, edit=True) if row.status != "archived": await db.execute( @@ -571,7 +554,7 @@ async def delete_app( ), {"id": str(row.id)}, ) - await db.commit() + # Early return is a clean exit — the wrapper commits the UPDATE. return {"slug": slug, "status": "archived"} if row.owner_email != user.email: raise HTTPException( @@ -580,9 +563,6 @@ async def delete_app( await db.execute( text("DELETE FROM apps WHERE id = :id"), {"id": str(row.id)}, ) - await db.commit() - finally: - await db.close() # Remove the workspace folder — but only when it's really under apps_root # (a corrupt workspace_path must never delete arbitrary directories). workspace = app_workspace(row).resolve() diff --git a/apps/services/gateway/gateway/routes/apps/pins.py b/apps/services/gateway/gateway/routes/apps/pins.py index 66af53528..1b95a3730 100644 --- a/apps/services/gateway/gateway/routes/apps/pins.py +++ b/apps/services/gateway/gateway/routes/apps/pins.py @@ -13,7 +13,7 @@ from acb_auth import UserContext from fastapi import Depends from gateway.routes.apps._common import ( - _get_db, + _tenant_session, _uid, get_app_or_404, require_app_user, @@ -35,8 +35,7 @@ async def list_pinned_apps( ) -> list[PinnedApp]: """This viewer's pinned apps, most-recently-pinned first — the sidebar's query. Deliberately minimal payload (no manifest/status/etc.).""" - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text( """SELECT a.slug, a.name, a.icon @@ -47,8 +46,6 @@ async def list_pinned_apps( ), {"email": _uid(user)}, )).fetchall() - finally: - await db.close() return [PinnedApp(slug=r.slug, name=r.name, icon=r.icon or "") for r in rows] @@ -58,8 +55,7 @@ async def pin_app( user: UserContext = Depends(require_app_user), ) -> dict[str, bool]: """Pin *slug* for the current viewer. Idempotent — pinning twice is a no-op.""" - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user) await db.execute( text( @@ -69,9 +65,6 @@ async def pin_app( ), {"app_id": str(row.id), "email": _uid(user)}, ) - await db.commit() - finally: - await db.close() return {"pinned": True} @@ -82,8 +75,7 @@ async def unpin_app( ) -> dict[str, bool]: """Unpin *slug* for the current viewer. Idempotent — unpinning an already-unpinned app is a no-op, not a 404.""" - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user) await db.execute( text( @@ -91,7 +83,4 @@ async def unpin_app( ), {"app_id": str(row.id), "email": _uid(user)}, ) - await db.commit() - finally: - await db.close() return {"pinned": False} diff --git a/apps/services/gateway/gateway/routes/apps/publish.py b/apps/services/gateway/gateway/routes/apps/publish.py index 92e0a6f6d..6d2d8bb95 100644 --- a/apps/services/gateway/gateway/routes/apps/publish.py +++ b/apps/services/gateway/gateway/routes/apps/publish.py @@ -29,8 +29,8 @@ from fastapi.responses import HTMLResponse from gateway.routes.apps import tools as _tools from gateway.routes.apps._common import ( - _get_db, _log, + _tenant_session, _uid, app_workspace, can_edit, @@ -123,8 +123,7 @@ async def publish_app( """Snapshot the draft as the next immutable version and go live.""" if body.visibility is not None and body.visibility not in VISIBILITIES: raise HTTPException(status_code=422, detail="Invalid visibility") - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user, edit=True) await ensure_workspace(db, row) data, manifest = await asyncio.get_event_loop().run_in_executor( @@ -200,9 +199,8 @@ async def publish_app( sets += ", visibility = :visibility" params["visibility"] = body.visibility await db.execute(text(f"UPDATE apps SET {sets} WHERE id = :id"), params) - await db.commit() - finally: - await db.close() + # ^ block exit commits — the version row is durable BEFORE the review + # proposal below is submitted, preserving the pre-H2 ordering. if needs_review: # SUGGEST always → NEEDS_APPROVAL, so this unconditionally queues — @@ -245,8 +243,7 @@ async def rollback_app( user: UserContext = Depends(require_app_author), ) -> dict[str, Any]: """Repoint the live version at an existing snapshot.""" - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user, edit=True) exists = (await db.execute( text( @@ -264,9 +261,6 @@ async def rollback_app( ), {"version": body.version, "id": str(row.id)}, ) - await db.commit() - finally: - await db.close() await record_app_audit( app_id=str(row.id), user_email=_uid(user), kind="rollback", app_version=body.version, detail={"to_version": body.version}, @@ -283,8 +277,7 @@ async def list_app_versions( user: UserContext = Depends(require_app_user), ) -> list[VersionEntry]: """Version history, newest first (view-gated — feeds the info popover).""" - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user) rows = (await db.execute( text( @@ -294,8 +287,6 @@ async def list_app_versions( ), {"app_id": str(row.id)}, )).fetchall() - finally: - await db.close() return [ VersionEntry( version=r.version, @@ -318,8 +309,7 @@ async def get_app_bundle( current workspace entry file; ``live``/N (viewers) serve the immutable snapshot. ``track=1`` marks a real open (the run page) so preview refreshes don't flood the audit trail.""" - db = await _get_db() - try: + async with _tenant_session() as db: row, grants = await get_app_or_404(db, slug, user) if version == "draft": if not can_edit(row, user, grants): @@ -356,8 +346,6 @@ async def get_app_bundle( )).fetchone() if vrow is None: raise HTTPException(status_code=404, detail="Version not found") - finally: - await db.close() if track: await record_app_audit( app_id=str(row.id), user_email=_uid(user), kind="open", diff --git a/apps/services/gateway/gateway/routes/apps/runtime.py b/apps/services/gateway/gateway/routes/apps/runtime.py index 7e2691235..f75ab4d0d 100644 --- a/apps/services/gateway/gateway/routes/apps/runtime.py +++ b/apps/services/gateway/gateway/routes/apps/runtime.py @@ -23,8 +23,8 @@ MAX_STORAGE_ROWS_PER_APP, MAX_STORAGE_VALUE_BYTES, TABLE_NAME_RE, - _get_db, _log, + _tenant_session, _uid, app_workspace, get_app_or_404, @@ -131,11 +131,8 @@ async def app_me( user: UserContext = Depends(require_app_viewer), ) -> dict[str, str]: """Who is viewing — what ``cc.user()`` returns.""" - db = await _get_db() - try: + async with _tenant_session() as db: await get_app_or_404(db, slug, user) - finally: - await db.close() return {"email": _uid(user), "role": user.role.value} @@ -149,8 +146,7 @@ async def list_storage_rows( user: UserContext = Depends(require_app_viewer), ) -> dict[str, list[StorageRow]]: _validate_table(table) - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user) rows = (await db.execute( text( @@ -163,8 +159,6 @@ async def list_storage_rows( {"app_id": str(row.id), "table": table, "scope": _partition(scope, user)}, )).fetchall() - finally: - await db.close() return {"rows": [_row_out(r) for r in rows]} @@ -177,8 +171,7 @@ async def get_storage_row( user: UserContext = Depends(require_app_viewer), ) -> StorageRow: _validate_table(table) - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user) rec = (await db.execute( text( @@ -190,8 +183,6 @@ async def get_storage_row( {"app_id": str(row.id), "table": table, "key": key, "scope": _partition(scope, user)}, )).fetchone() - finally: - await db.close() if rec is None: raise HTTPException(status_code=404, detail="Key not found") return _row_out(rec) @@ -217,8 +208,7 @@ async def put_storage_row( detail=f"Value too large. Maximum is {MAX_STORAGE_VALUE_BYTES} bytes.", ) partition = _partition(body.scope, user) - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user) # Per-app row quota. Overwrites of an existing key stay allowed at the # cap so a full app can still update (not brick) its own data. @@ -259,9 +249,6 @@ async def put_storage_row( {"app_id": str(row.id), "table": table, "key": key, "value": encoded, "scope": partition, "by": _uid(user)}, )).fetchone() - await db.commit() - finally: - await db.close() await record_app_audit( app_id=str(row.id), user_email=_uid(user), kind="storage", detail={"table": table, "key": key, "op": "put"}, @@ -278,8 +265,7 @@ async def delete_storage_row( user: UserContext = Depends(require_app_viewer), ) -> dict[str, bool]: _validate_table(table) - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user) result = await db.execute( text( @@ -290,9 +276,6 @@ async def delete_storage_row( {"app_id": str(row.id), "table": table, "key": key, "scope": _partition(scope, user)}, ) - await db.commit() - finally: - await db.close() deleted = bool(getattr(result, "rowcount", 0)) if deleted: await record_app_audit( @@ -353,14 +336,11 @@ async def ai_complete( the app's monthly token budget (429 ``ai_budget_exhausted`` when spent).""" messages = _build_messages(body) max_tokens = min(max(int(body.max_tokens or 1000), 1), MAX_AI_OUTPUT_TOKENS) - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user) manifest = _effective_manifest(row) budget = resolve_ai_budget(manifest) used = (await _month_ai_usage(db, str(row.id)))["tokens"] - finally: - await db.close() if used >= budget: return JSONResponse( status_code=429, @@ -431,12 +411,9 @@ async def app_usage( user: UserContext = Depends(require_app_viewer), ) -> dict[str, Any]: """This calendar month's AI usage + budget (the info-popover numbers).""" - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user) usage = await _month_ai_usage(db, str(row.id)) - finally: - await db.close() return { "month_tokens": usage["tokens"], "month_cost_usd": round(usage["cost_usd"], 6), diff --git a/apps/services/gateway/gateway/routes/apps/tools.py b/apps/services/gateway/gateway/routes/apps/tools.py index d2d15bee1..8e30f1dba 100644 --- a/apps/services/gateway/gateway/routes/apps/tools.py +++ b/apps/services/gateway/gateway/routes/apps/tools.py @@ -56,6 +56,7 @@ from gateway.routes.apps._common import ( _get_db, _log, + _tenant_session, _uid, get_app_or_404, manifest_scopes, @@ -221,6 +222,17 @@ async def _apply_publish_review(proposal: ActionProposal) -> dict[str, Any]: commit — mirrors ``record_app_audit``'s use-and-close shape, on the same async engine as the rest of this package (``_common._get_db``). + ⚠️ H4, DELIBERATELY NOT H2 (`saas_multitenancy_handover.md`): this is a + BROKER-invoked action handler, not a request handler — it runs whenever + the ``app.publish_review`` proposal is approved, which can be minutes + after (and structurally outside) the publishing request, from the generic + approvals surface. The runbook's rule for that category is "do not let a + job inherit an ambient tenant", so it stays on the unbound ``get_db()`` + until H4 threads an EXPLICIT tenant through the proposal payload — the + app row's organization, resolvable from ``payload["app_id"]`` — as + ``tenant_session(org_id)``. The H2 ratchet in ``test_db_engine_seam.py`` + carries this site as a named, counted exemption. + Known accepted gap (do not build a reconciliation job for this): a REJECTED review leaves this version's ``review_status`` stuck at ``'pending'`` forever — the generic ``/actions/pending/{id}/reject`` @@ -314,6 +326,8 @@ async def _has_remembered_grant( async def _remember_tool_grant( db: Any, app_id: str, email: str, tool: str, ) -> None: + """Upsert one remembered-consent row. Does NOT commit — the caller's + ``_tenant_session`` block commits on clean exit (H2).""" await db.execute( text( """INSERT INTO app_tool_grants (app_id, user_email, tool) @@ -322,7 +336,6 @@ async def _remember_tool_grant( ), {"app_id": app_id, "email": email, "tool": tool}, ) - await db.commit() def _audit_args(args: dict[str, Any]) -> Any: @@ -373,8 +386,7 @@ async def _run_destructive_tool( ) -> Any: """Per-use confirm (or a remembered grant), then the Action Broker.""" email = _uid(user) - db = await _get_db() - try: + async with _tenant_session() as db: pre_confirmed = await _has_remembered_grant(db, str(row.id), email, tool) if not pre_confirmed and not body.confirm: return JSONResponse(status_code=409, content={ @@ -382,8 +394,6 @@ async def _run_destructive_tool( }) if body.confirm and body.remember: await _remember_tool_grant(db, str(row.id), email, tool) - finally: - await db.close() authority = ( AuthorityTier.SUGGEST_APPLY if _tool_broker_enforced(tool) @@ -445,12 +455,9 @@ async def call_app_tool( "always allow" grant, ``app_tool_grants``) and then flow through the Action Broker exactly like every other outward write in the platform. """ - db = await _get_db() - try: + async with _tenant_session() as db: row, _grants = await get_app_or_404(db, slug, user) manifest = await _load_live_manifest(db, row) - finally: - await db.close() constraints = find_declared_tool_scope(manifest, tool) if constraints is None: diff --git a/tests/unit/test_app_grants.py b/tests/unit/test_app_grants.py index 194a7f82d..5f7a11d6d 100644 --- a/tests/unit/test_app_grants.py +++ b/tests/unit/test_app_grants.py @@ -8,9 +8,17 @@ in-memory DB sessions (mirrors ``tests/unit/test_app_tools.py``'s convention: no TestClient, no live Postgres — DB-touching seams are monkeypatched so every test stays hermetic and fast). + +H2 note: the handlers' seam is the package's ``_tenant_session`` alias +(``acb_common.db.tenant_session``), swapped in via an ``asynccontextmanager`` +that commits on clean exit — so one-transaction contracts stay observable — +while ``_common._get_db`` (``record_app_audit``'s H4-deferred seam) keeps its +plain-coroutine patch. """ from __future__ import annotations +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from types import SimpleNamespace from typing import Any @@ -168,8 +176,12 @@ def _patch_grants( row = row or _row() db = fake_db if fake_db is not None else _FakeGrantsDB() - async def _get_db() -> _FakeGrantsDB: - return db + @asynccontextmanager + async def _tenant_session() -> AsyncIterator[_FakeGrantsDB]: + # Commit-on-clean-exit, like the real wrapper — a handler that raises + # mid-block commits nothing here just as against Postgres. + yield db + await db.commit() async def _get_app_or_404( _db: Any, _slug: str, _user: UserContext, edit: bool = False, @@ -179,7 +191,7 @@ async def _get_app_or_404( async def _common_get_db() -> _FakeGrantsDB: return _FakeGrantsDB() # keeps record_app_audit best-effort + DB-free - monkeypatch.setattr(grants, "_get_db", _get_db) + monkeypatch.setattr(grants, "_tenant_session", _tenant_session) monkeypatch.setattr(grants, "get_app_or_404", _get_app_or_404) monkeypatch.setattr(_common, "_get_db", _common_get_db) return row, db @@ -353,6 +365,9 @@ async def execute(self, sql: Any, params: dict | None = None) -> _Result: return _Result(one=self.consent_row) return _Result() + async def commit(self) -> None: + pass + async def close(self) -> None: pass @@ -373,13 +388,15 @@ def _patch_lifecycle_get_app( monkeypatch: pytest.MonkeyPatch, *, row: Any, caller_grants: list, db: _LifecycleFakeDB, ) -> None: - async def _get_db() -> _LifecycleFakeDB: - return db + @asynccontextmanager + async def _tenant_session() -> AsyncIterator[_LifecycleFakeDB]: + yield db + await db.commit() async def _get_app_or_404(_db: Any, _slug: str, _user: UserContext) -> Any: return row, caller_grants - monkeypatch.setattr(lifecycle, "_get_db", _get_db) + monkeypatch.setattr(lifecycle, "_tenant_session", _tenant_session) monkeypatch.setattr(lifecycle, "get_app_or_404", _get_app_or_404) diff --git a/tests/unit/test_app_runtime_activity.py b/tests/unit/test_app_runtime_activity.py index ed54fb635..0430aa60c 100644 --- a/tests/unit/test_app_runtime_activity.py +++ b/tests/unit/test_app_runtime_activity.py @@ -8,13 +8,16 @@ another's (stack-inferred attribution collapses every custom app into one generic "apps" bucket — see ``acb_llm.context._infer_app_source``). -DB-touching seams (``_get_db``/``get_app_or_404``/``_month_ai_usage``/ +DB-touching seams (``_tenant_session``/``get_app_or_404``/``_month_ai_usage``/ ``record_app_audit``) and the LLM call itself are monkeypatched with fakes — no live Postgres, no live model — mirroring ``test_app_actions.py``'s -conventions. +conventions (``_tenant_session`` swapped in as an ``asynccontextmanager``, +the H2 seam shape). """ from __future__ import annotations +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from types import SimpleNamespace from typing import Any @@ -60,6 +63,9 @@ def _row(**overrides: Any) -> Any: class _FakeDB: + async def commit(self) -> None: + pass + async def close(self) -> None: pass @@ -68,8 +74,11 @@ def _patch_common(monkeypatch: pytest.MonkeyPatch, *, used_tokens: int = 0, budget: int = 100_000) -> None: row = _row() - async def _get_db() -> _FakeDB: - return _FakeDB() + @asynccontextmanager + async def _tenant_session() -> AsyncIterator[_FakeDB]: + db = _FakeDB() + yield db + await db.commit() async def _get_app_or_404(_db: Any, _slug: str, _user: UserContext) -> Any: return row, [] @@ -80,7 +89,7 @@ async def _month_ai_usage(_db: Any, _app_id: str) -> dict[str, Any]: async def _record_app_audit(**_kw: Any) -> None: pass - monkeypatch.setattr(runtime, "_get_db", _get_db) + monkeypatch.setattr(runtime, "_tenant_session", _tenant_session) monkeypatch.setattr(runtime, "get_app_or_404", _get_app_or_404) monkeypatch.setattr(runtime, "_month_ai_usage", _month_ai_usage) monkeypatch.setattr(runtime, "resolve_ai_budget", lambda _manifest: budget) diff --git a/tests/unit/test_app_tools.py b/tests/unit/test_app_tools.py index af1e400fe..b48ba64ef 100644 --- a/tests/unit/test_app_tools.py +++ b/tests/unit/test_app_tools.py @@ -21,6 +21,8 @@ from __future__ import annotations import json +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from types import SimpleNamespace from typing import Any @@ -189,8 +191,14 @@ def _patch_common( row = row or _row() shared_grants = grants if grants is not None else set() - async def _get_db() -> _FakeDB: - return _FakeDB(shared_grants) + @asynccontextmanager + async def _tenant_session() -> AsyncIterator[_FakeDB]: + # H2: the handlers' seam is the tenant-bound context manager; a fresh + # fake per block sharing `shared_grants` keeps the state-per-table + # convention, and commit-on-clean-exit mirrors the real wrapper. + db = _FakeDB(shared_grants) + yield db + await db.commit() async def _get_app_or_404(_db: Any, _slug: str, _user: UserContext) -> Any: return row, [] @@ -201,7 +209,7 @@ async def _load_live_manifest(_db: Any, _row: Any) -> dict[str, Any]: async def _common_get_db() -> _FakeDB: return _FakeDB() # keeps record_app_audit best-effort + DB-free - monkeypatch.setattr(tools, "_get_db", _get_db) + monkeypatch.setattr(tools, "_tenant_session", _tenant_session) monkeypatch.setattr(tools, "get_app_or_404", _get_app_or_404) monkeypatch.setattr(tools, "_load_live_manifest", _load_live_manifest) monkeypatch.setattr(_common, "_get_db", _common_get_db) @@ -494,14 +502,16 @@ async def _fake_get_app_or_404( ) -> Any: return row, [] - async def _get_db() -> _PublishFakeDB: - return fake_db + @asynccontextmanager + async def _tenant_session() -> AsyncIterator[_PublishFakeDB]: + yield fake_db + await fake_db.commit() async def _noop(*_a: Any, **_k: Any) -> None: return None monkeypatch.setattr(publish, "get_app_or_404", _fake_get_app_or_404) - monkeypatch.setattr(publish, "_get_db", _get_db) + monkeypatch.setattr(publish, "_tenant_session", _tenant_session) monkeypatch.setattr(publish, "sync_workspace_best_effort", _noop) monkeypatch.setattr(publish, "record_app_audit", _noop) monkeypatch.setattr(publish, "publish_app_activity", lambda *a, **k: None) diff --git a/tests/unit/test_db_engine_seam.py b/tests/unit/test_db_engine_seam.py index 77e51ba35..aab21952e 100644 --- a/tests/unit/test_db_engine_seam.py +++ b/tests/unit/test_db_engine_seam.py @@ -311,7 +311,31 @@ def test_acb_auth_shares_the_pool() -> None: #: The unconverted remainder OUTSIDE routes/projects at the time the Projects #: slice landed (2026-08-10). Lower it as packages convert; never raise it. -H2_BASELINE_ELSEWHERE = 494 +#: 494 → 462: the routes/apps slice (32 sites converted, 6 named exemptions — +#: see H2_APPS_EXEMPT_SITES below). +H2_BASELINE_ELSEWHERE = 462 + +#: routes/apps (H2 slice, 2026-08-10): the sites that STAY on the unbound +#: seam, as file → exact remaining count. Counts rather than whole files +#: because ``tools.py`` is mixed — its request handlers are converted while +#: its broker-invoked ``_apply_publish_review`` stays. Each entry is a +#: decision, not a grandfathering — H4 owns retiring them, and each site's +#: own ``H4`` comment names the reason and the tenant source (the app row's +#: organization): +#: +#: * ``actions.py`` (4) — ``execute_app_action`` + its ``_run_storage_*`` +#: helpers are dual-audience: the HTTP route AND the orchestrator's +#: in-process agent tools (``orchestrator/app_tools.py``), which run with +#: no request and no bound tenant. +#: * ``_common.py`` (1) — ``record_app_audit``, reached from that same +#: agent path; converting it would silently drop agent-action audit rows. +#: * ``tools.py`` (1) — ``_apply_publish_review``, an Action Broker +#: handler that runs when an admin approves a queued proposal. +H2_APPS_EXEMPT_SITES: dict[str, int] = { + "apps/services/gateway/gateway/routes/apps/_common.py": 1, + "apps/services/gateway/gateway/routes/apps/actions.py": 4, + "apps/services/gateway/gateway/routes/apps/tools.py": 1, +} def _get_db_sites() -> dict[str, int]: @@ -342,6 +366,26 @@ def test_routes_projects_is_converted_and_stays_converted() -> None: ) +def test_routes_apps_is_converted_and_stays_converted() -> None: + """The Custom Apps package acquires sessions through `tenant_session`, + except the named H4 sites — pinned by EXACT count, both directions. + + A count above an entry (or a new file) is a handler whose queries will + silently return nothing under RLS; a count below it is banked progress + that must lower the entry, or the headroom becomes new-debt budget. + """ + sites = { + f: n for f, n in _get_db_sites().items() + if f.startswith("apps/services/gateway/gateway/routes/apps/") + } + assert sites == H2_APPS_EXEMPT_SITES, ( + f"routes/apps unbound get_db() sites {sites} != the named exemptions " + f"{H2_APPS_EXEMPT_SITES} — new sites must use " + f"`async with _tenant_session() as db:` (_common.py); a retired " + f"exemption must shrink H2_APPS_EXEMPT_SITES in this test" + ) + + def test_get_db_sites_elsewhere_only_ratchet_down() -> None: total = sum( n for f, n in _get_db_sites().items() From 971fd1621f15f5d8a1cf3888e7d610b619bf8411 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 07:40:09 +0000 Subject: [PATCH 07/10] fix: remove leftover conflict marker from the apps-slice merge The previous merge commit shipped with one stray '<<<<<<< HEAD' line in test_db_engine_seam.py (a piped test run masked the failure). Ratchet and apps suites green again (116 passed). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- tests/unit/test_db_engine_seam.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/test_db_engine_seam.py b/tests/unit/test_db_engine_seam.py index 59eaf8bf1..5f1f4159d 100644 --- a/tests/unit/test_db_engine_seam.py +++ b/tests/unit/test_db_engine_seam.py @@ -330,7 +330,6 @@ def test_acb_auth_shares_the_pool() -> None: #: The unconverted remainder OUTSIDE routes/projects at the time the Projects #: slice landed (2026-08-10). Lower it as packages convert; never raise it. -<<<<<<< HEAD #: 494 → 433: routes/notes converted (61 handler sites → `_tenant_session`; #: 33 remain there — background pipeline/poller/copilot-task sites and the #: meeting-bot worker's service-identity paths, each marked `# H4` in place). From 0b69c3e1397269068d3121e7ed23b7b7c11483f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 07:42:21 +0000 Subject: [PATCH 08/10] =?UTF-8?q?feat(tenancy):=20H2=20=E2=80=94=20routes/?= =?UTF-8?q?crm,=20routes/people,=20routes/admin=20converted=20to=20tenant?= =?UTF-8?q?=5Fsession=20(slice)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 55 of the three packages' 58 get_db() sites now acquire their session through the tenant-bound seam (`async with _tenant_session() as db:`), with the seam aliased in each package's central module (crm/core.py, admin/_common.py, people/core.py) exactly as routes/projects did: - crm: activities (4), admin (8), deal_contacts (3), import_zoho (1), pipeline (2), records (5), reports (4), stage_metadata (1) — all member-reached request handlers (the CRM agent reaches them over HTTP with the acting member's identity, so the central binding covers it). Three files stay on the unbound seam BY DECISION, each annotated and named in H2_EXEMPT_FILES: auto_lead (background, email scheduler hook), sync_zoho (scheduled engine, service identity, per-phase commits), broker_handlers (approval-time handler; tenant belongs to the proposal payload, never the approver's ambient binding) — H4 owns all three. - people: directory (4) — all request handlers; core.py's dead `get_session` wrapper replaced by the shared-seam alias and the module added to the seam-identity parametrize list. - admin: members (8), roles (5), groups (6), access_requests (3), me (1) — since S1-1 admin resolves the CALLER's org from app_user, the same source the central bind_tenant reads; the explicit organization_id predicates stay as defense in depth. `_common.get_db` remains exported only for routes/agent_skills.py (not this package's to convert). Handlers' explicit commits are gone (the wrapper commits on clean exit); post-commit re-reads now read their own uncommitted writes in the same transaction. Hermetic fakes swap in via @asynccontextmanager patches with commit-on-clean-exit, so one-transaction contracts stay observable; `committed == 0` proxies on read-only successes became statement assertions, and the purge route's one-commit structural test now pins the `async with` shape instead of a literal db.commit(). Ratchets: H2_BASELINE_ELSEWHERE 494 → 439 (banked); the projects-only converted-package test is now parametrized over all four converted packages, with a stale-exemption check. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../gateway/gateway/routes/admin/_common.py | 25 ++++++--- .../gateway/routes/admin/access_requests.py | 22 ++++---- .../gateway/gateway/routes/admin/groups.py | 25 +++------ .../gateway/gateway/routes/admin/me.py | 5 +- .../gateway/gateway/routes/admin/members.py | 37 +++++-------- .../gateway/gateway/routes/admin/roles.py | 20 +++---- .../gateway/gateway/routes/crm/activities.py | 25 ++------- .../gateway/gateway/routes/crm/admin.py | 48 ++++------------- .../gateway/gateway/routes/crm/auto_lead.py | 11 ++++ .../gateway/routes/crm/broker_handlers.py | 9 ++++ .../gateway/gateway/routes/crm/core.py | 17 +++++- .../gateway/routes/crm/deal_contacts.py | 19 ++----- .../gateway/gateway/routes/crm/import_zoho.py | 8 +-- .../gateway/gateway/routes/crm/pipeline.py | 13 ++--- .../gateway/gateway/routes/crm/records.py | 30 +++-------- .../gateway/gateway/routes/crm/reports.py | 22 ++------ .../gateway/routes/crm/stage_metadata.py | 9 +--- .../gateway/gateway/routes/crm/sync_zoho.py | 11 ++++ .../gateway/gateway/routes/people/core.py | 16 +++--- .../gateway/routes/people/directory.py | 22 ++------ tests/unit/_admin_fakes.py | 22 ++++++-- tests/unit/_crm_fakes.py | 31 +++++++++-- tests/unit/test_admin_groups.py | 12 +++-- tests/unit/test_admin_member_purge.py | 25 ++++++--- tests/unit/test_admin_tenancy.py | 10 ++-- tests/unit/test_crm_reports.py | 3 +- tests/unit/test_crm_stage_metadata.py | 3 +- tests/unit/test_db_engine_seam.py | 54 ++++++++++++++++--- tests/unit/test_people_directory.py | 12 +++-- tests/unit/test_people_write.py | 22 +++++--- 30 files changed, 304 insertions(+), 284 deletions(-) diff --git a/apps/services/gateway/gateway/routes/admin/_common.py b/apps/services/gateway/gateway/routes/admin/_common.py index 632006247..bfa44f87c 100644 --- a/apps/services/gateway/gateway/routes/admin/_common.py +++ b/apps/services/gateway/gateway/routes/admin/_common.py @@ -48,8 +48,19 @@ # The shared gateway engine (BO-10) — see the DB section below for why the # names are re-exported rather than imported at each call site. +# +# H2 (MT-1c): `_tenant_session` IS `acb_common.db.tenant_session` — the +# tenant-bound context manager every request handler in this package now +# acquires its session through. The tenant is bound centrally from the +# caller's `app_user` row (`_with_resolved_access` → `bind_tenant`), which is +# the SAME source `get_org_id` below resolves — S1-1's explicit +# `organization_id` predicates stay as defense in depth on top of it. +# `get_db` (unbound) remains re-exported ONLY for `routes/agent_skills.py`, +# which reaches the seam through this module and is not this package's to +# convert. from gateway.db import get_db # noqa: F401 from gateway.db import get_session_factory as _get_session_factory # noqa: F401 +from gateway.db import tenant_session as _tenant_session # noqa: F401 # The ONE answer to "which tenant is this caller" (WS-29b, D-MT-1 (a)). Imported # rather than re-derived: two implementations of that question is exactly how @@ -95,14 +106,15 @@ # ── DB (the one shared gateway engine — gateway/db.py, BO-10) ──────────────── # # This package used to build its own engine here, with its own pool of 5+10. -# It now has none: `get_db` and `_get_session_factory` at the top of this module -# are re-exports of the shared seam, so every `from ._common import get_db` in -# this package keeps working with a single pool behind it. +# It now has none: `_tenant_session` and `_get_session_factory` at the top of +# this module are re-exports of the shared seam, so every +# `from ._common import _tenant_session` in this package keeps working with a +# single pool behind it. # # The re-export is deliberate rather than pointing each caller at `gateway.db`. # Sibling modules import the name from here and the tests monkeypatch it *on the -# sibling* (`monkeypatch.setattr(groups, "get_db", ...)`), so the name has to -# stay resolvable through this module for both to keep working. +# sibling* (`monkeypatch.setattr(groups, "_tenant_session", ...)`), so the name +# has to stay resolvable through this module for both to keep working. # ── Auth gate ─────────────────────────────────────────────────────────────── @@ -655,7 +667,8 @@ async def provision_member( hand-rolled INSERT would quietly skip. Spec ``colleague_onboarding.md`` §6 done-when 8. - Deliberately left to the CALLER: ``db.commit()`` (so an approval can mark + Deliberately left to the CALLER: the commit — the caller's + ``_tenant_session`` block commits on clean exit (so an approval can mark its request decided in the same transaction), ``invalidate_for``, ``record_admin_change`` (the audit action differs — `org.member_invited` vs `org.access_request_approved`) and the response model. diff --git a/apps/services/gateway/gateway/routes/admin/access_requests.py b/apps/services/gateway/gateway/routes/admin/access_requests.py index 9a0443575..5a610139c 100644 --- a/apps/services/gateway/gateway/routes/admin/access_requests.py +++ b/apps/services/gateway/gateway/routes/admin/access_requests.py @@ -54,8 +54,8 @@ from gateway.routes.admin._common import ( _iso, _log, + _tenant_session, find_member, - get_db, get_org_id, invalidate_for, provision_member, @@ -133,9 +133,9 @@ #: for :func:`_load_request`: read-then-write is two statements, so two admins #: clicking the same row both pass the read. Binding the same condition into #: the UPDATE makes the loser's row-count zero, and because this runs BEFORE -#: ``db.commit()`` the 409 it raises discards that transaction's provisioning -#: with it — approve stays all-or-nothing under concurrency, not just in -#: sequence. +#: the commit (`_tenant_session` commits only on clean exit) the 409 it raises +#: discards that transaction's provisioning with it — approve stays +#: all-or-nothing under concurrency, not just in sequence. _DECIDE_SQL = ( "UPDATE access_request SET status = :status, decided_by = :by, " " decided_at = now() " @@ -298,7 +298,8 @@ async def _decide( without the condition here both writers would succeed and both callers would be told they won. Zero rows updated means the row moved, so the whole transaction — this stamp *and* any provisioning done above it — is - abandoned by raising before ``db.commit()``. + abandoned by raising before the commit (``_tenant_session`` commits only + on clean exit). """ if status not in REQUEST_STATUSES: raise ValueError(f"unknown access_request status {status!r}") @@ -370,8 +371,7 @@ async def list_access_requests( Sits on the package's ``admin:members:read`` floor like every other read — seeing who is locked out is part of reading the roster, not a new right. """ - db = await get_db() - async with db: + async with _tenant_session() as db: rows = (await db.execute(text(_PENDING_REQUESTS_SQL))).mappings().all() return [_entry(dict(r)) for r in rows] @@ -413,8 +413,7 @@ async def approve_access_request( person out of a queue that renders only `pending`, and recorded an `org.access_request_approved` for an approval that did not happen. """ - db = await get_db() - async with db: + async with _tenant_session() as db: request = await _load_request(db, email, allowed_statuses=("pending",)) org_id = await get_org_id(db, admin) @@ -473,7 +472,6 @@ async def approve_access_request( await _decide(db, member["email"].lower(), "approved", admin, allowed_statuses=("pending",)) - await db.commit() roles = await roles_for_user(db, member["id"]) status = member["status"] @@ -516,14 +514,12 @@ async def deny_access_request( so the only thing it could achieve is a queue record that contradicts the roster. Suspend or remove them from the roster instead. """ - db = await get_db() - async with db: + async with _tenant_session() as db: request = await _load_request( db, email, allowed_statuses=("pending", "denied"), ) await _decide(db, request["email"].lower(), "denied", admin, allowed_statuses=("pending", "denied")) - await db.commit() _log.info("access_request_denied", email=request["email"], by=admin.email, attempts=request["attempt_count"]) diff --git a/apps/services/gateway/gateway/routes/admin/groups.py b/apps/services/gateway/gateway/routes/admin/groups.py index e13bb4db2..fc7611463 100644 --- a/apps/services/gateway/gateway/routes/admin/groups.py +++ b/apps/services/gateway/gateway/routes/admin/groups.py @@ -23,7 +23,7 @@ from fastapi import Depends, HTTPException from gateway.routes.admin._common import ( _log, - get_db, + _tenant_session, get_member, get_org_id, invalidate_for, @@ -175,8 +175,7 @@ async def list_groups( the roster) — the org has tens of people, so the full expansion is cheap and saves the UI a per-group round trip. """ - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) rows = ( await db.execute( @@ -202,8 +201,7 @@ async def create_group( admin: UserContext = Depends(require_admin_user), ) -> GroupEntry: slug = _clean_slug(req.slug) - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) clash = ( await db.execute( @@ -228,7 +226,6 @@ async def create_group( "name": (req.display_name or slug).strip() or slug, "desc": req.description or "", "by": admin.email}, ) - await db.commit() _log.info("group_created", slug=slug, by=admin.email) record_admin_change(admin.email, "org.group_created", f"group:{slug}") @@ -253,8 +250,7 @@ async def update_group( ``group:`` participant subjects, ``t:`` agent instance keys, the ``center.`` pairing — and renaming it would orphan all three. """ - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) group = await _get_group(db, org_id, slug) await db.execute( @@ -268,7 +264,6 @@ async def update_group( {"name": patch.display_name, "desc": patch.description, "gid": group["id"]}, ) - await db.commit() group = await _get_group(db, org_id, slug) entry = await _entry(db, group) @@ -291,8 +286,7 @@ async def delete_group( and silently emptying every room shared to ``group:`` — is a bigger action than the admin asked for. """ - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) group = await _get_group(db, org_id, slug) if slug in CENTER_GROUP_SLUGS: @@ -324,7 +318,6 @@ async def delete_group( text("DELETE FROM org_group WHERE id = CAST(:gid AS uuid)"), {"gid": group["id"]}, ) - await db.commit() _log.info("group_deleted", slug=slug, by=admin.email) record_admin_change(admin.email, "org.group_deleted", f"group:{slug}") @@ -366,8 +359,7 @@ async def add_group_member( ), ) - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) group = await _get_group(db, org_id, slug) member = await get_member(db, org_id, req.email) @@ -395,7 +387,6 @@ async def add_group_member( "reason": f"group membership: {slug}", "by": admin.email}, ) granted = bool(getattr(result, "rowcount", 0)) - await db.commit() invalidate_for(member["email"]) _log.info("group_member_added", group=slug, email=member["email"], @@ -431,8 +422,7 @@ async def remove_group_member( DOES immediately remove the member from rooms shared to ``group:``: those expand membership at read time.) """ - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) group = await _get_group(db, org_id, slug) member = await get_member(db, org_id, email) @@ -449,7 +439,6 @@ async def remove_group_member( status_code=404, detail=f"'{email}' is not a member of '{slug}'.", ) - await db.commit() invalidate_for(member["email"]) _log.info("group_member_removed", group=slug, email=member["email"], diff --git a/apps/services/gateway/gateway/routes/admin/me.py b/apps/services/gateway/gateway/routes/admin/me.py index 15ec77f2d..2851da2aa 100644 --- a/apps/services/gateway/gateway/routes/admin/me.py +++ b/apps/services/gateway/gateway/routes/admin/me.py @@ -19,7 +19,7 @@ from acb_auth.permissions import CAPABILITIES from fastapi import APIRouter, Depends -from gateway.routes.admin._common import get_db, get_org_id +from gateway.routes.admin._common import _tenant_session, get_org_id me_router = APIRouter(prefix="/auth", tags=["auth"]) @@ -106,8 +106,7 @@ async def get_me(user: UserContext = Depends(get_current_user)) -> dict[str, Any organization: dict[str, str] = {} catalog: list[str] = [] try: - db = await get_db() - async with db: + async with _tenant_session() as db: # The CALLER's organization, not the deployment's. This line used to # report the `default` org's slug and display name to every # signed-in member of every tenant, so the frontend's "which org am diff --git a/apps/services/gateway/gateway/routes/admin/members.py b/apps/services/gateway/gateway/routes/admin/members.py index e7db0551b..3d9b4b3d9 100644 --- a/apps/services/gateway/gateway/routes/admin/members.py +++ b/apps/services/gateway/gateway/routes/admin/members.py @@ -36,10 +36,10 @@ PURGE_OUTCOME, _iso, _log, + _tenant_session, assert_not_self_demotion, assert_not_self_lockout, assert_owner_survives, - get_db, get_member, get_org_id, invalidate_for, @@ -108,8 +108,7 @@ async def list_members( include_removed: bool = False, admin: UserContext = Depends(require_admin_user), ) -> list[MemberEntry]: - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) sql = ( "SELECT u.id::text AS id, u.email, u.display_name, u.avatar_url, " @@ -164,8 +163,7 @@ async def invite_member( """ email = (req.email or "").strip().lower() - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) member, _assigned = await provision_member( db, org_id, @@ -175,7 +173,6 @@ async def invite_member( admin=admin, status="invited", ) - await db.commit() roles = await roles_for_user(db, member["id"]) invalidate_for(email) @@ -204,8 +201,7 @@ async def update_member( detail=f"status must be one of {list(VALID_STATUSES)}.", ) - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) member = await get_member(db, org_id, email) @@ -241,7 +237,6 @@ async def update_member( ), {"name": patch.display_name, "uid": member["id"]}, ) - await db.commit() member = await get_member(db, org_id, email) roles = await roles_for_user(db, member["id"]) @@ -276,8 +271,7 @@ async def remove_member( actually matters for access is that the member resolves to nothing, which the `removed` status guarantees. """ - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) member = await get_member(db, org_id, email) # Invariant 4, from the same helper the PATCH above calls — this route @@ -297,7 +291,6 @@ async def remove_member( ), {"uid": member["id"]}, ) - await db.commit() invalidate_for(member["email"]) _log.info("member_removed", email=member["email"], by=admin.email) @@ -561,8 +554,9 @@ async def purge_member( the last owner is not a recoverable mistake, it is a permanently ownerless org. - **One transaction.** Every statement runs on one session with a single - ``commit()`` at the end. A half-purge that deleted the credentials but left + **One transaction.** Every statement runs on one session, committed once — + by ``_tenant_session``, on clean exit of the block, and only then. A + half-purge that deleted the credentials but left the member active is worse than either outcome, and a half-purge that deleted the member row but left an OAuth token behind is worse still. @@ -586,8 +580,7 @@ async def purge_member( Recorded rather than fixed: making one caller strict is a change to ``acb_audit``'s contract, not to this route. """ - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) member = await get_member(db, org_id, email) @@ -627,7 +620,6 @@ async def purge_member( # Before the commit, on its own connection — see the docstring. record_admin_change(admin.email, "org.member_purged", f"user:{addr}", deleted=deleted, kept=kept) - await db.commit() invalidate_for(member["email"]) _log.info("member_purged", email=member["email"], by=admin.email, @@ -650,8 +642,7 @@ async def set_member_roles( req: RoleAssignment, admin: UserContext = Depends(require_admin_user), ) -> MemberEntry: - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) member = await get_member(db, org_id, email) role_ids = await resolve_assignable_roles(db, org_id, req.roles, admin) @@ -682,7 +673,6 @@ async def set_member_roles( ), {"role": legacy, "uid": member["id"]}, ) - await db.commit() roles = await roles_for_user(db, member["id"]) invalidate_for(member["email"]) @@ -799,8 +789,7 @@ async def get_member_access( admin: UserContext = Depends(require_admin_user), ) -> dict[str, Any]: """The member's effective access, with provenance for every decision.""" - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) member = await get_member(db, org_id, email) roles = await roles_for_user(db, member["id"]) @@ -885,8 +874,7 @@ async def set_member_overrides( seen.add(perm) cleaned.append((perm, entry.effect, entry.reason or "")) - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) member = await get_member(db, org_id, email) @@ -921,7 +909,6 @@ async def set_member_overrides( {"uid": member["id"], "perm": perm, "effect": effect, "reason": reason, "by": admin.email}, ) - await db.commit() invalidate_for(member["email"]) _log.info("member_overrides_set", email=member["email"], by=admin.email, diff --git a/apps/services/gateway/gateway/routes/admin/roles.py b/apps/services/gateway/gateway/routes/admin/roles.py index b2a9eeb4e..bd575abb2 100644 --- a/apps/services/gateway/gateway/routes/admin/roles.py +++ b/apps/services/gateway/gateway/routes/admin/roles.py @@ -28,7 +28,7 @@ from gateway.routes.admin._common import ( _log, - get_db, + _tenant_session, get_org_id, get_role, invalidate_for, @@ -107,8 +107,7 @@ async def _permissions_for(db: Any, role_id: str) -> list[str]: async def list_roles( admin: UserContext = Depends(require_admin_user), ) -> list[RoleEntry]: - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) rows = ( await db.execute( @@ -152,8 +151,7 @@ async def create_role( slug = _clean_slug(req.slug) permissions = _clean_permissions(req.permissions) - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) existing = ( await db.execute( @@ -208,7 +206,6 @@ async def create_role( ), {"rid": role_id, "perm": perm}, ) - await db.commit() _log.info("role_created", slug=slug, by=admin.email, permissions=permissions) record_admin_change(admin.email, "org.role_created", f"role:{slug}", @@ -229,8 +226,7 @@ async def update_role( patch: RolePatch, admin: UserContext = Depends(require_admin_user), ) -> RoleEntry: - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) role = await get_role(db, org_id, slug) if role["is_system"]: @@ -269,7 +265,6 @@ async def update_role( ), {"rid": role["id"], "perm": perm}, ) - await db.commit() permissions = await _permissions_for(db, role["id"]) role = await get_role(db, org_id, slug) @@ -295,8 +290,7 @@ async def delete_role( slug: str, admin: UserContext = Depends(require_admin_user), ) -> dict[str, str]: - db = await get_db() - async with db: + async with _tenant_session() as db: org_id = await get_org_id(db, admin) role = await get_role(db, org_id, slug) if role["is_system"]: @@ -320,7 +314,6 @@ async def delete_role( text("DELETE FROM org_role WHERE id = CAST(:rid AS uuid)"), {"rid": role["id"]}, ) - await db.commit() invalidate_for(None) _log.info("role_deleted", slug=slug, by=admin.email) @@ -340,8 +333,7 @@ async def list_features( """ features: list[dict[str, Any]] = [] try: - db = await get_db() - async with db: + async with _tenant_session() as db: rows = ( await db.execute( text( diff --git a/apps/services/gateway/gateway/routes/crm/activities.py b/apps/services/gateway/gateway/routes/crm/activities.py index f19bfc6fd..9f6674497 100644 --- a/apps/services/gateway/gateway/routes/crm/activities.py +++ b/apps/services/gateway/gateway/routes/crm/activities.py @@ -40,7 +40,7 @@ ORGANIZATIONS, ActivityModel, Entity, - _get_db, + _tenant_session, actor, bump_last_activity, insert_row, @@ -107,8 +107,7 @@ async def _timeline( ) -> TimelineResponse: """The merged timeline. ``user`` is required because one of the three sources — email — is scoped to the CALLER's mailboxes, not to the record.""" - db = await _get_db() - try: + async with _tenant_session() as db: record = await require_row(db, entity.table, record_id, entity.label) sources: list[tuple[Entity, str, str]] = [(entity, record_id, "own")] inherited = getattr(record, "lead_id", None) @@ -137,8 +136,6 @@ async def _timeline( # which sorts last rather than crashing the comparison. entries.sort(key=lambda e: (e.at or ""), reverse=True) return TimelineResponse(entries=entries[:limit]) - finally: - await db.close() async def _activity_entries( @@ -395,8 +392,7 @@ async def _log_activity( "('status_change' and 'system' are written by the platform.)" ), ) - db = await _get_db() - try: + async with _tenant_session() as db: await require_row(db, entity.table, record_id, entity.label) # `entity.activity_column` is one of four registry literals, so the # CHECK requiring at least one target cannot be reached with all four @@ -412,10 +408,7 @@ async def _log_activity( entity.activity_column: record_id, }) await bump_last_activity(db, entity.table, record_id) - await db.commit() return row_to_dict(row, ActivityModel) - finally: - await db.close() # ── Timelines ─────────────────────────────────────────────────────────────── @@ -504,8 +497,7 @@ async def patch_activity( history, and re-logging is one request. """ values = payload.model_dump(exclude_unset=True) - db = await _get_db() - try: + async with _tenant_session() as db: row = await require_row(db, "crm_activities", activity_id, "Activity") if row.type in ("status_change", "system"): # Same guard as delete_activity: one rule, both verbs. An edited @@ -524,10 +516,7 @@ async def patch_activity( row = await update_row( db, "crm_activities", activity_id, values, touch=False, ) - await db.commit() return row_to_dict(row, ActivityModel) - finally: - await db.close() @router.delete("/activities/{activity_id}") @@ -544,8 +533,7 @@ async def delete_activity( retired. Do not "fix" one direction alone — a native tombstone without the matching Zoho→native delete would make the two sides disagree in a new way. """ - db = await _get_db() - try: + async with _tenant_session() as db: row = await require_row(db, "crm_activities", activity_id, "Activity") if row.type in ("status_change", "system"): raise HTTPException( @@ -559,10 +547,7 @@ async def delete_activity( text("DELETE FROM crm_activities WHERE id = CAST(:id AS uuid)"), {"id": activity_id}, ) - await db.commit() return {"deleted": activity_id} - finally: - await db.close() __all__ = [ diff --git a/apps/services/gateway/gateway/routes/crm/admin.py b/apps/services/gateway/gateway/routes/crm/admin.py index e631b323a..add1133d9 100644 --- a/apps/services/gateway/gateway/routes/crm/admin.py +++ b/apps/services/gateway/gateway/routes/crm/admin.py @@ -28,7 +28,7 @@ STATUS_TYPES, LostReasonModel, StatusModel, - _get_db, + _tenant_session, insert_row, require_row, router, @@ -181,14 +181,11 @@ async def list_statuses( ) -> list[StatusModel]: """The kanban lanes, in lane order.""" table, _ = _kind(kind) - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text(f"SELECT * FROM {table} ORDER BY position, name"), {}, )).fetchall() return [status_wire(r) for r in rows] - finally: - await db.close() @router.post("/statuses/{kind}", response_model=StatusModel, status_code=201) @@ -202,15 +199,11 @@ async def create_status( if not (values.get("name") or "").strip(): raise HTTPException(status_code=422, detail="A status needs a name.") values.setdefault("type", "open") - db = await _get_db() - try: + async with _tenant_session() as db: if values.get("position") is None: values["position"] = await _next_position(db, table) row = await insert_row(db, table, values) - await db.commit() return status_wire(row) - finally: - await db.close() @router.patch("/statuses/{kind}/{status_id}", response_model=StatusModel) @@ -227,17 +220,13 @@ async def patch_status( """ table, _ = _kind(kind) values = payload.model_dump(exclude_unset=True) - db = await _get_db() - try: + async with _tenant_session() as db: row = await require_row(db, table, status_id, "Status") _validate_status(kind, values, existing=row) if not values: return status_wire(row) row = await update_row(db, table, status_id, values, touch=False) - await db.commit() return status_wire(row) - finally: - await db.close() @router.delete("/statuses/{kind}/{status_id}") @@ -246,8 +235,7 @@ async def delete_status( user: UserContext = Depends(get_current_user), ) -> dict: table, referencing = _kind(kind) - db = await _get_db() - try: + async with _tenant_session() as db: await require_row(db, table, status_id, "Status") in_use = (await db.execute( text( @@ -268,10 +256,7 @@ async def delete_status( text(f"DELETE FROM {table} WHERE id = CAST(:id AS uuid)"), {"id": status_id}, ) - await db.commit() return {"deleted": status_id, "kind": kind} - finally: - await db.close() async def _next_position(db: Any, table: str) -> int: @@ -289,8 +274,7 @@ async def _next_position(db: Any, table: str) -> int: async def list_lost_reasons( user: UserContext = Depends(get_current_user), ) -> list[LostReasonModel]: - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text("SELECT * FROM crm_lost_reasons ORDER BY position, label"), {}, )).fetchall() @@ -301,8 +285,6 @@ async def list_lost_reasons( ) for r in rows ] - finally: - await db.close() @router.post("/lost-reasons", response_model=LostReasonModel, status_code=201) @@ -312,18 +294,14 @@ async def create_lost_reason( values = payload.model_dump(exclude_unset=True) if not (values.get("label") or "").strip(): raise HTTPException(status_code=422, detail="A lost reason needs a label.") - db = await _get_db() - try: + async with _tenant_session() as db: if values.get("position") is None: values["position"] = await _next_position(db, "crm_lost_reasons") row = await insert_row(db, "crm_lost_reasons", values) - await db.commit() return LostReasonModel( id=str(row.id), label=row.label, position=int(getattr(row, "position", 0) or 0), ) - finally: - await db.close() @router.patch("/lost-reasons/{reason_id}", response_model=LostReasonModel) @@ -332,20 +310,16 @@ async def patch_lost_reason( user: UserContext = Depends(get_current_user), ) -> LostReasonModel: values = payload.model_dump(exclude_unset=True) - db = await _get_db() - try: + async with _tenant_session() as db: row = await require_row(db, "crm_lost_reasons", reason_id, "Lost reason") if values: row = await update_row( db, "crm_lost_reasons", reason_id, values, touch=False, ) - await db.commit() return LostReasonModel( id=str(row.id), label=row.label, position=int(getattr(row, "position", 0) or 0), ) - finally: - await db.close() @router.delete("/lost-reasons/{reason_id}") @@ -354,14 +328,10 @@ async def delete_lost_reason( ) -> dict: """Deleting a lost reason is safe: both FKs are ``ON DELETE SET NULL``, so the records that cited it keep their ``lost_note`` and lose the label.""" - db = await _get_db() - try: + async with _tenant_session() as db: await require_row(db, "crm_lost_reasons", reason_id, "Lost reason") await db.execute( text("DELETE FROM crm_lost_reasons WHERE id = CAST(:id AS uuid)"), {"id": reason_id}, ) - await db.commit() return {"deleted": reason_id} - finally: - await db.close() diff --git a/apps/services/gateway/gateway/routes/crm/auto_lead.py b/apps/services/gateway/gateway/routes/crm/auto_lead.py index 40142da61..f9844e207 100644 --- a/apps/services/gateway/gateway/routes/crm/auto_lead.py +++ b/apps/services/gateway/gateway/routes/crm/auto_lead.py @@ -269,6 +269,17 @@ async def create_leads_from_new_mail(account_id: str) -> dict[str, int]: "considered 0" are different facts and one of them is a bug report. """ stats = _new_stats() + # H4, DELIBERATELY NOT H2 (`saas_multitenancy_handover.md`): this is a + # BACKGROUND path, not a request handler — it is invoked from the email + # scheduler's shared new-mail hook (`routes/email/scheduler_hooks.py:: + # process_new_mail`), which the background sync loop, the Graph webhook and + # the manual-sync route all fan into, and it runs under no member's request + # context. The runbook's rule for that category is "do not let a job + # inherit an ambient tenant", so it stays on the unbound `get_db()` until + # H4 threads an explicit tenant through — the natural source is the + # mailbox's own row (`email_accounts.user_id` → `app_user.organization_id`, + # i.e. `tenant_session(org_id)`). Named in `test_db_engine_seam.py`'s + # H2_EXEMPT_FILES. db = await _get_db() try: account = await _load_account(db, account_id) diff --git a/apps/services/gateway/gateway/routes/crm/broker_handlers.py b/apps/services/gateway/gateway/routes/crm/broker_handlers.py index 0b8987657..914d567e4 100644 --- a/apps/services/gateway/gateway/routes/crm/broker_handlers.py +++ b/apps/services/gateway/gateway/routes/crm/broker_handlers.py @@ -188,6 +188,15 @@ async def _handle_crm_zoho_write(proposal: Any) -> dict[str, Any]: ) return result + # H4/H6, DELIBERATELY NOT H2 (`saas_multitenancy_handover.md`): this is an + # approval-time BROKER HANDLER acting as the service identity + # `crm:zoho-sync`, not a member's request — an operator may approve the + # queued proposal months after it was enqueued, from a context whose + # ambient tenant (if any) is the approver's, not the record's. The tenant + # source H4 must thread through is the PROPOSAL PAYLOAD (it already carries + # `table` + `native_id`, whose row carries `organization_id`), i.e. + # `tenant_session(org_id)` — never the ambient binding. Named in + # `test_db_engine_seam.py`'s H2_EXEMPT_FILES. db = await _get_db() try: await apply_push_result( diff --git a/apps/services/gateway/gateway/routes/crm/core.py b/apps/services/gateway/gateway/routes/crm/core.py index 0f9c71c08..e2f87f2c5 100644 --- a/apps/services/gateway/gateway/routes/crm/core.py +++ b/apps/services/gateway/gateway/routes/crm/core.py @@ -41,7 +41,22 @@ from acb_auth import require_feature_router from acb_common import get_logger from fastapi import APIRouter, HTTPException -from gateway.db import get_db as _get_db # noqa: F401 — the shared seam (D-CRM-4) +# The shared seam (BO-10 / D-CRM-4 → MT-1c/H2). `_tenant_session` IS +# `acb_common.db.tenant_session`, aliased per-package for the same reason +# `_get_db` was: every request-handler submodule imports it from here BY NAME, +# which is the seam `tests/unit/_crm_fakes.bind_db` patches per module. The +# tenant comes from the request context — bound centrally in +# `_with_resolved_access` — so no call site passes one (H2). A call outside a +# bound request raises `TenantUnbound` rather than defaulting: fail closed, +# never "the usual org". +# +# `_get_db` stays exported for the package's three NON-request leaves only — +# `sync_zoho` (the scheduled sync engine), `auto_lead` (the email scheduler's +# hook) and `broker_handlers` (the approval-time push handler). Those run +# outside a member's request, so inheriting the ambient tenant is exactly what +# H4 forbids; they stay unbound until H4 threads an explicit tenant through. +from gateway.db import get_db as _get_db # noqa: F401 +from gateway.db import tenant_session as _tenant_session # noqa: F401 from pydantic import BaseModel from sqlalchemy import text diff --git a/apps/services/gateway/gateway/routes/crm/deal_contacts.py b/apps/services/gateway/gateway/routes/crm/deal_contacts.py index cb657b083..ab3019b62 100644 --- a/apps/services/gateway/gateway/routes/crm/deal_contacts.py +++ b/apps/services/gateway/gateway/routes/crm/deal_contacts.py @@ -27,7 +27,7 @@ CONTACTS, DEALS, ContactModel, - _get_db, + _tenant_session, link_deal_contact, require_row, router, @@ -68,8 +68,7 @@ async def list_deal_contacts( panel prints a name, a title and a phone number, and a payload of ids would make it issue one request per person to render a card. """ - db = await _get_db() - try: + async with _tenant_session() as db: await require_row(db, DEALS.table, deal_id, DEALS.label) rows = (await db.execute( text( @@ -89,8 +88,6 @@ async def list_deal_contacts( ) for row in rows ]) - finally: - await db.close() @router.post("/deals/{deal_id}/contacts", status_code=201) @@ -104,8 +101,7 @@ async def add_deal_contact( transaction (``core.link_deal_contact``) — the whole point of the endpoint beyond the row it writes. """ - db = await _get_db() - try: + async with _tenant_session() as db: await require_row(db, DEALS.table, deal_id, DEALS.label) contact = await require_row( db, CONTACTS.table, payload.contact_id, CONTACTS.label, @@ -114,14 +110,11 @@ async def add_deal_contact( db, deal_id, str(contact.id), role=payload.role, is_primary=payload.is_primary, ) - await db.commit() return DealContact( contact=row_to_dict(contact, ContactModel), role=getattr(link, "role", None), is_primary=bool(getattr(link, "is_primary", False)), ) - finally: - await db.close() @router.delete("/deals/{deal_id}/contacts/{contact_id}") @@ -135,8 +128,7 @@ async def remove_deal_contact( from this deal" answered OK while Priya is still on it is the response that makes the UI re-render her and look broken. """ - db = await _get_db() - try: + async with _tenant_session() as db: await require_row(db, DEALS.table, deal_id, DEALS.label) existing = (await db.execute( text( @@ -158,7 +150,6 @@ async def remove_deal_contact( ), {"deal_id": deal_id, "contact_id": contact_id}, ) - await db.commit() # A deal whose primary contact was just removed has none. Saying so # lets the sheet prompt for a new one instead of silently showing a # deal that nobody is the contact for. @@ -167,8 +158,6 @@ async def remove_deal_contact( "deal_id": deal_id, "was_primary": bool(getattr(existing, "is_primary", False)), } - finally: - await db.close() __all__ = ["DealContact", "DealContactIn", "DealContactsResponse"] diff --git a/apps/services/gateway/gateway/routes/crm/import_zoho.py b/apps/services/gateway/gateway/routes/crm/import_zoho.py index 989717517..d8a09ccf4 100644 --- a/apps/services/gateway/gateway/routes/crm/import_zoho.py +++ b/apps/services/gateway/gateway/routes/crm/import_zoho.py @@ -42,7 +42,7 @@ LEADS, ORGANIZATIONS, Entity, - _get_db, + _tenant_session, actor, bump_last_activity, compute_lead_name, @@ -806,8 +806,7 @@ async def import_from_zoho( report.modules[module] = ModuleReport(fetched=len(records)) return report - db = await _get_db() - try: + async with _tenant_session() as db: for module in ALL_MODULES: # The backfill has no cursor to advance, so the watermark half of # the pass is discarded here. @@ -815,9 +814,6 @@ async def import_from_zoho( db, module, fetched[module], owners=owners, fallback_owner=who, report=report, )).report - await db.commit() - finally: - await db.close() _log.info( "crm.import.completed", diff --git a/apps/services/gateway/gateway/routes/crm/pipeline.py b/apps/services/gateway/gateway/routes/crm/pipeline.py index 85544b345..876fcd1ad 100644 --- a/apps/services/gateway/gateway/routes/crm/pipeline.py +++ b/apps/services/gateway/gateway/routes/crm/pipeline.py @@ -33,7 +33,7 @@ DealModel, Entity, StatusModel, - _get_db, + _tenant_session, actor, has_column, insert_row, @@ -219,8 +219,7 @@ async def get_pipeline( of rows returned, or the header would lie about a lane with more than ``per_lane`` deals in it. """ - db = await _get_db() - try: + async with _tenant_session() as db: lanes = (await db.execute( text("SELECT * FROM crm_deal_statuses ORDER BY position, name"), {}, )).fetchall() @@ -276,8 +275,6 @@ async def get_pipeline( ), )) return PipelineResponse(lanes=out) - finally: - await db.close() # ── Lead → deal conversion ────────────────────────────────────────────────── @@ -325,8 +322,7 @@ async def convert_lead( """ body = body or ConvertRequest() who = actor(user) - db = await _get_db() - try: + async with _tenant_session() as db: lead = await require_row(db, LEADS.table, lead_id, "Lead") if getattr(lead, "converted_deal_id", None): raise HTTPException( @@ -338,7 +334,6 @@ async def convert_lead( organization = await _resolve_organization(db, lead, body) deal = await _create_deal(db, lead, body, contact, organization, who) lead = await _stamp_converted(db, lead, contact, organization, deal, who) - await db.commit() return ConvertResponse( lead=row_to_dict(lead, LEADS.model), @@ -352,8 +347,6 @@ async def convert_lead( ), deal=row_to_dict(deal, DealModel), ) - finally: - await db.close() async def _resolve_contact(db: Any, lead: Any, body: ConvertRequest) -> Any | None: diff --git a/apps/services/gateway/gateway/routes/crm/records.py b/apps/services/gateway/gateway/routes/crm/records.py index 2a73d00d4..c9524727a 100644 --- a/apps/services/gateway/gateway/routes/crm/records.py +++ b/apps/services/gateway/gateway/routes/crm/records.py @@ -40,7 +40,7 @@ LeadIn, ListResponse, OrganizationIn, - _get_db, + _tenant_session, actor, clean_payload, compute_lead_name, @@ -126,20 +126,14 @@ async def _list(entity: Entity, params: ListParams) -> ListResponse: status_id=params.status_id, owner=params.owner, source=params.source, extra_where=extra, ) - db = await _get_db() - try: + async with _tenant_session() as db: return await run_list(db, entity, query) - finally: - await db.close() async def _get(entity: Entity, record_id: str) -> dict: - db = await _get_db() - try: + async with _tenant_session() as db: row = await require_row(db, entity.table, record_id, entity.label) return row_to_dict(row, entity.model) - finally: - await db.close() async def _resolve_status( @@ -203,14 +197,10 @@ async def create_record( # have nothing to match. values.setdefault("owner_email", actor(user)) - db = await _get_db() - try: + async with _tenant_session() as db: await _resolve_status(db, entity, values) row = await insert_row(db, entity.table, values) - await db.commit() return row_to_dict(row, entity.model) - finally: - await db.close() async def patch_record( @@ -225,8 +215,7 @@ async def patch_record( """ values = clean_payload(payload) validate_source(values) - db = await _get_db() - try: + async with _tenant_session() as db: record = await require_row(db, entity.table, record_id, entity.label) wanted = values.get("status_id") if wanted and str(getattr(record, "status_id", "") or "") != str(wanted): @@ -239,10 +228,7 @@ async def patch_record( if not values: return row_to_dict(record, entity.model) row = await update_row(db, entity.table, record_id, values) - await db.commit() return row_to_dict(row, entity.model) - finally: - await db.close() #: The lead fields the display name is derived from. Touching any of them may @@ -294,8 +280,7 @@ async def delete_record( deletion upstream no longer exists anywhere, and a delete that rolls back must not leave a tombstone that would delete a live Zoho record. """ - db = await _get_db() - try: + async with _tenant_session() as db: record = await require_row(db, entity.table, record_id, entity.label) cascaded: dict[str, int] = {} for table, column in entity.cascades: @@ -307,13 +292,10 @@ async def delete_record( text(f"DELETE FROM {entity.table} WHERE id = CAST(:id AS uuid)"), {"id": record_id}, ) - await db.commit() return DeleteResponse( deleted=record_id, entity=entity.slug, cascaded=cascaded, zoho_delete_queued=queued, ) - finally: - await db.close() # ── Leads ─────────────────────────────────────────────────────────────────── diff --git a/apps/services/gateway/gateway/routes/crm/reports.py b/apps/services/gateway/gateway/routes/crm/reports.py index f408f7377..cfa70c9c0 100644 --- a/apps/services/gateway/gateway/routes/crm/reports.py +++ b/apps/services/gateway/gateway/routes/crm/reports.py @@ -73,7 +73,7 @@ WEIGHTED_SQL, WEIGHTED_TYPES, StatusModel, - _get_db, + _tenant_session, now, router, status_wire, @@ -303,8 +303,7 @@ async def pipeline_report( stage's prior is how a pipeline number stays high while the quarter empties (``core.WEIGHTED_TYPES``). """ - db = await _get_db() - try: + async with _tenant_session() as db: stages: list[StageTotals] = [] for lane in await _deal_stages(db): if getattr(lane, "type", "open") not in WEIGHTED_TYPES: @@ -322,8 +321,6 @@ async def pipeline_report( amount=sum(s.amount for s in stages), weighted=sum(s.weighted for s in stages), ) - finally: - await db.close() # ── 2 · Funnel ────────────────────────────────────────────────────────────── @@ -338,8 +335,7 @@ async def funnel_report( three numbers are each defined against what the log ACTUALLY records, and the naive reading of each one is wrong in a way that looks right. """ - db = await _get_db() - try: + async with _tenant_session() as db: lanes = await _deal_stages(db) names = {str(lane.id): lane.name for lane in lanes} # `position` per stage NAME — the log's join key. A duplicate name @@ -377,8 +373,6 @@ async def funnel_report( transitions=len(owned), unmatched=_unmatched(owned, set(positions)), ) - finally: - await db.close() def _visited_sets(deals: list[Any], names: dict[str, str]) -> dict[str, set[str]]: @@ -525,8 +519,7 @@ async def win_loss_report( INCLUSIVE: ``closed_at`` is an instant, so the endpoints are a measure-zero set, and stating the rule beats leaving it to be inferred from an operator. """ - db = await _get_db() - try: + async with _tenant_session() as db: lanes = await _deal_stages(db) types = {str(lane.id): getattr(lane, "type", "open") for lane in lanes} until = now() @@ -558,8 +551,6 @@ async def win_loss_report( closed_without_date=await _undated_closed(db, lanes), lost_reasons=await _lost_breakdown(db, lost), ) - finally: - await db.close() def _amount(deals: list[Any]) -> float: @@ -657,8 +648,7 @@ async def owner_leaderboard( leaderboard over who is carrying what, not a permission surface, and every ``feature:crm`` holder sees all of it. """ - db = await _get_db() - try: + async with _tenant_session() as db: lanes = await _deal_stages(db) ranked, omitted = await _owners(db) until = now() @@ -671,8 +661,6 @@ async def owner_leaderboard( return OwnerLeaderboard( owners=rows, window_days=WINDOW_DAYS, omitted=omitted, ) - finally: - await db.close() async def _owners(db: Any) -> tuple[list[str | None], int]: diff --git a/apps/services/gateway/gateway/routes/crm/stage_metadata.py b/apps/services/gateway/gateway/routes/crm/stage_metadata.py index 39cd6adda..1dad3ee63 100644 --- a/apps/services/gateway/gateway/routes/crm/stage_metadata.py +++ b/apps/services/gateway/gateway/routes/crm/stage_metadata.py @@ -48,7 +48,7 @@ from gateway.routes.crm.core import ( CLOSING_TYPES, STATUS_TYPES, - _get_db, + _tenant_session, actor, insert_row, router, @@ -634,8 +634,7 @@ async def import_zoho_stages( f"({report.layout_name or report.layout_id})." ) - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text(f"SELECT * FROM {STATUS_TABLE} ORDER BY position, name"), {}, )).fetchall() @@ -656,10 +655,6 @@ async def import_zoho_stages( # never print a count for a computation that did not happen. backfill = await backfill_closed_at(db, plan.types_after, apply=apply) report.closed_at = backfill - if apply: - await db.commit() - finally: - await db.close() _log.info( "crm.stages.repair", diff --git a/apps/services/gateway/gateway/routes/crm/sync_zoho.py b/apps/services/gateway/gateway/routes/crm/sync_zoho.py index cc8d2acd0..338f2be00 100644 --- a/apps/services/gateway/gateway/routes/crm/sync_zoho.py +++ b/apps/services/gateway/gateway/routes/crm/sync_zoho.py @@ -1149,6 +1149,17 @@ async def _run_cycle_locked(actor_email: str) -> SyncCycleReport: owners = {} report.errors.append(f"owner mapping unavailable: {str(exc)[:200]}") + # H4, DELIBERATELY NOT H2 (`saas_multitenancy_handover.md`): one cycle is + # a BACKGROUND engine run — the scheduled loop below fires it with no + # request in flight, and the `POST /crm/sync/zoho` hand-run shares this + # exact code path, acting as the service identity `crm:zoho-sync` (an + # admin *starts* a cycle; the cycle does not act as them). It also commits + # per phase and per record, which `tenant_session()`'s one-transaction + # contract cannot express. The runbook's rule for the category is "do not + # let a job inherit an ambient tenant": it stays on the unbound `get_db()` + # until H4 threads an explicit tenant through the sync configuration + # (`tenant_session(org_id)` per phase). Named in `test_db_engine_seam.py`'s + # H2_EXEMPT_FILES. db = await _get_db() try: # ONE snapshot, before anything moves. `pull_phase` writes cursors as it diff --git a/apps/services/gateway/gateway/routes/people/core.py b/apps/services/gateway/gateway/routes/people/core.py index 1fef6550e..867560f08 100644 --- a/apps/services/gateway/gateway/routes/people/core.py +++ b/apps/services/gateway/gateway/routes/people/core.py @@ -18,6 +18,15 @@ from acb_auth import require_feature_router from acb_common import get_logger from fastapi import APIRouter + +# The shared seam (BO-10 → MT-1c/H2). `_tenant_session` IS +# `acb_common.db.tenant_session`, aliased here for the same reason every +# converted package aliases it: submodules import it from this module BY NAME, +# which is the seam the hermetic tests patch per module. The tenant comes from +# the request context — bound centrally in `_with_resolved_access` — so no +# call site passes one (H2). A call outside a bound request raises +# `TenantUnbound` rather than defaulting: fail closed, never "the usual org". +from gateway.db import tenant_session as _tenant_session # noqa: F401 from gateway.routes.tasks.core import ( # noqa: F401 — re-exports PEOPLE_STATUSES, can_manage_people, @@ -33,13 +42,6 @@ ) -async def _get_db() -> Any: - """The shared engine seam (BO-10) — never ``create_async_engine`` here.""" - from gateway.db import get_session - - return await get_session() - - #: Statuses the directory understands — the SAME tuple the write routes #: validate against, not a copy of it. Listed so a bad filter value is an empty #: result rather than an error, and so the UI can render the pill set (and the diff --git a/apps/services/gateway/gateway/routes/people/directory.py b/apps/services/gateway/gateway/routes/people/directory.py index 391f1ed9a..ceac6a713 100644 --- a/apps/services/gateway/gateway/routes/people/directory.py +++ b/apps/services/gateway/gateway/routes/people/directory.py @@ -16,7 +16,7 @@ from fastapi import Depends, HTTPException from gateway.routes.people.core import ( STATUSES, - _get_db, + _tenant_session, can_manage_people, can_read_hr_fields, has_login, @@ -145,8 +145,7 @@ async def list_directory( skill=skill, has_capacity=has_capacity, ) - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text( "SELECT * FROM gtd_people WHERE " + " AND ".join(clauses) @@ -155,8 +154,6 @@ async def list_directory( params, )).fetchall() people = [_row_to_person(r, include_hr=hr).model_dump() for r in rows] - finally: - await db.close() return DirectoryResponse(rows=people, total=len(people), hr_visible=hr, can_manage=can_manage_people(user)) @@ -169,8 +166,7 @@ async def list_facets(user: UserContext = Depends(get_current_user)) -> dict: stale the first time the org changes — the same call ``/projects/my/contexts`` made for GTD contexts. """ - db = await _get_db() - try: + async with _tenant_session() as db: rows = (await db.execute( text( "SELECT department, team, count(*) AS total FROM gtd_people " @@ -178,8 +174,6 @@ async def list_facets(user: UserContext = Depends(get_current_user)) -> dict: "ORDER BY department, team" ), )).fetchall() - finally: - await db.close() departments: dict[str, int] = {} teams: list[dict[str, Any]] = [] for row in rows: @@ -205,8 +199,7 @@ async def get_person( the *shape* of the answer, not whether one comes back. """ hr = can_read_hr_fields(user) - db = await _get_db() - try: + async with _tenant_session() as db: row = (await db.execute( text("SELECT * FROM gtd_people WHERE id = CAST(:id AS uuid)"), {"id": person_id}, @@ -223,8 +216,6 @@ async def get_person( person["load"] = ( await compute_load(db, getattr(row, "email", None)) if hr else None ) - finally: - await db.close() person["hr_visible"] = hr # Independent of `hr_visible` on purpose. The two permissions are separate # grants, and an admin who may edit a record but not read its HR half is a @@ -301,8 +292,7 @@ async def get_person_work( if not user.has_permission("feature:projects"): return WorkResponse(rows=[], total=0, available=False) - db = await _get_db() - try: + async with _tenant_session() as db: person = (await db.execute( text("SELECT email FROM gtd_people WHERE id = CAST(:id AS uuid)"), {"id": person_id}, @@ -339,8 +329,6 @@ async def get_person_work( ), params, )).fetchall() - finally: - await db.close() items = [ { diff --git a/tests/unit/_admin_fakes.py b/tests/unit/_admin_fakes.py index a4cb331df..3236a6a6f 100644 --- a/tests/unit/_admin_fakes.py +++ b/tests/unit/_admin_fakes.py @@ -15,6 +15,7 @@ from __future__ import annotations import re +from contextlib import asynccontextmanager from types import SimpleNamespace from typing import Any, ClassVar @@ -656,14 +657,25 @@ def bind_admin_db(monkeypatch: Any, fake: _FakeDB, modules: tuple[Any, ...]) -> """Point each admin submodule's DB / cache / audit seams at ``fake``. Per-module and not per-package because the routes import the seams by name - (``from _common import get_db``), so patching ``_common`` alone would not - reach them. + (``from _common import _tenant_session``), so patching ``_common`` alone + would not reach them. + + H2 note: the real seam is ``acb_common.db.tenant_session`` — an async + context manager that begins a transaction, issues ``SET LOCAL + app.tenant_id`` and commits on clean exit. The fake mirrors the SHAPE plus + commit-on-clean-exit, so "one transaction, committed once" stays an + OBSERVABLE fact (``db.committed == 1``) and a refusal that raises + mid-block commits nothing here just as it commits nothing against + Postgres. GUC plumbing stays out of the mirror — ``test_tenant_session.py`` + owns that. """ - async def _get_db() -> _FakeDB: - return fake + @asynccontextmanager + async def _tenant_session(organization_id: str | None = None) -> Any: + yield fake + await fake.commit() for module in modules: - monkeypatch.setattr(module, "get_db", _get_db) + monkeypatch.setattr(module, "_tenant_session", _tenant_session) monkeypatch.setattr( module, "invalidate_for", lambda *e: fake.invalidated.extend(x for x in e if x), diff --git a/tests/unit/_crm_fakes.py b/tests/unit/_crm_fakes.py index 6d15dd178..4da1fa579 100644 --- a/tests/unit/_crm_fakes.py +++ b/tests/unit/_crm_fakes.py @@ -7,7 +7,8 @@ Not named ``test_*``, so pytest imports it without collecting it. Convention: the route functions are called directly as async functions with -``core._get_db`` monkeypatched, so nothing here touches Postgres and no +``core._tenant_session`` (H2) monkeypatched — and ``core._get_db`` for the H4 +leaves that stay on the unbound seam — so nothing here touches Postgres and no TestClient is started. Same shape as ``test_tasks_people_scoping.py``. ⚠️ **This is a MIRROR, and a mirror can only agree with itself.** It reads the @@ -34,6 +35,7 @@ import json import re +from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta from types import SimpleNamespace from typing import Any @@ -1025,13 +1027,34 @@ def crm_user(email: str = "vjvarada@fracktal.in", *, features: str = "*") -> Any def bind_db(monkeypatch: Any, fake: FakeCrmDB, modules: tuple[Any, ...]) -> None: - """Point each CRM submodule's ``_get_db`` seam at ``fake``. + """Point each CRM submodule's DB seam at ``fake``. - Per-module and not per-package because each module imports ``_get_db`` from + Per-module and not per-package because each module imports its seam from ``core`` by name, so patching ``core`` alone would not reach them. + + H2 note: the request-handler modules now acquire sessions through + ``_tenant_session`` — an async context manager that begins a transaction, + issues ``SET LOCAL app.tenant_id`` and commits on clean exit. The fake + mirrors only the SHAPE (``async with … as db``) plus commit-on-clean-exit, + so "these writes share one transaction" stays an OBSERVABLE fact + (``db.committed == 1``) and a handler that raises mid-block commits + nothing here just as it commits nothing against Postgres. Transaction/GUC + plumbing stays out of the mirror — ``test_tenant_session.py`` owns that. + + The three H4 leaves (``sync_zoho``, ``auto_lead``, ``broker_handlers``) + deliberately stay on the unbound ``_get_db`` seam, so both names are + patched wherever a module carries them. """ + @asynccontextmanager + async def _tenant_session(organization_id: str | None = None) -> Any: + yield fake + await fake.commit() + async def _get_db() -> FakeCrmDB: return fake for module in modules: - monkeypatch.setattr(module, "_get_db", _get_db) + if hasattr(module, "_tenant_session"): + monkeypatch.setattr(module, "_tenant_session", _tenant_session) + if hasattr(module, "_get_db"): + monkeypatch.setattr(module, "_get_db", _get_db) diff --git a/tests/unit/test_admin_groups.py b/tests/unit/test_admin_groups.py index ffb1d5d22..937b546d2 100644 --- a/tests/unit/test_admin_groups.py +++ b/tests/unit/test_admin_groups.py @@ -255,10 +255,16 @@ async def execute( # noqa: C901 — one branch per SQL statement, by design def db(monkeypatch: pytest.MonkeyPatch) -> _FakeDB: fake = _FakeDB() - async def _get_db() -> _FakeDB: - return fake + from contextlib import asynccontextmanager - monkeypatch.setattr(groups, "get_db", _get_db) + @asynccontextmanager + async def _tenant_session(organization_id=None): + # Commit-on-clean-exit, like the real `tenant_session` wrapper (H2) — + # a refusal that raises mid-block commits nothing. + yield fake + await fake.commit() + + monkeypatch.setattr(groups, "_tenant_session", _tenant_session) # Cache invalidation reaches into acb_auth's resolver cache; keep the # tests hermetic (and assert it fires for access-affecting writes). fake.invalidated: list[str] = [] diff --git a/tests/unit/test_admin_member_purge.py b/tests/unit/test_admin_member_purge.py index 4d4e1526e..4a3055f04 100644 --- a/tests/unit/test_admin_member_purge.py +++ b/tests/unit/test_admin_member_purge.py @@ -913,19 +913,28 @@ async def test_the_whole_purge_is_one_transaction(db: _FakeDB) -> None: def test_the_route_holds_exactly_one_commit() -> None: """The structural half of the test above. - A single ``commit()`` reached twelve times in a loop would also count 12, - but a purge restructured into "commit per table" could conceivably reach - ``db.committed == 1`` in a fake that only models one session. Source is - the direct evidence, and it also pins that the commit is the LAST write — - everything destructive happens before it, inside the transaction. + Since H2 the one commit is ``_tenant_session``'s, issued on clean exit of + the ``async with`` block — so the structural facts to pin are that the + route opens exactly ONE tenant-bound block, writes no commit of its own + (a hand-rolled ``db.commit()`` inside the block would END the transaction + early and drop the tenant GUC for everything after it), and keeps every + destructive statement INSIDE the block, before that commit. """ from gateway.routes.admin.members import purge_member src = inspect.getsource(purge_member) - assert src.count("await db.commit()") == 1 - assert src.index("await db.commit()") > src.rindex("rows.delete_sql"), ( - "something is deleted after the transaction is committed" + assert src.count("async with _tenant_session() as db:") == 1 + assert src.count("await db.commit()") == 0, ( + "a mid-block commit would end the transaction (and the tenant " + "binding) before the wrapper's own commit" ) + block_indent = " " * 8 + after_block = src[src.index("async with _tenant_session() as db:"):] + for line in after_block.splitlines()[1:]: + if "rows.delete_sql" in line: + assert line.startswith(block_indent), ( + "something is deleted outside the one transaction" + ) async def test_the_purge_is_audited_before_it_is_committed( diff --git a/tests/unit/test_admin_tenancy.py b/tests/unit/test_admin_tenancy.py index 16091a246..133dc5757 100644 --- a/tests/unit/test_admin_tenancy.py +++ b/tests/unit/test_admin_tenancy.py @@ -101,10 +101,14 @@ def db(monkeypatch: pytest.MonkeyPatch) -> _FakeDB: bind_admin_db(monkeypatch, fake, MODULES) # `/auth/me` is a read: it has the DB seam and neither of the write seams. - async def _get_db() -> _FakeDB: - return fake + from contextlib import asynccontextmanager - monkeypatch.setattr(me, "get_db", _get_db) + @asynccontextmanager + async def _tenant_session(organization_id=None): + yield fake + await fake.commit() + + monkeypatch.setattr(me, "_tenant_session", _tenant_session) fake.seed_organization(ORG, "alpha", "Alpha Industries") fake.seed_organization(ORG_B, "beta", "Beta Consulting") diff --git a/tests/unit/test_crm_reports.py b/tests/unit/test_crm_reports.py index cfed1e11a..fc381d025 100644 --- a/tests/unit/test_crm_reports.py +++ b/tests/unit/test_crm_reports.py @@ -154,7 +154,8 @@ async def test_pipeline_report_never_writes(db: FakeCrmDB) -> None: await crm_reports.funnel_report(user=USER) await crm_reports.win_loss_report(user=USER) await crm_reports.owner_leaderboard(user=USER) - assert db.committed == 0 + # H2: `tenant_session` commits an EMPTY transaction on clean exit even for + # reads, so "writes nothing" is asserted on the statements themselves. for statement in db.statements: assert statement.split(None, 1)[0].upper() == "SELECT", statement diff --git a/tests/unit/test_crm_stage_metadata.py b/tests/unit/test_crm_stage_metadata.py index ea7de0941..818c4fe25 100644 --- a/tests/unit/test_crm_stage_metadata.py +++ b/tests/unit/test_crm_stage_metadata.py @@ -219,8 +219,9 @@ async def test_dry_run_writes_nothing_at_all( assert report.outcome == "dry_run" assert report.applied is False + # H2: `tenant_session` commits an empty transaction on clean exit, so the + # honest "a dry run writes nothing" assertion is the statement list. assert writes(db) == [] - assert db.committed == 0 # …and it still says what it would do. assert report.changed > 0 assert {s.name for s in report.stages} == { diff --git a/tests/unit/test_db_engine_seam.py b/tests/unit/test_db_engine_seam.py index 77e51ba35..5e54fecb2 100644 --- a/tests/unit/test_db_engine_seam.py +++ b/tests/unit/test_db_engine_seam.py @@ -238,6 +238,7 @@ def test_gateway_makes_no_engine_of_its_own() -> None: "gateway.routes.crm.core", "gateway.routes.email.core", "gateway.routes.notes.core", + "gateway.routes.people.core", "gateway.routes.projects.core", "gateway.routes.tasks.core", "gateway.routes.whatsapp.core", @@ -307,11 +308,35 @@ def test_acb_auth_shares_the_pool() -> None: "apps/services/gateway/gateway/routes/projects/agent_dispatch.py": "event consumer, not a request handler — H4 threads an explicit " "tenant through the event payload; ambient inheritance is forbidden", + "apps/services/gateway/gateway/routes/crm/auto_lead.py": + "background job fired from the email scheduler's new-mail hook — H4 " + "threads the mailbox owner's tenant explicitly; ambient inheritance " + "is forbidden", + "apps/services/gateway/gateway/routes/crm/sync_zoho.py": + "scheduled sync engine running as `crm:zoho-sync` with per-phase " + "commits — H4 threads an explicit tenant through the sync " + "configuration; ambient inheritance is forbidden", + "apps/services/gateway/gateway/routes/crm/broker_handlers.py": + "approval-time broker handler running as `crm:zoho-sync` — H4/H6 " + "derives the tenant from the proposal payload's native row, never " + "from the approver's ambient binding", } -#: The unconverted remainder OUTSIDE routes/projects at the time the Projects -#: slice landed (2026-08-10). Lower it as packages convert; never raise it. -H2_BASELINE_ELSEWHERE = 494 +#: The route packages H2 has converted: every session in them is acquired +#: through `tenant_session`, except the named H2_EXEMPT_FILES. +H2_CONVERTED_PACKAGES: tuple[str, ...] = ( + "apps/services/gateway/gateway/routes/projects/", + "apps/services/gateway/gateway/routes/crm/", + "apps/services/gateway/gateway/routes/people/", + "apps/services/gateway/gateway/routes/admin/", +) + +#: The unconverted remainder OUTSIDE routes/projects at the time each slice +#: landed. 494 when the Projects slice froze it (2026-08-10); 439 after the +#: crm/people/admin slice (same day, 55 sites converted — the three crm +#: leaves above stay and still count here). Lower it as packages convert; +#: never raise it. +H2_BASELINE_ELSEWHERE = 439 def _get_db_sites() -> dict[str, int]: @@ -324,24 +349,37 @@ def _get_db_sites() -> dict[str, int]: return out -def test_routes_projects_is_converted_and_stays_converted() -> None: - """The Projects package acquires sessions ONLY through `tenant_session`. +@pytest.mark.parametrize("package", H2_CONVERTED_PACKAGES) +def test_converted_packages_stay_converted(package: str) -> None: + """A converted package acquires sessions ONLY through `tenant_session`. A new `get_db()` here is a handler whose queries will silently return nothing under RLS — the fail-closed symptom H2's runbook warns about. + Only the named H2_EXEMPT_FILES may stay on the unbound seam. """ sites = _get_db_sites() offenders = { f: n for f, n in sites.items() - if f.startswith("apps/services/gateway/gateway/routes/projects/") - and f not in H2_EXEMPT_FILES + if f.startswith(package) and f not in H2_EXEMPT_FILES } assert offenders == {}, ( f"unbound get_db() in converted package: {offenders} — use " - f"`async with _tenant_session() as db:` (core.py) instead" + f"`async with _tenant_session() as db:` (the package's central " + f"module) instead" ) +def test_h2_exempt_files_still_use_the_unbound_seam() -> None: + """An exemption that stopped calling `get_db()` leaves the list. + + Same discipline as the engine allow-lists above: a stale entry is silent + permission for a regression nobody decided on. + """ + sites = _get_db_sites() + stale = sorted(f for f in H2_EXEMPT_FILES if f not in sites) + assert stale == [], f"H2 exemptions with no get_db() call left: {stale}" + + def test_get_db_sites_elsewhere_only_ratchet_down() -> None: total = sum( n for f, n in _get_db_sites().items() diff --git a/tests/unit/test_people_directory.py b/tests/unit/test_people_directory.py index 835c57998..a5532a7ae 100644 --- a/tests/unit/test_people_directory.py +++ b/tests/unit/test_people_directory.py @@ -122,11 +122,17 @@ def db() -> FakeDB: @pytest.fixture(autouse=True) def bind(monkeypatch, db): - async def _get_db(): - return db + from contextlib import asynccontextmanager + + @asynccontextmanager + async def _tenant_session(organization_id=None): + # Commit-on-clean-exit, like the real `tenant_session` wrapper (H2). + yield db + await db.commit() for module in (people_core, people_directory): - monkeypatch.setattr(module, "_get_db", _get_db, raising=False) + monkeypatch.setattr(module, "_tenant_session", _tenant_session, + raising=False) def _user(email: str, *grants: str): diff --git a/tests/unit/test_people_write.py b/tests/unit/test_people_write.py index 45b26beb9..3c64f7365 100644 --- a/tests/unit/test_people_write.py +++ b/tests/unit/test_people_write.py @@ -335,11 +335,16 @@ def test_the_directory_tells_the_caller_whether_they_may_write(monkeypatch): """Read as a whole response, because the flag is only useful if it ships.""" database = FakeDB() - async def _get_db(): - return database + from contextlib import asynccontextmanager + + @asynccontextmanager + async def _tenant_session(organization_id=None): + yield database + await database.commit() for module in (people_core, people_directory): - monkeypatch.setattr(module, "_get_db", _get_db, raising=False) + monkeypatch.setattr(module, "_tenant_session", _tenant_session, + raising=False) for user, expected in ((ADMIN, True), (READER, False)): res = run(people_directory.list_directory(user=user)) @@ -352,11 +357,16 @@ def test_the_person_page_carries_can_manage_independently_of_hr_visible(monkeypa skills strip restricted and the Edit button present.""" database = FakeDB() - async def _get_db(): - return database + from contextlib import asynccontextmanager + + @asynccontextmanager + async def _tenant_session(organization_id=None): + yield database + await database.commit() for module in (people_core, people_directory): - monkeypatch.setattr(module, "_get_db", _get_db, raising=False) + monkeypatch.setattr(module, "_tenant_session", _tenant_session, + raising=False) editor_only = _user("ops@fracktal.in", "feature:people", PEOPLE_WRITE_PERMISSION) person = run(people_directory.get_person(PERSON.id, user=editor_only)) From 938144d24b863f9ec4bca4aac5feaea1f4936853 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 07:48:33 +0000 Subject: [PATCH 09/10] =?UTF-8?q?docs:=20H2=20nearly=20done=20=E2=80=94=20?= =?UTF-8?q?nine=20packages=20converted,=20111=20classified=20sites=20remai?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handover H2 box and WS-29 board row updated to the post-wave state. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../specs/saas_multitenancy_handover.md | 28 +++++++++++++++++++ project-docs/work_plan.md | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/project-docs/specs/saas_multitenancy_handover.md b/project-docs/specs/saas_multitenancy_handover.md index 521a05e33..e62f867f5 100644 --- a/project-docs/specs/saas_multitenancy_handover.md +++ b/project-docs/specs/saas_multitenancy_handover.md @@ -187,6 +187,34 @@ stated values, **and** the baseline test set still passes. ## H2 · Convert 561 session-acquisition sites to `tenant_session()` · 🟢 AGENT-SAFE · **the long pole** +> ### ◐◐ H2 NEARLY DONE 2026-08-10 (same day) — nine packages converted, 111 sites left, ALL classified +> +> Two waves of parallel slice-agents converted the rest of the gateway's request +> handlers on top of the Projects slice below: **notes** (61 converted/33 left), +> **whatsapp** (38/14), **email** (98/27), **tasks** (73/6), **workflows** (26/17), +> **apps** (32/6), **crm** (28/3), **people** (4/0), **admin** (23/0). +> `H2_BASELINE_ELSEWHERE` banked stepwise **494 → 111**, and every one of the 111 +> remaining sites is CLASSIFIED in place: a `# H4:` (background consumer — scheduler, +> pipeline, sink, broker handler, `asyncio.create_task`) or `# H4/H6:` +> (service-identity route — provider webhook, bridge secret, hook token, OAuth +> callback) marker naming the tenant-derivation source for the conversion H4/H6 owns. +> Zero-remainder packages are pinned by the parametrized +> `test_converted_packages_stay_converted` (projects/crm/people/admin + +> `H2_EXEMPT_FILES`); whatsapp and apps hold at exact per-file counts +> (`H2_WHATSAPP_EXEMPT_SITES`, `H2_APPS_EXEMPT_SITES`). +> +> **Live verification (R8):** every wave ended with a real-Postgres smoke — wave 1 +> and wave 2 integration smokes drove one converted read per package under a bound +> GUC on a migrated scratch cluster; the tasks slice additionally proved actual +> FORCE-RLS org-A/org-B row isolation; unbound sessions raise `TenantUnbound` +> everywhere. +> +> **What "H2 done" still needs:** the 111 H4/H6 sites are NOT H2's — they convert +> with explicit tenants in H4 (jobs/consumers) and H6 (service-identity + identity +> cutover). H2's own remaining act is nothing in `apps/` or `packages/` — the +> original done-when ("grep returns 0") is superseded by this classification: the +> grep now returns exactly the named, pinned, owned remainder. + > ### ◐ H2 STARTED 2026-08-10 — central binding SHIPPED + the Projects slice converted > > **The "do this first" step is done:** `_with_resolved_access` (acb_auth/deps.py) calls diff --git a/project-docs/work_plan.md b/project-docs/work_plan.md index 96768d685..8305d207b 100644 --- a/project-docs/work_plan.md +++ b/project-docs/work_plan.md @@ -195,7 +195,7 @@ owning specs are the archive; this file owns ordering, gates and states only. | WS | Workstream | State | Owning spec · record | Gates · next (verified) | |---|---|---|---|---| -| WS-29 | **Multi-tenancy — turning CommandCenter into a product sold to other companies** | ◐ H1 done · H2 next | **`specs/saas_multitenancy.md`** (architecture; §11 tickets) · ⭐ **`specs/saas_multitenancy_handover.md`** (H1→H8 runbook — hand THIS to the executing agent) · `specs/saas_multitenancy_implementation.md` (shapes) · board record 2026-08-09 in the parent spec | **Phase 0 ✅** (MT-0a · 0b · 0c-1 · 0d, pending review) · **H1 ✅ CLOSED**: scratch-verified 2026-08-09, **PR #404 merged and migrations 157/158/159 CONFIRMED on prod the same day** (ledger line "157 already recorded"; box self-applied via pull timer). · MT-1: 1a schema ✅ (identity cutover = H6, open) · 1b generated ✅ · 1c seam + ratchets ✅ — **561 call sites across 138 files unconverted = H2, the long pole** · 1e wrapper ✅ (~58 key sites unconverted = H5) · 1i ✅ (two-org DB fixture owed) · **MT-2/MT-3/MT-4 owner inputs ALL ANSWERED — final pricing shape D23/D24 2026-08-10 (§2.4b Center packages; ladder 600/1200/1800/2400/3000)** — MT-2's scope includes `center_package` + `plan_catalog` + seat `source` + the one-assignment act; spec detailing may start on all three; the customer console is WS-30 · 🔴 MT-0c-2 parked (D16; §6 first blockquote) · §5.1 cutover trigger **ADOPTED 2026-08-09** — owner checks monthly · ⚠️ **#399 (MERGED 2026-08-09) carried a second WS-29 thread** (`specs/multi_tenancy.md`, superseded for architecture — measured record only): migration **161** keys all 17 `pm_*` + a parent-consistency trigger, **162** makes `app_user` unique on `lower(email)`, S1-1 fixed a cross-tenant **write** into access control, S1-4 removed the process-global agent identity, plus a 14-finding leak audit. **It also found a defect in MT-1b:** the generator scoped `crm_contacts`/`crm_deals`/`crm_activities` by column name, but their `organization_id` references `crm_organizations` — phase 2 would have aborted mid-window. Gated at generation time now (`HOMONYM_BLOCKED`); those three tables carry **no isolation** pending a rename — 🔴 owner call. ◐ **H2 STARTED 2026-08-10**: central `bind_tenant` binding + `TenantScopeMiddleware` SHIPPED; `routes/projects` converted (84 sites; `agent_dispatch` H4-exempt by name); ratchets in `test_db_engine_seam.py` (projects=0, elsewhere frozen at 494, bank-your-progress rule). ⚠️ **The first live run found `tenant_session()` itself broken** — `SET LOCAL … = :param` is a Postgres syntax error (SET can't bind); fixed to `set_config(..., true)`, pinned + live-proven. See the handover's H2 box. **Next: convert the remaining 494 sites package-by-package (email/wa/crm/tasks/notes/workflows…), each with one live-Postgres smoke.** (2026-08-10) | +| WS-29 | **Multi-tenancy — turning CommandCenter into a product sold to other companies** | ◐ H1 done · H2 next | **`specs/saas_multitenancy.md`** (architecture; §11 tickets) · ⭐ **`specs/saas_multitenancy_handover.md`** (H1→H8 runbook — hand THIS to the executing agent) · `specs/saas_multitenancy_implementation.md` (shapes) · board record 2026-08-09 in the parent spec | **Phase 0 ✅** (MT-0a · 0b · 0c-1 · 0d, pending review) · **H1 ✅ CLOSED**: scratch-verified 2026-08-09, **PR #404 merged and migrations 157/158/159 CONFIRMED on prod the same day** (ledger line "157 already recorded"; box self-applied via pull timer). · MT-1: 1a schema ✅ (identity cutover = H6, open) · 1b generated ✅ · 1c seam + ratchets ✅ — **561 call sites across 138 files unconverted = H2, the long pole** · 1e wrapper ✅ (~58 key sites unconverted = H5) · 1i ✅ (two-org DB fixture owed) · **MT-2/MT-3/MT-4 owner inputs ALL ANSWERED — final pricing shape D23/D24 2026-08-10 (§2.4b Center packages; ladder 600/1200/1800/2400/3000)** — MT-2's scope includes `center_package` + `plan_catalog` + seat `source` + the one-assignment act; spec detailing may start on all three; the customer console is WS-30 · 🔴 MT-0c-2 parked (D16; §6 first blockquote) · §5.1 cutover trigger **ADOPTED 2026-08-09** — owner checks monthly · ⚠️ **#399 (MERGED 2026-08-09) carried a second WS-29 thread** (`specs/multi_tenancy.md`, superseded for architecture — measured record only): migration **161** keys all 17 `pm_*` + a parent-consistency trigger, **162** makes `app_user` unique on `lower(email)`, S1-1 fixed a cross-tenant **write** into access control, S1-4 removed the process-global agent identity, plus a 14-finding leak audit. **It also found a defect in MT-1b:** the generator scoped `crm_contacts`/`crm_deals`/`crm_activities` by column name, but their `organization_id` references `crm_organizations` — phase 2 would have aborted mid-window. Gated at generation time now (`HOMONYM_BLOCKED`); those three tables carry **no isolation** pending a rename — 🔴 owner call. ◐ **H2 STARTED 2026-08-10**: central `bind_tenant` binding + `TenantScopeMiddleware` SHIPPED; `routes/projects` converted (84 sites; `agent_dispatch` H4-exempt by name); ratchets in `test_db_engine_seam.py` (projects=0, elsewhere frozen at 494, bank-your-progress rule). ⚠️ **The first live run found `tenant_session()` itself broken** — `SET LOCAL … = :param` is a Postgres syntax error (SET can't bind); fixed to `set_config(..., true)`, pinned + live-proven. See the handover's H2 box. **H2 ◐◐ NEARLY DONE same day (two waves of parallel slice-agents):** notes/whatsapp/email/tasks/workflows/apps/crm/people/admin all converted — baseline **494 → 111**, every remaining site classified in place (`# H4:` background / `# H4/H6:` service-identity, tenant source named), zero-remainder packages pinned parametrically, whatsapp/apps pinned at exact counts, live smokes per wave incl. a FORCE-RLS two-org isolation proof. **Next: H4 (explicit tenants for the 111 marked sites' jobs/consumers) and H5 (Redis), then H3.** (2026-08-10) | | — | **Future modules roadmap** (KB · Marketing · Support · dashboards-colour · Builder/Workflows slicing rule) *(named 2026-08-09, D21)* | 🔴 not dispatchable | `specs/future_modules_roadmap.md` | No WS rows until each earns a §1-contract spec; Dashboards colour lands in WS-15's acceptance; the Builder/Workflows visibility-tier-at-creation rule binds reviews now (D12). | | WS-30 | **Subscription Console — customer-facing billing surface** *(minted 2026-08-09)* | 🔴 MT-2 first | **`specs/subscription_console.md`** | Manage-only at launch: **Centers & add-ons panel · users × Centers seat grid (D23)** · credit monitor · seat writes under D19.3's hard cap · role presets (D24.5) · change-request flow (fulfilment 🔴 OWNER-GATE during silo phase). Sequencing: MT-2 tables → SC-1 → SC-2/SC-3 → SC-4 with MT-4. Business inputs answered (D19 + D23 + **D24** — framing closed); blockers: MT-2's substrate and the MT-2/MT-3 ticket contracts. (2026-08-10) | From a51cc611e4ef064e98d735615a09c71cd706593c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 08:12:15 +0000 Subject: [PATCH 10/10] =?UTF-8?q?fix(tenancy):=20adversarial-review=20find?= =?UTF-8?q?ings=20=E2=80=94=20auto-lead=20explicit=20tenant=20+=20mid-bloc?= =?UTF-8?q?k=20commit;=20cross-file=20edge=20ratchet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: auto_lead._create_lead calls the H2-converted create_record from the background mail path, where no ambient tenant exists — every message raised TenantUnbound, _consider counted it failed, and the watermark never advanced (auto-lead dead behind a WARNING). Fixed the H4 way, done early because it was a today-failure: the mailbox owner's organization is resolved from their app_user row on the caller's unbound session and bound explicitly around the create_record call, released after. P1: actions.py passed the enclosing tenant session to _store_ai_draft with the default commit=True — a mid-block commit that ends the transaction and drops the tenant GUC for everything after it (partial commits today; zero-row reads under RLS phase 4). Now commit=False; all three _apply_rule_actions callers hold wrapper sessions. New ratchet: test_exempt_files_do_not_reach_converted_sessions_unreviewed — no H4-exempt file may call a tenant_session-opening function without a reviewed edge in H2_EXEMPT_CALL_EDGES (AST calls, so marker comments can't false-positive). The exemption markers are file-scoped but the call graph is not; this is the mechanical catch for the P0 class, plus a stale-edge companion test. CRM fake taught the app_user lower()=lower() lookup arm; auto-lead tests seed the owner's app_user row. Full suite: 5807 passed. Both findings from the D29 adversarial diff review; neither was visible to any hermetic suite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../gateway/gateway/routes/crm/auto_lead.py | 50 +++++++- .../routes/email/automation/actions.py | 7 +- tests/unit/_crm_fakes.py | 9 ++ tests/unit/test_crm_auto_lead.py | 4 + tests/unit/test_db_engine_seam.py | 113 ++++++++++++++++++ 5 files changed, 177 insertions(+), 6 deletions(-) diff --git a/apps/services/gateway/gateway/routes/crm/auto_lead.py b/apps/services/gateway/gateway/routes/crm/auto_lead.py index f9844e207..fab708923 100644 --- a/apps/services/gateway/gateway/routes/crm/auto_lead.py +++ b/apps/services/gateway/gateway/routes/crm/auto_lead.py @@ -100,6 +100,7 @@ from acb_auth import UserContext, UserRole from acb_common import get_settings +from acb_common.db import bind_tenant, release_tenant from gateway.routes.crm.core import ( LEADS, LeadIn, @@ -789,7 +790,7 @@ async def _consider( stats["skipped_known"] += 1 return False try: - lead = await _create_lead(account, address, display_name) + lead = await _create_lead(db, account, address, display_name) except Exception as exc: stats["errors"] += 1 _log.warning("crm.auto_lead_message_failed", @@ -818,7 +819,7 @@ async def _consider( async def _create_lead( - account: Any, address: str, display_name: str, + db: Any, account: Any, address: str, display_name: str, ) -> dict[str, Any]: """Create the lead through the CRM's own service write path. @@ -832,6 +833,17 @@ async def _create_lead( ``POST /crm/leads`` calls), which is also why the caller counts a created lead before it writes the activity: by the time this returns, the row is committed and queued for Zoho. + + ⚠️ H4, done early because it was a today-failure: ``create_record`` is H2- + converted (``_tenant_session``), and THIS caller runs on the background + mail path with no request and no ambient tenant. Left alone it raised + ``TenantUnbound`` on every message, ``_consider`` counted it failed, and + the watermark never advanced — auto-lead dead, wearing a WARNING. The + tenant is therefore bound EXPLICITLY here from the mailbox owner's + ``app_user`` row (the source this file's H4 marker names), scoped to + exactly this call — never inherited (the runbook's rule for jobs). An + owner with no organization fails this one message the same way any other + ``_consider`` error does, with the org named in the log. """ first_name, last_name = _split_display_name(display_name) # Absent, never explicitly null: ``clean_payload`` is ``exclude_unset``, so @@ -844,9 +856,37 @@ async def _create_lead( fields["first_name"] = first_name if last_name: fields["last_name"] = last_name - return await create_record( - LEADS, LeadIn(**fields), _principal(str(account.user_id)), - ) + owner = str(account.user_id) + token = bind_tenant(await _owner_organization(db, owner)) + try: + return await create_record( + LEADS, LeadIn(**fields), _principal(owner), + ) + finally: + release_tenant(token) + + +async def _owner_organization(db: Any, owner_email: str) -> str: + """The mailbox owner's organization id, from their ``app_user`` row. + + Reads on the CALLER's (unbound, background) session: this runs before any + tenant is bound — it is what DECIDES the tenant, exactly like identity + resolution on the request path. Raises rather than returning None — a + mailbox whose owner has no organization must fail its message loudly, not + write a lead nowhere. + """ + row = (await db.execute( + text("SELECT organization_id FROM app_user " + "WHERE lower(email) = lower(:e)"), + {"e": owner_email}, + )).fetchone() + org = getattr(row, "organization_id", None) if row is not None else None + if not org: + raise RuntimeError( + f"auto-lead: mailbox owner {owner_email!r} has no app_user " + f"organization — cannot choose a tenant for the lead" + ) + return str(org) async def _log_origin_activity( diff --git a/apps/services/gateway/gateway/routes/email/automation/actions.py b/apps/services/gateway/gateway/routes/email/automation/actions.py index 7c0c9c16b..eab865e41 100644 --- a/apps/services/gateway/gateway/routes/email/automation/actions.py +++ b/apps/services/gateway/gateway/routes/email/automation/actions.py @@ -451,9 +451,14 @@ async def _apply_rule_actions( subject=subj, body=body, ) # AI-written (non-template) drafts: remember for edit-learning. + # commit=False: every caller of _apply_rule_actions holds a + # _tenant_session — a mid-block commit would end that + # transaction and drop the tenant GUC for everything after it + # (H2; found by the adversarial review, not the suites). if not tmpl and account_id: await _store_ai_draft( - db, account_id, email.get("thread_id") or "", body) + db, account_id, email.get("thread_id") or "", body, + commit=False) elif t == "FORWARD" and a.get("to_address"): note = await _render_template( (a.get("content") or "").strip(), email) diff --git a/tests/unit/_crm_fakes.py b/tests/unit/_crm_fakes.py index 4da1fa579..5bee71308 100644 --- a/tests/unit/_crm_fakes.py +++ b/tests/unit/_crm_fakes.py @@ -528,6 +528,15 @@ async def execute(self, sql: Any, params: dict | None = None) -> _Result: raise RuntimeError( f"fake driver error on statement containing {entry[0]!r}" ) + # The auto-lead H4 fix's owner-organization lookup: a `lower() = + # lower()` WHERE the generic reader rightly refuses to guess at. + # Statement-keyed like every special arm, R10 honoured on both sides. + if "FROM app_user" in statement and "lower(email)" in statement: + wanted = str(args.get("e") or "").lower() + return _Result([ + SimpleNamespace(**r) for r in self.rows("app_user") + if str(r.get("email") or "").lower() == wanted + ]) head = statement.split(None, 1)[0].upper() table = _table(statement) if head == "INSERT": diff --git a/tests/unit/test_crm_auto_lead.py b/tests/unit/test_crm_auto_lead.py index 3e0b29041..533d42c48 100644 --- a/tests/unit/test_crm_auto_lead.py +++ b/tests/unit/test_crm_auto_lead.py @@ -194,6 +194,10 @@ def _seed_account( ) -> None: db.seed("email_accounts", id=ACCOUNT_ID, user_id=owner, email_address=address) + # H4 fix: _create_lead binds the owner's organization explicitly before + # calling the converted create_record — the lookup reads app_user. + db.seed("app_user", email=owner, + organization_id="99999999-9999-9999-9999-999999999999") if org_domains is not None: db.seed("email_assistant_settings", account_id=ACCOUNT_ID, org_domains=org_domains) diff --git a/tests/unit/test_db_engine_seam.py b/tests/unit/test_db_engine_seam.py index 53e53e3ca..37e956d57 100644 --- a/tests/unit/test_db_engine_seam.py +++ b/tests/unit/test_db_engine_seam.py @@ -481,6 +481,119 @@ def test_h2_exempt_files_still_use_the_unbound_seam() -> None: assert stale == [], f"H2 exemptions with no get_db() call left: {stale}" +#: Cross-file edges from an H4-exempt (background/service) file into a +#: converted function that opens `_tenant_session()`. ⚠️ **Every entry here is +#: a reviewed decision that the callee is safe WITHOUT an ambient request +#: binding** — because the caller binds an explicit tenant around the call, or +#: the callee takes one. An UNLISTED edge is the auto-lead defect class: the +#: exempt file runs with no tenant bound, the converted callee raises +#: `TenantUnbound` at runtime, and no file-scoped ratchet or hermetic fake can +#: see it (the fakes yield unconditionally). Found by the H2 adversarial +#: review — the exemption markers are file-scoped, but the call graph is not. +H2_EXEMPT_CALL_EDGES: dict[tuple[str, str], str] = { + ("apps/services/gateway/gateway/routes/crm/auto_lead.py", "create_record"): + "_create_lead binds the mailbox owner's organization explicitly " + "(bind_tenant around the call — the H4 shape, done early because the " + "unbound call was a today-failure that stalled the watermark)", +} + + +def _h4_files() -> set[str]: + return ( + set(H2_EXEMPT_FILES) + | set(H2_WHATSAPP_EXEMPT_SITES) + | set(H2_APPS_EXEMPT_SITES) + ) + + +def _tenant_session_functions(module_rel: str) -> frozenset[str]: + """Names of functions in *module_rel* whose body CALLS `tenant_session`. + + AST calls, not source text — an H4 marker comment that merely *mentions* + tenant_session must not read as an opener (first version's false positive: + `record_app_audit`'s own exemption comment). + """ + path = _REPO / module_rel + if not path.exists(): + return frozenset() + src = path.read_text(encoding="utf-8-sig") + tree = ast.parse(src, filename=str(path)) + out: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for inner in ast.walk(node): + if isinstance(inner, ast.Call): + fn = inner.func + name = fn.id if isinstance(fn, ast.Name) else ( + fn.attr if isinstance(fn, ast.Attribute) else "" + ) + if name.endswith("tenant_session"): + out.add(node.name) + break + return frozenset(out) + + +def test_exempt_files_do_not_reach_converted_sessions_unreviewed() -> None: + """No H4-exempt file may CALL into a `_tenant_session`-opening function + without a reviewed edge in H2_EXEMPT_CALL_EDGES. + + The failure this catches is invisible to everything else in this file: the + exempt caller has no bound tenant, the converted callee raises + `TenantUnbound` on the background path, and the hermetic fakes (which + yield unconditionally) stay green. Auto-lead died exactly this way. + """ + offenders: list[str] = [] + for rel in sorted(_h4_files()): + path = _REPO / rel + src = path.read_text(encoding="utf-8-sig") + tree = ast.parse(src, filename=str(path)) + # name -> defining module (repo-relative) for names imported from + # gateway route packages + imported: dict[str, str] = {} + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module and \ + node.module.startswith("gateway.routes."): + mod_rel = ( + "apps/services/gateway/" + + node.module.replace(".", "/") + ".py" + ) + for alias in node.names: + imported[alias.asname or alias.name] = mod_rel + if not imported: + continue + called = _called_names(path) + for name, mod_rel in sorted(imported.items()): + if name not in called: + continue + if name in _tenant_session_functions(mod_rel) and \ + (rel, name) not in H2_EXEMPT_CALL_EDGES: + offenders.append( + f"{rel} calls {name}() from {mod_rel}, which opens a " + f"tenant session — the exempt caller has NO bound tenant" + ) + assert offenders == [], ( + "unreviewed exempt→converted call edges (TenantUnbound at runtime " + "on the background path):\n " + "\n ".join(offenders) + + "\n\nEither bind an explicit tenant around the call (H4 shape) and " + "add the reviewed edge to H2_EXEMPT_CALL_EDGES, or un-convert the " + "callee." + ) + + +def test_exempt_call_edge_allowlist_has_no_stale_entries() -> None: + for (rel, name), _reason in H2_EXEMPT_CALL_EDGES.items(): + assert rel in _h4_files(), f"{rel} is no longer an H4-exempt file" + src = (_REPO / rel).read_text(encoding="utf-8-sig") + assert name in _called_names(_REPO / rel), ( + f"{rel} no longer calls {name}() — remove the stale edge" + ) + assert f"bind_tenant(" in src, ( + f"{rel} holds a reviewed edge but no longer binds an explicit " + f"tenant anywhere — the edge's justification is gone" + ) + + def test_get_db_sites_elsewhere_only_ratchet_down() -> None: total = sum( n for f, n in _get_db_sites().items()