Nobody has started this yet — be first.
Business impact
TenantSettings.limits (default {"max_members": 25}) exists for exactly the reason a SaaS product has per-plan limits at all: to make "how many seats does your plan include" mean something. Right now it is pure decoration -- GET /api/tenant/settings will happily show max_members: 25, but POST /api/tenant/members never reads it. A tenant on a plan capped at, say, 5 seats can invite an unbounded number of members, which means the limit is not actually a limit -- it is a number displayed in a settings panel with no relationship to what the product will actually let you do.
Problem
invite_member creates a new Membership for any email/role combination that passes validation, with no reference to TenantSettings.limits anywhere.
Current behavior
There is no code path anywhere in the project that reads limits.max_members for enforcement purposes -- only DEFAULT_LIMITS setting its initial value -- so a tenant can accumulate arbitrarily many active memberships regardless of its configured limit.
Expected behavior
POST /api/tenant/members rejects an invite that would push the tenant's active membership count past limits.max_members, with a clear, distinct error (409 {"error": "member_limit_reached", ...}), not the generic invalid_request used for malformed input. A tenant with no explicit TenantSettings row yet is treated as DEFAULT_LIMITS, the same fallback get_settings already uses. Inviting when there is still room under the limit is unaffected.
Steps to reproduce
Why this matters
This ticket is entirely about wiring two things that already exist (TenantSettings.limits and invite_member) together correctly, not about inventing new state. The interesting part is getting the count right: an already-existing-member early return in invite_member returns before creating anything, so it is irrelevant to the limit check, but the new check needs to run at a point where it sees the current member count, not a stale one -- and it needs a defined fallback for a tenant with no TenantSettings row yet.
Suggested approach
Look at get_settings() just above invite_member in the same file for the established pattern of "no TenantSettings row yet -> fall back to DEFAULT_LIMITS" (imported from app.models.tenant). Count existing Membership rows for g.tenant_uuid before deciding whether to proceed with the insert.
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket_11_max_members_enforcement.py -v
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
Sign in to create it automatically, or do it yourself:
$git checkout -b feat/max-members-enforcementFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/max-members-enforcementSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign inacmeglobexmake seed
*.localhost subdomains resolve to 127.0.0.1 out of the box in curl and every modern browser -- no /etc/hosts editing needed:
curl -s -X POST http://acme.localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
--cookie-jar /tmp/acme.cookies --cookie /tmp/acme.cookies \
-d '{"email": "owner@acme.test", "password": "owner-acme"}'
curl -s http://acme.localhost:5000/api/tenant/settings --cookie /tmp/acme.cookies
For tooling that cannot fake subdomains (some HTTP clients, CI), send X-Tenant-Slug instead -- honored only when TRUST_TENANT_HEADER=true (the Docker dev setup enables it; production does not):
curl -s http://localhost:5000/api/tenant/settings -H "X-Tenant-Slug: acme"
You do not strictly need Docker to work the tickets, but -- unlike a project that can fake its backing services -- this one genuinely needs a real Postgres and Redis, because the whole point is proving isolation against the real database, not a mock. Without Docker: cp .env.example .env, point DATABASE_URL/ADMIN_DATABASE_URL/TEST_DATABASE_URL/TEST_ADMIN_DATABASE_URL/REDIS_URL at a local Postgres/Redis (docker/init-db.sql documents the exact roles/databases the app expects -- notably the saas_admin/BYPASSRLS role migrations need and the plain saas role every request uses), then:
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
.venv/bin/flask db upgrade
.venv/bin/pytest practice_tickets/tests/test_ticket_07_webhook_signature_raw_body.py -v # a single ticket
./practice_tickets/run_tickets.sh # all 15, clean pass/fail summary
./practice_tickets/run_tickets.sh 07 12 13 # just a few, by number
All 15 tests fail at the start; as you fix each ticket in app/ (or, for TICKET-12, in migrations/versions/0002_row_level_security.py, plus re-applying it), its test -- and only its test -- should flip to passing. make test runs the project's own main suite; several of its tests (RBAC, tenant-spoofing, and billing-webhook tests among them) fail at the start for the same underlying reasons as specific tickets -- fixing those tickets fixes those failures too, not a separate task.
Level 2
Implement a feature
Extend the system within its own patterns.