This page is the domain contract. Every modeling, endpoint, or test decision in the project must be justifiable by a rule listed here. When the code disagrees with the rule, the code is wrong (or the rule needs an RFC before it changes).
Severity convention
MUST = domain invariant, violation is a bug.
MAY = intentional opening, decided case by case.
Anyone MUST be able to sign up via POST /auth/signup without authentication.
U-02
Email MUST be unique (case-insensitive, stored lowercase).
U-03
Password MUST be ≥ 12 characters and hashed with bcrypt (PasswordUtils.hash).
U-04
Signup MUST return 201 with {user_id, access_token, refresh_token}.
U-05
Login (POST /auth/login) MUST accept email+password and return both tokens.
U-06
Refresh (POST /auth/refresh) MUST validate refresh_token and emit a new pair.
U-07
User MAY update name / photo via PATCH /users/me. Email is immutable.
U-08
Soft-delete (DELETE /users/me) MUST flip is_active=False and revoke issued tokens via jti blacklist.
Rationale: public signup is the funnel trigger. Unique email prevents duplicates; bcrypt prevents password leak on breach. Soft-delete preserves referential integrity (old orders keep pointing at the now-inactive user).
Any user MUST be able to create an organization via POST /organizations.
O-02
A user MUST NOT create more than 2 active organizations simultaneously (count Organization rows with owner_id = user.id AND is_active = true).
O-03
Creator becomes the OWNER automatically — Membership(role=OWNER) in the same create transaction.
O-04
Each org MUST have exactly 1 OWNER. Transfer via POST /organizations/{id}/transfer-ownership (target must already be a member).
O-05
OWNER MAY delete the organization (soft-delete). Deletion does not free the slot until the async cleanup (TaskIQ) zeroes out members and converts open orders to cancelled.
O-06
Org slug MUST be globally unique (acme-supplies).
Rationale: the 2-org cap blocks mass-creation abuse for quota resets. Single OWNER eliminates governance conflicts.
OWNER/ADMIN MAY create an invitation via POST /organizations/{id}/invitations with {email, role}.
I-02
Invitation generates an opaque token (generate_opaque_token(48)), stores the hash (hash_opaque_token), and sends the link via email (Jinja2 via EmailUtils.render_template).
I-03
Invitation MUST expire in 7 days (expires_at).
I-04
POST /invitations/{token}/acceptMUST validate: invitation valid + not expired + logged-in user has the same email.
I-05
Acceptance creates Membership(role=invite.role) in the same transaction that marks the invitation ACCEPTED.
I-06
Duplicate invite for the same email MUST invalidate the previous one before creating the new one (status=SUPERSEDED).
I-07
OWNER/ADMIN MAY revoke via DELETE /invitations/{id} before acceptance (status=REVOKED).
I-08
Acceptance MUST fail with 409 when the org already maxed out at 10 members.
Checkout (POST /orders) MUST be idempotent via the Idempotency-Key header.
D-02
Checkout converts 1 cart into 1 order. Cart becomes CONVERTED.
D-03
Order follows the machine: PENDING → PAID → SHIPPED → DELIVERED (see Flows).
D-04
CANCELLED is reachable from PENDING and PAID; past SHIPPED only RETURNED.
D-05
Order creation MUST emit a stock RESERVATION (atomic with the order INSERT).
D-06
Confirmed payment MUST convert RESERVATION into OUT.
D-07
Cancellation MUST emit RELEASE (returns stock).
D-08
State change MUST notify the buyer via NotificationService.notify(...) — SSE (foreground) + Web Push (background), with the same payload (see section 10).
D-09
Payment in this project is mock — an admin endpoint marks the order as paid. No external integration.
Every relevant domain event MUST notify the right user: order PAID/SHIPPED/DELIVERED/CANCELLED → the buyer; invite received → the invitee; new review → OWNER/ADMIN of the selling org.
N-02
The same event MUST be delivered on two channels with the same payload: SSE (foreground, app open) and Web Push (background, app closed). A single NotificationService.notify(...) does the fan-out.
N-03
SSE uses SSEBroker with channel = str(user.id); the client subscribes to GET /notifications/stream (returns broker.response(...)). SSE is core; multi-worker requires SSEBroker(redis=...) + broker.run() in the lifespan ([cache] extra).
N-04
Web Push uses WebPushSubscriptionService.notify_user(user_id, payload) — sends to all the user's registered devices. Needs the [webpush] extra.
N-05
Dead devices (404/410 from the push service) MUST be pruned automatically by WebPushSubscriptionService — no zombie subscriptions piling up.
N-06
When the action came from the user themselves, the notification MUST NOT echo back to the originating device — pass exclude_endpoints to notify_user.
N-07
A notification is ephemeral: there is no persisted Notification entity nor history. Each notification lives only as an SSE frame and/or Web Push payload derived from the event.
N-08
Notifications for the same event MAY coalesce on the device via WebPushPayloadSchema.tag (use tag=event) — an update replaces the previous one instead of stacking.