## مغلّف الأخطاء

كل استجابة خطأ — أيًّا كانت حالة HTTP — تتبع نفس الشكل:

```json
{
  "error": {
    "code": "STUDENT_NOT_FOUND",
    "message": "Student not found.",
    "details": {
      "studentId": "0190a1b2-..."
    },
    "requestId": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
  }
}
```

`message` دائمًا بالإنجليزية ومخصّصة للسجلّات لا للمستخدم النهائي — تَرجِم حسب `code` في واجهتك أنت. `details` تظهر فقط حين تضيف شيئًا يمكن للعميل التصرّف بناءً عليه (أي حقل فشل التحقّق منه، أي معرّف لم يُعثر عليه). `requestId` يطابق القيمة التي تسجّلها الواجهة أيضًا من جهة الخادم — أرفقه عند الإبلاغ عن مشكلة.

## رموز الأخطاء

كل رمز يمكن أن تُعيده الواجهة، مُولَّد مباشرة من `packages/shared/src/errors.ts` — المصدر الوحيد الذي تُبنى منه كل دالة إنشاء خطأ في قاعدة الشيفرة، لذا لا يمكن لهذا الجدول أن ينحرف أبدًا عمّا ترسله الواجهة فعليًا.

| الرمز | حالة HTTP | الوصف |
| --- | --- | --- |
| VALIDATION_ERROR | 422 | The request parsed, but its content is not acceptable: a schema failure at the boundary (body, query, path params or headers — always carrying `details.issues`) or a domain rule rejecting a well-formed value. Every validation failure in the API uses this one status; a 400 (`MALFORMED_REQUEST`) means only that the request could not be read at all. |
| MALFORMED_REQUEST | 400 | The request could not be read at all, before any schema ran: an unparseable body, an unusable content type, a payload over the size limit. Deliberately not `VALIDATION_ERROR`, which always means 422, so a client can tell "I could not read your request" apart from "I read it and its content is not acceptable" from the code alone. |
| UNAUTHORIZED | 401 | No valid credentials (JWT, API key, or session) were presented. |
| FORBIDDEN | 403 | The caller is authenticated but lacks the permission this action requires. |
| NOT_FOUND | 404 | The requested resource does not exist (or is not visible to this tenant). |
| CONFLICT | 409 | The request conflicts with the resource’s current state. |
| INTERNAL_ERROR | 500 | An unexpected server error. Never leaks a stack trace or SQL to the client. |
| PAYMENT_REQUIRED | 402 | The tenant's subscription does not currently permit this action. |
| SUBSCRIPTION_SUSPENDED | 402 | Subscription/license lifecycle has reached `suspended`/`stopped` (docs/ARCHITECTURE.md §1). |
| INSUFFICIENT_CREDIT | 402 | The messaging relay's atomic conditional debit found no row to update: the account's balance minus the message's cost would fall below `-overdraft_units`, the account is not `active`, or it does not exist yet (docs/specs/phase9-messaging-relay.md §10.3). Nothing is debited and nothing is sent when this is thrown — the caller falls back to the in-app inbox. Never used for a `system`-class message, which is never blocked by balance (§11.1). |
| RATE_LIMITED | 429 | The caller exceeded a rate limit (per-IP, per-user, or per-API-key sliding window). See the `RateLimit-*` response headers. |
| INVALID_CREDENTIALS | 401 | Email/password did not match any account. |
| ACCOUNT_LOCKED | 423 | Account temporarily locked out after repeated failed logins. |
| ACCOUNT_DISABLED | 403 | The user account itself has been disabled (distinct from a locked-out but active account). |
| SESSION_REVOKED | 401 | A refresh token that was already rotated away was presented again (reuse detected). |
| TOKEN_INVALID | 401 | A refresh/reset/verification token is missing, malformed, expired, or already used. |
| REGISTRATION_DISABLED | 403 | Self-registration is disabled for this tenant/deployment. |
| ACCOUNT_EMAIL_UNVERIFIED | 403 | `POST /accounts/sign-in` for an account whose `email_verified_at` is still null (docs/specs/phase13-portal-and-institflow-account.md §2.2). A distinct, actionable code so the web client can offer "resend the code" instead of showing a generic credentials error — the password was correct. |
| ROLE_IMMUTABLE | 403 | Attempted to edit the immutable system `owner` role. |
| PERMISSION_ESCALATION | 403 | Caller tried to grant a permission (directly, or via a role assignment) they do not themselves hold. |
| INVITATION_INVALID | 400 | The invitation link is invalid, expired, or already used. |
| MEMBERSHIP_INACTIVE | 403 | The caller's membership in the active tenant is not `active` (invited or disabled). |
| TENANT_SUSPENDED | 403 | The active tenant itself is not usable (archived). Not used for billing suspension — see `subscriptionSuspended`. |
| TENANT_NOT_FOUND | 404 | No tenant matches the given id/slug, or it is not visible to this caller. |
| TENANT_SLUG_TAKEN | 409 | The slug (or the subdomain built from it) already belongs to a tenant. `details`, when the owning tenant is known, is the same `{ tenantId, tenantName, status, endedAt }` shape `ownerEmailTaken` carries, so the console can name that institute and link to it (docs/specs/phase15-institute-delete.md §1). |
| OWNER_EMAIL_TAKEN | 409 | `POST /platform/tenants` (and the customer-request approval that reuses it): the owner email is already the owner of another institute — active, suspended or ended (archived). A deleted institute never matches: deleting it removed its owner user. `details` is `{ tenantId, tenantName, status, endedAt }` (`endedAt` is `null` unless the institute was ended) — docs/specs/phase15-institute-delete.md §1. |
| LICENSE_INVALID | 400 | A license JWT failed signature/shape verification (self-hosted `LicenseService`). |
| VERSION_CONFLICT | 409 | `common/optimistic-lock.ts`'s `updateWithVersion` matched zero rows: the caller's `expectedVersion` no longer matches the row's current `version` (someone else wrote it first). Distinct from the generic `conflict()` (`CONFLICT`) so clients can special-case "reload and retry" instead of showing a generic conflict message. |
| IDEMPOTENCY_KEY_REQUIRED | 428 | An API-key caller sent a POST/PUT/PATCH/DELETE with no `Idempotency-Key` header (docs/specs/phase4-integration-mcp.md §A). 428 (not 400) so clients can tell "you forgot a required precondition" apart from a generic validation failure — documented choice, see `docs/build-log/phase4-integration.md`. |
| IDEMPOTENCY_KEY_REUSED | 409 | The same `Idempotency-Key` was reused with a different method/path/body than the first request that used it. |
| OAUTH_CLIENT_NOT_FOUND | 404 | `POST /api/v1/oauth/authorize/decision` (or `/context`) referenced an unknown or disabled OAuth client. |
| OAUTH_INVALID_REDIRECT_URI | 400 | The `redirect_uri` does not exactly match one registered for this OAuth client. |
| OAUTH_ACCESS_DENIED | 403 | The tenant/membership selected on the consent screen does not hold `mcp.access`. |
| FEATURE_DISABLED | 403 | `FeatureGuard` (docs/specs/phase6-courses-video.md §3.1) found the resolved tenant's effective feature flags do not grant the `@RequireFeature(...)` key the route declares. Runs after `PermissionsGuard`, so a caller lacking the permission still sees `FORBIDDEN`, never this. |
| QUOTA_EXCEEDED | 403 | A plan-quota check (docs/specs/phase6-courses-video.md §3.2, mirrors the notification quota) rejected a create before any write happened. `details` carries `{ limit, current, key }`. |
| VIDEO_URL_INVALID | 422 | `parseVideoUrl()` (`packages/shared/src/video.ts`) could not recognize or validate a pasted video URL. `details.reason` names the specific cause (e.g. `'playlist_not_supported'`, `'unknown_host'`). |
| VIDEO_PROVIDER_UNSUPPORTED | 422 | The URL's provider was recognized but is not one `VIDEO_PROVIDERS` accepts: only `youtube` from 1.4.0 (ق-32, docs/DECISIONS-LOG.md — a second provider was removed entirely, not gated off; `upload`/`vimeo`/`bunny` remain declared-but-unbuilt, D8). |
| STORAGE_FULL | 409 | The server's disk has reached the reserve kept free for the database, backups and updates (docs/specs/phase20-course-files.md §3): the upload is refused **before** a signed URL is issued, so nothing is ever half-uploaded. A business refusal, not a server fault — hence 409, not 5xx. `details` carries `{ requestedBytes, remainingBytes }`. |
| FILE_TYPE_NOT_ALLOWED | 422 | A file's type is not one the course-files feature accepts (docs/specs/phase20-course-files.md §4): its extension or declared MIME type is off the allowlist, or — at `complete` — the object's first bytes do not match what its extension claims. `details.reason` names which. |
| MFA_STEP_UP_REQUIRED | 403 | The access token's `mfa_at` (last successful TOTP check) is older than the sensitive route's `@RequireRecentMfa(seconds)` window (docs/specs/phase11-vendor-console.md §2.4). The client is expected to prompt for the current 6-digit code and retry via `POST /auth/mfa/step-up`, which returns a fresh access token with a new `mfa_at`. |

## 400 مقابل 422

التحقّق من الصحّة يكون دائمًا `422`. كل ما تقرأه الواجهة ولا تقبله — جسم الطلب أو سلسلة الاستعلام أو معامل المسار أو ترويسة تفشل في مخطّطها، أو قاعدة عمل ترفض قيمة سليمة الشكل — يعود بـ `422 VALIDATION_ERROR`، وفشل المخطّط يحمل الحقول التي فشلت في `details.issues`:

```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid input: expected string, received undefined",
    "details": {
      "issues": [
        {
          "path": [
            "branchId"
          ],
          "message": "Invalid input"
        }
      ]
    },
    "requestId": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
  }
}
```

أمّا `400 MALFORMED_REQUEST` فيعني فقط أنّ الطلب تعذّرت قراءته أصلًا: JSON غير قابل للتحليل، أو `Content-Type` غير صالح، أو حمولة تتجاوز الحدّ. باختصار: `400` خلل في طريقة تسلسل الطلب، و`422` خلل في محتواه. وتستخدم بضع نقاط نهاية `400` أيضًا لرفض محدّد باسمه (`INVITATION_INVALID`، `LICENSE_INVALID`، `OAUTH_INVALID_REDIRECT_URI`) — ولهذا تُفرّع على `error.code` لا على رمز الحالة.

## التصفّح بالمؤشّر (Cursor)

كل نقطة نهاية لعرض قوائم تستخدم تصفّحًا بالمؤشّر — لا بالإزاحة أو رقم الصفحة أبدًا — لتبقى النتائج صحيحة حتى أثناء إدراج صفوف جديدة في الوقت نفسه. مرّر `limit` (بحدّ أقصى `100`، والقيمة الافتراضية تختلف حسب نقطة النهاية)، ولكل صفحة بعد الأولى، `cursor` من استجابة الصفحة السابقة:

```json
{
  "data": [
    "…"
  ],
  "nextCursor": "eyJpZCI6IjAxOTAwMDAwLTAwMDAtNzAwMC04MDAwLTAwMDAwMDAwMDAwMSJ9"
}
```

`nextCursor` من نوع `string | null` — `null` تعني أنك وصلت إلى الصفحة الأخيرة. تعامل مع المؤشّر كقيمة معتمة: هو مؤشّر مُرمَّز بـ base64url، وليس إزاحة صف، وقد يتغيّر شكله الداخلي.

## المبالغ المالية

كل قيمة مالية هي كائن متداخل — `{ amountMinor, currency }` — عدد صحيح بالوحدة الصغرى للعملة (سنتات، أغورات، فلوس) إلى جانب حقل `currency` خاص به على نفس ذلك الكائن، مثل `total.amountMinor` + `total.currency` للفاتورة، أو `amountMinor` + `currency` للدفعة. وهي ليست أبدًا رقمًا مجردًا مع وجود حقل العملة في مكان آخر على الكائن الأصل. **لا تُستخدم الأعداد العشرية (float) أبدًا**. القيمة `{ amountMinor: 1250, currency: "USD" }` تعني 12.50 دولارًا.

## الطوابع الزمنية

كل طابع زمني بصيغة ISO-8601 بتوقيت UTC (`2026-03-02T09:00:00.000Z`). التواريخ بلا مكوّن وقت (تاريخ استحقاق، تاريخ ميلاد) بصيغة `YYYY-MM-DD` بسيطة. حوِّل الأوقات إلى المنطقة الزمنية للمستأجر (افتراضيًا `Asia/Jerusalem`) عند العرض — الواجهة لا تفعل ذلك نيابة عنك.

## تعدّد المستأجرين

لا تمرّر معرّف مستأجر بنفسك أبدًا. رمز JWT للتطبيق الأوّل يحمل المستأجر المُختار وقت `select-tenant`؛ ومفتاح API مرتبط بشكل دائم بالمستأجر الذي أُنشئ فيه. كل استعلام تُجريه الواجهة مُقيَّد بذلك المستأجر على مستوى طبقة التطبيق وعلى مستوى أمان الصفوف (Row-Level Security) في قاعدة البيانات معًا — لا توجد طريقة للوصول إلى بيانات مستأجر آخر بتخمين معرّف.

> **requestId صديقك**
>
> سجّل `error.requestId` إلى جانب سجلّاتك الخاصة. إنّها أسرع طريقة للحصول على إجابة سريعة ومحدّدة عندما يبدو أن هناك خللًا من جهة الخادم.
