Skip to content

WS-29 H2: central tenant binding + routes/projects converted to tenant_session() - #416

Merged
vjvarada merged 2 commits into
mainfrom
claude/paca-research-task-management-a1f6zd
Aug 10, 2026
Merged

WS-29 H2: central tenant binding + routes/projects converted to tenant_session()#416
vjvarada merged 2 commits into
mainfrom
claude/paca-research-task-management-a1f6zd

Conversation

@vjvarada

Copy link
Copy Markdown
Contributor

Starts H2 — the long pole of the multi-tenancy workstream (saas_multitenancy_handover.md) — with the runbook's "do this first" step and its largest single package slice. Includes today's main merged in (D28–D30 harness + #409 tenancy alignment); no conflicts.

The central binding (the prerequisite for every conversion)

  • _with_resolved_access (acb_auth/deps.py) now calls bind_tenant(organization_id) the moment identity resolves — from the app_user row, never a header/query/body (R11). One place, so the 500+ call sites need no tenant argument.
  • New TenantScopeMiddleware in the gateway (pure ASGI): opens a fresh empty tenant scope per HTTP request, releases it after the response — a binding can never leak between requests on any server task model. clear_tenant() added to acb_common.db for it.
  • tests/unit/test_tenant_request_binding.py (new, 6 tests): binds on resolution, tenant comes from identity not caller-controlled headers, unresolved identity binds nothing, system:internal binds nothing (jobs bind explicitly — H4), no leak across sequential same-task requests, non-http passthrough.

The Projects slice (84 of the ~580 sites)

  • Every request handler in routes/projects (21 files) is now async with _tenant_session() as db:. The per-package alias is asserted to be acb_common.db.tenant_session (seam-identity test extended), so there's exactly one GUC discipline.
  • Explicit mid-flow db.commit() calls removed — the wrapper commits on clean exit, and a mid-block commit would end the transaction and silently drop the SET LOCAL scope for everything after it. (Recon first proved no handler queries after its final commit, so the rewrite is behavior-preserving.)
  • One named exemption: agent_dispatch.py (2 sites) is an event consumer, and the runbook forbids jobs inheriting an ambient tenant — it stays on get_db() with a loud H4 marker until H4 threads an explicit tenant through the event payload.
  • Ratchets (test_db_engine_seam.py): routes/projects pinned at zero unconverted sites; the 494 sites elsewhere frozen as a baseline that only ratchets down, with a bank-your-progress guard so headroom can't become new-debt budget.

⚠️ Live run found tenant_session() itself broken (shipped in #404)

The seam issued SET LOCAL app.tenant_id = :tenant — a Postgres syntax error through the extended protocol (SET cannot take a bind parameter). Every hermetic test was green because fakes don't parse SQL; test_tenant_session.py even pinned the broken spelling. The first converted handler against real Postgres refused it — had this survived to H3, every request would have 500'd at its first query.

Fixed to SELECT set_config('app.tenant_id', :tenant, true) — identical transaction-local semantics (is_local = true IS SET LOCAL), parameterizable. The pin test now enforces the working form and refuses both the literal and an is_local = false variant. Both tenancy specs annotated at their illustrative snippets; the handover's new H2 box tells the remaining packages' converters to run at least one live smoke each.

Verification

  • Backend: uv run python -m pytest tests/unit/5798 passed (49 skipped are the documented needs-live-Postgres checks); ruff CI gate clean.
  • Frontend (untouched by this change, run for the merge): npx vitest run1278 passed; tsc --noEmit clean.
  • Live scratch Postgres 16 (migrations applied through 166): test_tenant_session.py green against the real engine, plus an end-to-end smoke — unbound tenant_session() raises TenantUnbound; real converted handlers (create_node, create_task, list_tasks) write and read under the bound GUC; current_setting('app.tenant_id') observed inside the wrapper's transaction; handler-written rows stamped with the bound organization_id.
  • Test seams updated coherently: _projects_fakes.bind_db patches the package's _tenant_session with a commit-on-clean-exit context manager (one-transaction contracts stay observable as committed == 1); read-path tests that used committed == 0 as a "writes nothing" proxy now assert on statements/rows, since the wrapper commits read transactions too — exactly as real Postgres does.

Not in this PR

  • The remaining 494 get_db() sites (email, whatsapp, crm, tasks, notes, workflows…) — next H2 slices, package-by-package per the handover's new conversion notes.
  • H3 (applying the generated RLS policies) — gated on all of H2, per the runbook's cliff warning.
  • agent_dispatch's explicit-tenant conversion — H4.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W


Generated by Claude Code

claude added 2 commits August 10, 2026 05:33
…d to tenant_session

The runbook's 'do this first' step and its largest package slice:

- _with_resolved_access binds bind_tenant(organization_id) from the
  app_user row (never a header, R11); gateway TenantScopeMiddleware opens
  a fresh scope per request and releases it after the response. Pinned by
  tests/unit/test_tenant_request_binding.py (6 tests incl. service
  identity binds nothing, no leak across sequential same-task requests).

- routes/projects: all 84 request-handler get_db() sites are now
  'async with _tenant_session() as db:' — the per-package alias IS
  acb_common.db.tenant_session (identity asserted). Explicit mid-flow
  commits removed: the wrapper commits on clean exit, and a mid-block
  commit would end the transaction and drop the GUC. agent_dispatch.py
  (2 sites) is an event consumer and stays on get_db() by name until H4
  threads an explicit tenant through the event payload.

- Ratchets in test_db_engine_seam.py: routes/projects pinned at zero;
  the 494 sites elsewhere are a frozen baseline that only ratchets down,
  with a bank-your-progress guard.

- ⚠️ Live run found tenant_session() itself broken: the literal
  SET LOCAL app.tenant_id = :tenant is a Postgres syntax error through
  the extended protocol (SET cannot bind a parameter) — green under every
  hermetic fake, refused by real Postgres on the first converted handler.
  Fixed to SELECT set_config('app.tenant_id', :tenant, true) (identical
  transaction-local semantics), test_tenant_session.py re-pinned to the
  working form, and proven by a scratch-Postgres smoke: unbound raises
  TenantUnbound, converted handlers write under the GUC, rows land with
  the bound organization_id. Both tenancy specs annotated.

- Test seams updated: _projects_fakes.bind_db patches _tenant_session
  (commit-on-clean-exit so one-transaction contracts stay observable);
  committed==0 write-nothing proxies replaced with statement/row
  assertions.

Verification: full unit suite 5797 passed; ruff gate clean; live smoke
green on scratch Postgres 16 with migrations through 166 applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
@vjvarada
vjvarada merged commit e2d99c5 into main Aug 10, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants