البداية
نظام النطاقات (Scopes)
كل قطعة بيانات في أوكتا محمية بـ scope بصيغة:
<feature>.<resource>.<action>
أمثلة:
education.students.read → قراءة الطلاب
education.students.write → إضافة/تعديل الطلاب (لا حذف)
education.face_embeddings.read → قوالب التعرّف على الوجه (خطِر، قراءة فقط)
employees.directory.read → قراءة الموظفين
reports.builder.read → قراءة التقارير
reports.builder.write → اقتراح قوالب (ترويسات) — لا تعريف تقارير
المبادئ الأساسية
- lowercase + dot-separated بدون wildcards.
- شريكاً يحصل فقط على
readوwrite. الحذف لا يُمنح أبداً للشركاء. - الكتالوج هو المصدر الوحيد. لا تخترع scopes جديدة.
- أوكتا يضيف نطاقات جديدة عبر تحديثات المنصة.
كتالوج النطاقات
اطلب الكتالوج الكامل من:
GET /api/partners/permissions/catalog
يعيد جميع الـ scopes المتاحة مجمَّعة حسب feature → resource. هذا هو ما تختار منه عند إنشاء تطبيقك.
مرجع النطاقات التفصيلي
كل نطاق يفتح وصولاً لمجموعة محدّدة من نقاط نهاية الـPartner-app
runtime API، وكل نقطة نهاية تتحقق من الـscope تلقائياً عبر middleware
app.scope:<feature>.<resource>.<action>. الـURL الأساس: /api/apps.
ملاحظة عامة على المعرّفات: كل المعرّفات في الاستجابات هي ULIDs (سلسلة مكوّنة من 26 حرفاً، Crockford base32). لا تكسِتها إلى integer ولا تقارنها بأرقام — boilerplate scanner يرفض الـPR لو حاول (
ulid-cast-to-intوulid-numeric-comparison). خزّنها كـchar(26)في جداولك، ووسِم العمود في الميغريشن بـcore_reference:<الجدول>وإلا لن يحميه الحارس — راجع المعرّفات والـcore_reference.
education.students — الطلاب
| النطاق | الصلاحيات |
|---|---|
education.students.read |
عرض قائمة الطلاب، عرض طالب واحد |
education.students.write |
+ إضافة طالب، تعديل طالب (الحذف غير ممنوح) |
نقاط النهاية:
| Method | Path | Scope | الوصف |
|---|---|---|---|
| GET | /api/apps/education/students |
read | قائمة (مع pagination + فلترة بـgrade_id/section_id) |
| GET | /api/apps/education/students/{student} |
read | طالب واحد بـULID |
| POST | /api/apps/education/students |
write | إضافة طالب |
| PATCH | /api/apps/education/students/{student} |
write | تعديل طالب |
مثال Embedded (PHP):
use App\Services\PartnerApi\Education\Students\ListStudents;
use App\Services\PartnerApi\Education\Students\GetStudent;
$page = app(ListStudents::class)(page: 1, perPage: 50);
foreach ($page->data as $student) {
// $student هو StudentDto
echo $student->id; // ULID مثل "01H..."
echo $student->name;
echo $student->gradeId; // ULID للصف
echo $student->sectionId; // ULID للشعبة
}
$one = app(GetStudent::class)('01HXXXXXXXXXXXXXXXXXXXXXXX');
مثال External (HTTP):
curl -H "Authorization: Bearer $INSTALLATION_TOKEN" \
-H "Accept: application/json" \
"https://app.okta.platform/api/apps/education/students?page=1&perPage=50"
حالات استخدام شائعة: قوائم الفصول، إنشاء جداول الاختبارات، إصدار شهادات، استيراد طلاب جدد من نظام الشريك.
education.face_embeddings — قوالب التعرّف على الوجه
نطاق خطِر (
is_dangerous). شاشة التثبيت تعرضه بألوان الخطر، وأدمن الجهة سيتوقّف عنده ويقرأه. اطلبه فقط إن كان تطبيقك يطابق الوجوه فعلاً، واشرح السبب فيreasonبلغة يفهمها مدير مدرسة.
| النطاق | الصلاحيات |
|---|---|
education.face_embeddings.read |
قراءة قوالب الوجه (متجهات رقمية) للطلاب والموظفين المسجَّلين |
لا يوجد .write ولن يوجد. التسجيل يتمّ في المنصة بحضور الشخص أمام
مكتب الاستقبال؛ تطبيقٌ يستطيع كتابة قالب يستطيع أن يجعل أي كاميرا تتعرّف
على أي شخص بأنه شخص آخر.
لماذا هو منفصل عن education.student_photos.read: يبدوان الموافقة
نفسها وليسا كذلك. الصورة ما تطبعه المدرسة على البطاقة وتعرضه عند البوابة؛
القالب مفتاحٌ يطابق الطفل أمام أي كاميرا في أي مكان — وخلافاً لكلمة المرور
لا يمكن إصدار بديل عنه بعد التسريب. جهةٌ وافقت على عرض الوجوه لم تقل شيئاً
عن تسليم الرياضيات التي تتعرّف عليها.
نقاط النهاية:
| Method | Path | Scope | الوصف |
|---|---|---|---|
| GET | /api/apps/education/face-embeddings |
read | قوالب الجهة لنموذج واحد (مُرقَّم الصفحات) |
المعاملات: model (مطلوب)، person_type (student/employee،
اختياري)، page، per_page (افتراضي 50، بحد 100).
model مطلوب بلا افتراضي عمداً: AdaFace1 وArcFace_50r1 ينتجان
متجهات غير قابلة للمقارنة ببعضها. لو اختارت المنصة واحداً نيابةً عنك
لتسلّمت قوالب يفشل محرّكك في مطابقتها بصمت — بلا خطأ في أي مكان.
شكل الناتج:
{
"model": "AdaFace1",
"count": 2,
"total": 431,
"people": [
{
"person_type": "student",
"person_id": "01HXXXXXXXXXXXXXXXXXXXXXXX",
"model": "AdaFace1",
"dimensions": 512,
"captures_count": 3,
"registered_at": "2026-08-25T09:12:44+00:00",
"embedding": [0.0134, -0.0891, "…"]
}
]
}
count ≠ total: الأول ما تحمله هذه الصفحة بعد إسقاط الطلاب المُخلى
سبيلهم والصفوف غير القابلة لفكّ التشفير، والثاني ما تملكه الجهة. رقّم
الصفحات بـtotal — من يشتقّه من count يتوقّف مبكراً عند أول صفحة تصادف
طالباً مُخلى سبيله.
لا أسماء في الناتج. تحصل على متجهات مفهرسة بـ ULID فقط. إن أردت معرفة
صاحب الوجه الذي طابقته، اطلب education.students.read أيضاً وحلّ الـ ULID
هناك — منحة واحدة لا تُسلّمك اثنتين.
مثال Embedded (PHP):
use App\Services\PartnerApi\Education\FaceEmbeddings\ListFaceEmbeddings;
$page = app(ListFaceEmbeddings::class)(model: 'AdaFace1', page: 1, perPage: 50);
foreach ($page['people'] as $person) {
$person['person_id']; // ULID
$person['embedding']; // list<float>
}
مثال من تطبيق مصغّر (Dart) — لا يحتاج رمزاً خاصاً في okta_host:
final res = await Okta.get('/api/apps/education/face-embeddings?model=AdaFace1');
if (res.status == 200) {
final people = res.body['people'];
// people[i]['embedding'] متجه من الأعداد
}
حالات استخدام شائعة: بوّابة تتعرّف على الطلاب عند الدخول والخروج، تحضير آليّ في الفصل، مطابقة هوية قبل تسليم طالب لوليّ أمره.
المطابقة تحدث عندك لا عند المنصة. المتجهات تُسلَّم خاماً لأن المقارنة يجب أن تجري حيث الكاميرا — على بوّابة، داخل المبنى، غالباً بلا شبكة. نقطة نهاية تُعيد درجات تشابه بدلاً من متجهات كانت ستشترط الاتصال الذي لا تملكه البوّابة أصلاً.
education.subjects — المواد الدراسية
| النطاق | الصلاحيات |
|---|---|
education.subjects.read |
عرض قائمة المواد، عرض مادة |
education.subjects.write |
+ إضافة مادة، تعديل مادة |
نقاط النهاية:
| Method | Path | Scope |
|---|---|---|
| GET | /api/apps/education/subjects |
read |
| GET | /api/apps/education/subjects/{subject} |
read |
| POST | /api/apps/education/subjects |
write (+ idempotent) |
| PATCH | /api/apps/education/subjects/{subject} |
write |
مثال:
use App\Services\PartnerApi\Education\Subjects\ListSubjects;
$page = app(ListSubjects::class)(page: 1, perPage: 100, gradeId: $gradeUlid);
// كل subject له ulid في .id و grade_id في .gradeId (كلاهما ULID)
حالات استخدام شائعة: ربط المواد بالمعلمين، تنظيم الجداول الزمنية، إعداد الدرجات.
education.curriculum — توزيع المنهج
| النطاق | الصلاحيات |
|---|---|
education.curriculum.read |
قراءة توزيع المنهج المنشور لمادة، وتحديد درس حصة بعينها |
للقراءة فقط. التطبيق الذي يشغّل الجدول اليومي يحتاج أن يسمّي درس الحصة،
ولا شأن له بإعادة كتابة توزيع المدرسة — فلا يوجد .write هنا، لأن نطاقاً
تمنحه المدرسة ولا يفعل شيئاً أسوأ من غيابه: شاشة الموافقة توحي بأنه يفعل.
نقاط النهاية:
| Method | Path | Scope |
|---|---|---|
| GET | /api/apps/education/subjects/{subject}/curriculum |
read |
| GET | /api/apps/education/subjects/{subject}/curriculum/session |
read |
القاعدة التي يقوم عليها العقد: أنت تملك الجدول، والمنصّة تملك التوزيع.
الحصص تعيش في تطبيقك لا في okta-web، فالمنصّة لا تعرف أن الحصة التي بدأت الآن
هي حصة العربي الثانية في هذا الأسبوع. تخمينها من التاريخ وحده يعني اختراع جدول
وتسمية الدرس الخطأ بثقة. لذلك أنت تمرّر الرقم الذي تحسبه أصلاً في
session_index، والمنصّة تمشي على أسابيع التوزيع حتى الدرس الذي يقع فيه.
GET /api/apps/education/subjects/{subject}/curriculum/session
?date=2026-08-25 # اختياري، الافتراضي اليوم
&session_index=3 # ترتيب حصة هذه المادة داخل الأسبوع (1-based)
{
"subject_id": "01K3…",
"date": "2026-08-25",
"session_index": 3,
"resolved": true,
"reason": null,
"week": { "number": 1, "position": 1,
"starts_on": "2026-08-23", "ends_on": "2026-08-27",
"kind": "study", "label": null, "lessons": [ … ] },
"lesson": {
"position": 2, "unit": "أسرتي", "title": "الدرس الأول: حرف (م)",
"kind": "lesson", "sessions": 3, "content": "الأهداف… الصفحات 22-27",
"session_of_lesson": 2, "sessions_in_lesson": 3
}
}
session_of_lesson تقول إنها الحصة الثانية من ثلاث لهذا الدرس — والمعلّم
في الحصة الثالثة من درس يحتاج أن يعرف أنها الثالثة.
مفتاحان للأسبوع، والثابت منهما position. number هو الرقم الذي تقرأه
المدرسة على الورقة، وهو عدّ أسابيع الدراسة وحدها: صندوق الإجازة يرسل
number: null — كما تطبع وزارة التعليم تماماً: «الأسبوع ١٣»، «إجازة الخريف»،
«الأسبوع ١٤» — وتعليمُ أسبوعٍ كإجازة يُنقص رقم كل أسبوع بعده واحداً. أما
position فهو موضع الصندوق في الفصل بما فيه الإجازات، ولا يتحرّك. إن خزّنت
إشارة إلى أسبوع فخزّن position، واعرض number ولا تبنِ عليه مفتاحاً.
لا تعامل «لا يوجد درس» كخطأ. ثلاث حالات ترجع 200 مع resolved: false
وreason قابلة للفرع عليها برمجياً، ولكل واحدة بطاقة مختلفة في واجهتك:
reason |
معناه | ما تعرضه |
|---|---|---|
week_holiday / week_exam |
الأسبوع إجازة أو اختبارات | اسم الإجازة من week.label |
week_not_planned |
المدرسة لم تُدخل دروس هذا الأسبوع | «لم يُخطَّط بعد» |
beyond_planned_sessions |
الحصص تجاوزت ما خُطِّط للأسبوع | «الصف متقدّم على التوزيع» |
session_index_missing |
لم تمرّر رقم الحصة | دروس الأسبوع بترتيبها من week.lessons |
no_week_for_date |
التاريخ خارج أسابيع التوزيع | لا شيء |
و404 محجوزة لمعناها الحقيقي وحده: لا يوجد توزيع منشور لهذه المادة.
المسودّة تُقرأ كعدم وجود توزيع لا كتوزيع فارغ — لأن توزيعاً نصف مُدخَل يبدو
مكتملاً، ومن يبني عليه يخطّط فصلاً على أسبوع لم ينتهِ.
الكتاب: textbook_url فقط يعبر الواجهة. الملف المرفوع يبقى خلف دخول
المدرسة — تقديمه عبر توكن صادر لتطبيقٍ لا لشخص قرار آخر لم يُتَّخذ.
حالات استخدام شائعة: عرض درس الحصة داخل الجدول اليومي، تحضير المعلّم، إشعار وليّ الأمر بما دُرِّس اليوم.
education.grades — الصفوف
| النطاق | الصلاحيات |
|---|---|
education.grades.read |
عرض القائمة، عرض صف واحد |
education.grades.write |
+ إضافة صف، تعديل صف |
نقاط النهاية:
| Method | Path | Scope |
|---|---|---|
| GET | /api/apps/education/grades |
read |
| GET | /api/apps/education/grades/{grade} |
read |
| POST | /api/apps/education/grades |
write (+ idempotent) |
| PATCH | /api/apps/education/grades/{grade} |
write |
مثال:
use App\Services\PartnerApi\Education\Grades\ListGrades;
$page = app(ListGrades::class)(page: 1, perPage: 50, onlyActive: true);
foreach ($page->data as $grade) {
echo $grade->id; // ULID
echo $grade->nameAr;
echo $grade->order;
}
حالات استخدام شائعة: dropdowns لاختيار الصف، تصفية الطلاب، تجميع التقارير حسب المرحلة.
education.sections — الشُعب
| النطاق | الصلاحيات |
|---|---|
education.sections.read |
عرض القائمة، عرض شعبة |
education.sections.write |
+ إضافة شعبة، تعديل شعبة |
نقاط النهاية:
| Method | Path | Scope |
|---|---|---|
| GET | /api/apps/education/sections |
read |
| GET | /api/apps/education/sections/{section} |
read |
| POST | /api/apps/education/sections |
write (+ idempotent) |
| PATCH | /api/apps/education/sections/{section} |
write |
مثال:
use App\Services\PartnerApi\Education\Sections\ListSections;
$page = app(ListSections::class)(page: 1, perPage: 50, gradeId: $gradeUlid);
حالات استخدام شائعة: تنظيم الجلوس في الاختبارات، توزيع المعلمين، طباعة كشوف الحضور.
education.academic_years — السنوات الدراسية
| النطاق | الصلاحيات |
|---|---|
education.academic_years.read |
قراءة السنوات |
education.academic_years.write |
+ إضافة وتعديل |
حالة المسارات: مُسجَّل في الكتالوج للاستخدام المستقبلي. نقاط نهاية
الـrun-time لم تُكشف بعد على /api/apps/education/academic-years —
ستُضاف في إصدار قادم. اطلب هذا الـscope إذا كنت تخطط لاستخدامه قريباً.
education.terms — الفصول الدراسية
| النطاق | الصلاحيات |
|---|---|
education.terms.read |
قراءة الفصول |
education.terms.write |
+ إضافة وتعديل |
حالة المسارات: مماثل للسنوات الدراسية أعلاه — مُسجَّل في الكتالوج، نقاط النهاية ستُكشف لاحقاً.
employees.directory — الموظفون
| النطاق | الصلاحيات |
|---|---|
employees.directory.read |
عرض القائمة، عرض موظف واحد |
employees.directory.write |
+ إضافة وتعديل |
نقاط النهاية:
| Method | Path | Scope |
|---|---|---|
| GET | /api/apps/employees/directory |
read |
| GET | /api/apps/employees/directory/{employee} |
read |
| POST | /api/apps/employees/directory |
write (+ idempotent) |
| PATCH | /api/apps/employees/directory/{employee} |
write |
مثال:
use App\Services\PartnerApi\Employees\Directory\ListEmployees;
$page = app(ListEmployees::class)(
page: 1,
perPage: 100,
type: 'teacher', // أو 'administrator', null للجميع
onlyActive: true,
);
حالات استخدام شائعة: تعيين المعلمين على المواد، إصدار جداول المراقبين، تطبيقات HR التعليمية.
reports.builder — التقارير
| النطاق | الصلاحيات |
|---|---|
reports.builder.read |
عرض كتالوج التقارير وتشغيل تقرير، وسرد قوالب المدرسة والطباعة عليها |
reports.builder.write |
اقتراح قالب (ترويسة) من تأليف تطبيقك، وتعديله وحذفه — ما ألّفه هو وحده |
التقارير نفسها بلا write: الكتالوج تُنسّقه أوكتا ولا يستطيع شريك تعريف
تقرير جديد — أنت تشغّل الموجود وتأخذ مخرجاته. النطاق الثاني يخصّ القوالب
وحدها.
وwrite لا يشتري الطباعة. قالبٌ ألّفه تطبيقك يصل مسودةً ولا يُطبع
حتى تعتمده المدرسة، وتطبيقك لا يمسّ إلا ما ألّفه هو. التفصيل في
أن يقترح تطبيقك ترويسة.
النطاق يفتح سطحين مختلفين: كتالوج التقارير (بيانات تُعيدها أوكتا)، وقوالب المدرسة (ترويسة تطبع عليها أنت محتواك). الثاني موصوف كاملاً في الطباعة على ترويسة المدرسة.
نقاط النهاية:
| Method | Path | Scope |
|---|---|---|
| GET | /api/apps/reports/builder |
read |
| POST | /api/apps/reports/builder/{key}/run |
read (+ idempotent) |
| GET | /api/apps/reports/templates |
read |
| POST | /api/apps/reports/templates/{id}/render |
read |
| POST | /api/apps/reports/templates |
write (+ idempotent) |
| PATCH | /api/apps/reports/templates/{id} |
write |
| DELETE | /api/apps/reports/templates/{id} |
write |
مثال:
use App\Services\PartnerApi\Reports\Builder\ListReports;
use App\Services\PartnerApi\Reports\Builder\RunReport;
$catalog = app(ListReports::class)();
$result = app(RunReport::class)('students.attendance.monthly', [
'gradeId' => $gradeUlid,
'month' => '2026-04',
]);
حالات استخدام شائعة: لوحات إحصائية، تصدير شهري للأهالي، تقارير المعلمين الدورية.
ساعة الجهة — /api/apps/tenant/clock
بلا نطاق. كل تطبيق مثبَّت يصل إليها بتوكن التثبيت وحده، ولا شيء يُطلب من الجهة.
وهذا قرار لا إغفال: المنطقة الزمنية ليست بيانات الجهة، بل الإطار الذي تُقرأ فيه بياناتها — كاللغة. تطبيقك يستقبل أصلاً طوابع هذه المدرسة الزمنية، فلا شيء هنا يحميه إذن. ووضع نطاق أمامها كان سيشتري صفراً من الخصوصية ويكلّف شيئاً حقيقياً: تطبيقات ترسم الأوقات في منطقة خاطئة لأن أحداً لم يفكّر في طلب منحة لم يفكّر أحد في منحها. وسجلّ حضور بساعة خاطئة لا يبدو مكسوراً — وهذا ما يجعل هذا العطل باهظاً.
| Method | Path | Scope | الوصف |
|---|---|---|---|
| GET | /api/apps/tenant/clock |
— | المنطقة الزمنية التي تقرأ بها الجهة يومها |
شكل الناتج:
{
"timezone": "Asia/Riyadh",
"offset": "+03:00",
"now": "2026-09-02T14:31:07+03:00"
}
خزّن timezone، ولا تخزّن offset أبداً
offset وnow يصفان هذه اللحظة لا هذا المكان. الاسم وحده يحمل القواعد،
فيبقى صحيحاً حين تتغيّر القواعد تحته.
وهذا ليس افتراضاً نظرياً — إحدى الدول المدعومة تعمل بالتوقيت الصيفي:
| المنطقة | يناير | يوليو |
|---|---|---|
Asia/Riyadh |
+03:00 | +03:00 |
Asia/Dubai |
+04:00 | +04:00 |
Africa/Cairo |
+02:00 | +03:00 |
فتطبيق خزّن +02:00 لمدرسة قاهريّة في يناير يصير ساعةً كاملة خطأً في
أبريل — على كل شاشة، بلا خطأ في أي سجلّ، والتاريخ صحيح والصيغة صحيحة والساعة
وحدها مغلوطة. ولو خزّن "Africa/Cairo" لتحرّك معها وحده.
استعمل offset للعرض بجانب وقت، أو لتسليمه لشيء لا يقبل اسم منطقة. لا لشيء
آخر.
التخزين يبقى UTC
كل طابع زمني تُسلّمه المنصّة لك، وكل طابع تُعيده أنت، هو UTC — وهذا لا يتغيّر. هذه النقطة تجيب سؤالاً آخر: ماذا يُعرَض. حوِّل عند العرض، لا عند الكتابة؛ قيمةٌ حُوِّلت وقت الدخول قيمةٌ لا يستطيع أحد مقارنتها بين مدرستين بعد ذلك، والضرر لا يظهر حتى تجتمع مدرستان في تقرير واحد.
من أين تأتي القيمة
المدرسة تختارها من إعداداتها. وإن لم تختر، تُشتقّ من دولتها حين يكون لها منطقة واحدة، وإلا فمن إعداد المنصّة. أنت تحصل على الجواب النهائي دائماً — فلا تعامل غياب اختيار صريح كحالة خاصّة.
مثال Embedded (PHP):
use App\Services\PartnerApi\Tenants\GetTenantClock;
$clock = app(GetTenantClock::class)();
$clock->timezone; // «Asia/Riyadh» — هذا ما يُخزَّن
$clock->offset; // «+03:00» — للعرض فقط
$clock->now; // ISO8601 على ساعة المدرسة
// وموقتٌ مخزَّن UTC مقروءاً على ساعتها:
app(GetTenantClock::class)->at($row->created_at)?->format('Y-m-d H:i');
مثال من تطبيق مصغّر (Dart) — لا يحتاج رمزاً جديداً في okta_host ولا رفع
minContract:
final res = await Okta.get('/api/apps/tenant/clock');
if (res.status == 200) {
final zone = res.body['timezone']; // خزّن هذا
}
حالات استخدام شائعة: طباعة أوقات الحضور كما رآها من سجّلها، تحديد ما هو «اليوم» لدى المدرسة، جدولة تذكير في الصباح المحلّي لا صباح الخادم.
لا تسألها مرّة وتخزّن now. «كم الساعة» سؤالٌ يُسأل حين يُحتاج جوابه؛
والاسم وحده هو ما يستحقّ أن يُحفَظ.
المعرّفات والـcore_reference
كل response يخرج من المنصة يستبدل numeric IDs بـULIDs. الـcore_reference
هو عمود في جدولك أنت يحمل واحداً من هذه المعرّفات: سلسلة 26 حرفاً تشير إلى صف
في جدول منصّة على okta-web (students, subjects, grades, sections,
tenants, tenant_employees, users).
لماذا لا يوجد قيد FK حقيقي: الصف المُشار إليه يعيش في قاعدة بيانات أخرى
على okta-web، وPostgres لا يعرف قيود مفاتيح أجنبية عابرة لقواعد البيانات. فما
تخزّنه هو char(26)/varchar(26) عادي بلا أي قيد — ومن هنا تأتي المشكلة:
char(26) عرض قد تستخدمه لأي شيء آخر (كود قسيمة، رقم فاتورة خارجية، بادئة
hash). العرض وحده ليس دليلاً، ولو عاملت المنصة كل char(26) كمرجع منصّة
لرفضت كتاباتك المشروعة.
ولهذا الحارس لا يخمّن: عليك أن توسم العمود صراحةً في الميغريشن.
الوسم — صيغتان
الصيغة (أ) — تعليق على العمود في كتالوج Postgres (المفضّلة)، لأنها تبقى في قاعدة البيانات الحيّة فتقرأها okta-web وأدوات الفحص لاحقاً:
CREATE TABLE "students" (
"id" BIGSERIAL PRIMARY KEY,
"owner_ulid" CHAR(26) NOT NULL,
"teacher_ulid" CHAR(26)
);
COMMENT ON COLUMN "students"."owner_ulid" IS 'core_reference:tenants';
COMMENT ON COLUMN "students"."teacher_ulid" IS 'core_reference:users';
في ميغريشن Laravel هذه هي ->comment():
$table->string('owner_ulid', 26)->index()->comment('core_reference:tenants');
الصيغة (ب) — تعليق SQL على سطر العمود نفسه، داخل CREATE TABLE أو على
ALTER TABLE … ADD COLUMN من سطر واحد:
CREATE TABLE "students" (
"id" BIGSERIAL PRIMARY KEY,
"owner_ulid" CHAR(26) NOT NULL, -- core_reference: tenants
);
ALTER TABLE "students" ADD COLUMN "teacher_ulid" CHAR(26); -- core_reference: users
الفاصل بين الكلمة والهدف مرن: core_reference: tenants و
core_reference=tenants و core_reference(tenants) كلها مقبولة. و
core_reference بلا هدف تُقبل أيضاً كوسم (قاعدة صيغة الـULID تنطبق مهما كان
الجدول الهدف) — لكن سمِّ الهدف دائماً، فهو ما يظهر في الـ manifest.
ماذا يحدث بلا وسم
لا شيء يُرفَض — وهذه هي النقطة الخطرة. العمود ببساطة لا يراه الحارس، فلا يحميه:
App\Services\Partners\Sandbox\ValidateCoreReferenceWritesلا يعرف أن العمود مرجع منصّة، فيمرّر أي قيمة تكتبها فيه بلا فحص صيغة ULID ولا فحص أن الجدول الهدف مسموح.- كتلة
core_referencesفي الـ manifest تخرج فارغة لأعمدتك غير الموسومة.
الأعمدة بعرض 26 بلا وسم تُصنَّف «مرشّحة» (candidates) وتُعرَض لك في اللوحة — تنبيهاً لا رفضاً. فالفرق بين «هذا الجدول لا يحوي مراجع منصّة» و«قد يحويها ولا نراها» فرق يجب أن تحسمه أنت بالوسم.
ميغريشنات بمسار ملف داخل مستودعك (
.php) لا يمكن قراءة SQL منها في وقت الفحص، فتُحسب ضمنunreadableوتُعرَض على أنها غير مفحوصة — لا تفترض تغطية كاملة معها.
ممنوع (بوسم أو بدونه):
(int) $row->student_id_ulid— كسته إلى integer.where('student_id_ulid', 12345)— مقارنته بـliteral رقمي.
كلاهما يُكشَف من boilerplate scanner ويفشل CI. خزّنه ومرّره دائماً كـstring.
تحديث الكتالوج محلياً (okta-partners)
الكتالوج يُحدَّث تلقائياً من okta-web عبر:
- Cron كل ساعة (افتراضي).
- Webhook فوري على
partner_scopes.catalog.changed. - يدوي:
php artisan partners:sync-scope-catalog --force.
تحديث محلي عبر hash drift detection — لو الـhash لم يتغيّر يتجاوز المزامنة بالكامل.