# X1 — מודל המזהים הקנוני (Canonical Identifier Model)

קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **מזהי הישויות**
של עוזר משפטי. הוא אוכף את [G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה) (מזהה
קנוני מנורמל בכתיבה) ומעמיק את [INV-DM2](02-data-model.md#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות)
מ-[02-data-model.md](02-data-model.md). שני הקבצים חייבים להישאר עקביים: 02 מגדיר *אילו*
שדות מזהים כל ישות; X1 מגדיר את *הצורה הקנונית* של המזהה ו*איך* הוא מנורמל.

> **TARGET, לא תיאור-מצב.** המודל כאן הוא היעד הקנוני. כל מקום שבו הקוד בפועל
> (`mcp-server/src/legal_mcp/services/db.py`) סוטה ממנו — מתועד כ-**audit-finding** (§4),
> תסמין, לא התנהגות תקינה. כל טענה על הקוד הקיים מצוטטת `file:line` ואינה מונחת כתקינה.

---

## 1. הצורה הקנונית של `case_number`

מזהה-התיק (`case_number`) הוא **מספר-תיק מנורמל** — לא מחרוזת-ציטוט, לא תווית-תצוגה. הצורה
הקנונית מוגדרת ע"י **נרמול בנקודת-הכתיבה** (write-time canonicalization), כך שכל הרשומות
חולקות פורמט יחיד והשוואה היא תמיד שוויון-מחרוזת מול הצורה הקנונית.

**הנרמול הקנוני (TARGET — מופעל בכתיבה):**

| צעד | פעולה | דוגמה |
|------|--------|--------|
| trim | הסרת רווחים מקיפים | `"  8137/24 "` → `"8137/24"` |
| prefix-strip | הסרת קידומת-הליך לפני הספרה הראשונה ("ערר", "בל\"מ", "עע\"מ") | `"ערר 8137/24"` → `"8137/24"` |
| separator | איחוד מפריד `/` → `-` | `"8137/24"` → `"8137-24"` |

> **הצורה הקנונית = המספר הרשמי שהוקצה ע"י הוועדה, נשמר ככתבו** — לרבות מקטע-החודש **כשהוקצה**
> (למשל `8126-03-25`). מספרי-מורשת מסוימים הוקצו **ללא** חודש (למשל `8126-25`); המערכת **אסור**
> שתמציא או תוסיף (pad) מקטע-חודש שמעולם לא הוקצה. הנרמול-בכתיבה הוא **פורמט-בלבד ודטרמיניסטי**
> (trim · `/`→`-` · prefix-strip) — הוא **אינו מוסיף ואינו מסיר** מקטע-חודש. הפורמט המועדף
> מכאן-ואילך כולל את החודש.

> סוג-ההליך (`proceeding_type ∈ {ערר, בל"מ}`) הוא **חלק מהמפתח הקנוני** — לא חלק ממחרוזת
> ה-`case_number`. הקידומת "ערר"/"בל\"מ" מהכותרת נשללת מהמספר ונשמרת בעמודה ייעודית
> (`cases.proceeding_type`, `db.py:912`). כך "ערר 8137/24" ו-"בל\"מ 8137/24" הם שתי
> רשומות מובחנות בעלות אותו `case_number=8137-24` ו-`proceeding_type` שונה.

### 1א. אורך-הסידורי — אות לסוג-ההליך (נוהל-יו"ר, 2026-06-11)

מבנה ה-`case_number` הוא `<סידורי>-<חודש>-<שנה>` (serial-month-year). **אורך הסידורי מקודד
את סוג-ההליך:**

| אורך סידורי | סוג-הליך | דוגמה | הערה |
|-------------|----------|-------|------|
| 4 ספרות | **ערר** | `1230-04-26` | הליך עיקרי |
| 5 ספרות | **בל"מ** | `85074-09-24` | בקשה להארכת מועד |

- **הספרה הראשונה ממשיכה לקודד את התחום בשני האורכים** — `1→רישוי`, `8→היטל השבחה`,
  `9→פיצויים ס'197` (INV-DM/practice_area). תיק בל"מ `85074` → תחום היטל-השבחה.
- **הכלל חד-כיווני:** סידורי בן 5 ספרות **הוא** בל"מ (אות אוטומטי, `is_blam_by_number`,
  `practice_area.py`). סידורי בן 4 ספרות **אינו** מחייב ערר — בל"מ-מורשת בן 4 ספרות עדיין
  מזוהה מהנושא (`is_blam_subject`). הרקע: ירושלים אימצה מספור 5-ספרתי לבל"מ רק עכשיו; ת"א
  מזה זמן (למשל `81002-01-21`).
- **פתיחת תיק חדש מחייבת את צורת serial-month-year המלאה** (כולל חודש) — ולידציית-הכתיבה
  (`web-ui/src/lib/schemas/case.ts`) דוחה את צורת המורשת ללא-חודש. ההתאמה-הסלחנית-בקריאה
  (§3) עדיין בולעת רשומות-מורשת בנות שתי-חוליות לצורך *חיפוש*, לא לצורך *יצירה*.

**נרמול-בכתיבה הוא המנגנון הראשי; התאמה-סלחנית-בקריאה היא נוחות משנית בלבד.** כלל-ההנדסה
"נרמול לא תיקון-תסמין" (חוקה §6) קובע: מתקנים את הנתון במקור, לא מטליאים בקריאה. אם רשומה
נשמרה בצורה לא-קנונית — היעד הוא לנרמל אותה במיגרציה/בכתיבה, **לא** לסמוך על מנוע-קריאה
שיגשר על הפער. ההתאמה-הסלחנית (§3) קיימת כדי לבלוע *קלט-משתמש* רב-צורני (כותרת Paperclip),
לא כדי לתרץ נתון-מאוחסן לא-קנוני.

---

## 2. שני מרחבי-מזהים: `cases` מול `case_law`

`case_number` מופיע בשתי טבלאות נפרדות עם **שני מרחבי-מזהים שונים** ו**ללא FK חוצה-טבלאות**
ביניהן. בלבול בין השניים הוא כשל-שורש: תיק חי אינו תקדים, ולהפך.

| ממד | `cases` (תיק חי) | `case_law` (קורפוס פסיקה) |
|------|------------------|---------------------------|
| תפקיד | הערר שבטיפול כעת (1xxx/8xxx/9xxx) | תקדים — פסיקה חיצונית **וגם** החלטות-ועדה |
| מפתח קנוני | `(case_number, proceeding_type)` | `(case_number, source_kind, proceeding_type)` — ראה להלן |
| אילוץ-ייחודיות | `uq_cases_number_proc` על `(case_number, proceeding_type)` (`db.py:923-924`) | שני partial unique לפי `source_kind` (`db.py:904-909`) |
| מורשת (הוסרה) | `case_number TEXT UNIQUE NOT NULL` (`db.py:76`), הוסר V15 (`db.py:921-922`) | `case_number TEXT UNIQUE NOT NULL` (`db.py:368`), הוסר V15 (`db.py:902-903`) |
| FK חוצה | **אין** — `cases` ו-`case_law` הם מרחבים נפרדים | **אין** |

**`case_law` — מזהה מודע-source_kind.** ה-V15 החליפה את `UNIQUE(case_number)` הגלובלי בשני
partial unique indexes (`db.py:904-909`):

- **`internal_committee`** (החלטות-ועדה פנימיות): `UNIQUE(case_number, proceeding_type)`
  — `uq_case_law_internal_number_proc`, `WHERE source_kind = 'internal_committee'`.
- **חיצוני** (`external_upload` / `cited_only` / `nevo_seed`): `UNIQUE(case_number)`
  — `uq_case_law_external_number`, `WHERE source_kind <> 'internal_committee'`.

לכן המזהה הקנוני של `case_law` הוא הטריפלט **(`case_number` מנורמל, `source_kind`,
`proceeding_type`)** — עקבי עם [02-data-model §2א](02-data-model.md#2א-case_law--החוזה-הקונקרטי).

**אין הצמדה חוצה-טבלאות.** כשהחלטת-תיק מ-`cases` מצוטטת בהמשך כתקדים, היא נכנסת ל-`case_law`
כרשומה *חדשה* (`source_kind='internal_committee'`) — לא כ-FK ל-`cases`. שני המרחבים נשארים
עצמאיים; הגישור ביניהם הוא דרך הקליטה ([01-ingest.md](01-ingest.md)), לא דרך מפתח-זר.

---

## 3. ציטוט מול מזהה — `citation_formatted` הוא תצוגה, לא מפתח

הציטוט-המלא והמזהה-הקנוני הם **שני שדות נפרדים בכוונה**:

- **מזהה קנוני** = `case_number` מנורמל (`8126-03-25`) — המפתח שמשמש לחיפוש, ל-upsert,
  ולאילוצי-ייחודיות.
- **ציטוט מעוצב** = `citation_formatted` (`db.py:1070`, V19) — מחרוזת-תצוגה לפי כללי-הציטוט
  האחיד, למשל: `ערר (ועדות ערר - תכנון ובנייה ת"א-יפו) 81002-01-21 **אברהם אגסי נ' הועדה
  המקומית** (נבו 25.9.2025)` (`db.py:1067-1068`).

הציטוט הוא **שדה נגזר לתצוגה** — מכיל את המזהה אך גם צדדים, ערכאה, ותאריך-פרסום. הוא **לעולם
אינו המפתח**. אחסון מחרוזת-ציטוט בשדה-המזהה שובר את הנרמול ([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)),
מערבב תצוגה עם זהות (פוגע ב-1NF — ערך לא-אטומי בשדה-מפתח), ומונע התאמת-שוויון מול המספר
המנורמל.

---

## 4. Invariants של התחום

### INV-ID1: `case_number` מנורמל בכתיבה — התאמה-סלחנית משנית
**כלל:** `case_number` מנורמל לצורה קנונית יחידה **בנקודת-הכתיבה** בנרמול **פורמט-בלבד
ודטרמיניסטי** (trim · prefix-strip · `/`→`-`) — הנרמול **אינו ממציא ואינו מוסיף** מקטע-חודש
שלא הוקצה. הצורה הקנונית היא **המספר הרשמי שהוקצה** (עם חודש כשהוקצה, למשל `8126-03-25`),
והשוואה-בקריאה היא שוויון מול הצורה הקנונית. **התאמה-סלחנית-בקריאה היא
נוחות משנית בלבד** — היא בולעת קלט-משתמש רב-צורני, ואינה תחליף לנרמול-בכתיבה ([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה),
כלל-ההנדסה "נרמול לא תיקון-תסמין", חוקה §6).
**מקורות:** SSOT (Single Source of Truth — normalization principle) · E.F. Codd, First Normal
Form (CACM 13(6), 1970) · Martin Kleppmann, *Designing Data-Intensive Applications* (O'Reilly,
2017) | סטטוס: verified
**אכיפה:** נרמול-בכתיבה בנקודת-הקליטה ([01-ingest.md](01-ingest.md)) + אילוצי-ייחודיות על
המפתח הקנוני (`uq_cases_number_proc`, `db.py:923-924`; partial unique `case_law`, `db.py:904-909`).
**הפרה ידועה:** `_normalize_case_number` (`db.py:1196-1211`) מנרמל **בקריאה בלבד** ("tolerant
lookup", `db.py:1197`), ו-`get_case_by_number` (`db.py:1214-1231`) משווה two-pass (`case_number=$1`
**OR** `replace(btrim(case_number),'/','-')=$2`, `db.py:1223-1224`) — אין מסלול-כתיבה שמקנן את
הערך המאוחסן. בנפרד מכך: כשאותו תיק נקלט גם בצורה ללא-חודש וגם עם-חודש (סחף-הזנה, למשל `8126-25`
מול `8126-03-25` המתייחסים לתיק אחד), הצורה **עם-החודש (הרשמית) היא הקנונית** והרשומה החסרה
מתואמת אליה — זו **בעיית-תיאום (reconciliation)**, לא חולשה בנרמול (הנרמול אינו אמור לפדד חודש).
תיאום רשומות-מורשת מעורבות-צורה הוא **פריט ניקיון-נתונים/מיגרציה חד-פעמי** (ראה
[gap-audit / תת-פרויקט 2](../audit-report.md)), לא אלגוריתם-padding בזמן-ריצה → ממצא
ל-[audit](../audit-report.md).

### INV-ID2: אין ציטוט-מלא כמזהה — הציטוט שדה-תצוגה נגזר
**כלל:** אף ישות **אינה** משתמשת במחרוזת-ציטוט-מלאה כמזהה. שדה-המזהה מכיל מספר-תיק מנורמל
בלבד; הציטוט-המלא חי בשדה ייעודי נפרד (`citation_formatted`, `db.py:1070`) ככלי-תצוגה נגזר
([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה), [INV-DM2](02-data-model.md#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות)).
**מקורות:** SSOT (Single Source of Truth — normalization principle) · E.F. Codd, First Normal
Form (CACM 13(6), 1970) · Martin Kleppmann, *Designing Data-Intensive Applications* (O'Reilly,
2017) | סטטוס: verified
**אכיפה:** הפרדת-שדות ב-schema — מזהה ב-`case_number` (אילוצי-ייחודיות, `db.py:904-909,923-924`),
ציטוט ב-`citation_formatted` בלבד (`db.py:1070`); נרמול-בכתיבה שדוחה מחרוזת-ציטוט בשדה-המזהה.
**הפרה ידועה:** החלטות "סופר" נקלטו עם **ציטוט-מלא מאוחסן כ-`case_number`** (שדה-המזהה מכיל
את מחרוזת-הציטוט במקום מספר-תיק מנורמל) — חיפוש מול המספר המנורמל נכשל, והפער מתגלגל ל-INV-ID1
(`_normalize_case_number` רק מטליא בקריאה) → ממצא ל-[audit](../audit-report.md).

---

## 5. מצב קיים מול יעד — audit-findings

ההבדלים בין הקוד בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול `db.py`.

- **נרמול בצד-הקריאה בלבד.** `_normalize_case_number` (`db.py:1196-1211`) מתואר במפורש כ-
  "tolerant lookup" (`db.py:1197`) — מסיר קידומת לפני הספרה הראשונה, trim, ו-`/`→`-` — אך
  **אינו מנרמל את הערך המאוחסן**. `get_case_by_number` (`db.py:1214-1231`) בונה סביבו two-pass
  (exact `OR` normalized, `db.py:1223-1224`). **תסמין:** הנרמול חי כתיקון-תסמין בקריאה ולא
  כקנוניזציה-בכתיבה, בניגוד ל-[G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה) וכלל-ההנדסה
  §6. **יעד:** מסלול-כתיבה שמנרמל את `case_number` (פורמט-בלבד: trim/prefix-strip/`/`→`-`,
  **ללא המצאת חודש**) בנקודת-הקליטה; הקריאה הופכת להשוואת-שוויון פשוטה.
- **רשומות-מורשת מעורבות-צורה (בעיית-תיאום, לא padding).** כשאותו תיק נקלט גם כ-`8126-25`
  וגם כ-`8126-03-25` (סחף-הזנה), ה-two-pass אינו מזהה אותם כתיק אחד. **יעד:** תיאום חד-פעמי
  של הרשומות לצורה הרשמית עם-החודש (הקנונית) במסגרת ניקיון-נתונים/מיגרציה
  ([gap-audit / תת-פרויקט 2](../audit-report.md)) — **לא** אלגוריתם-padding בזמן-ריצה שממציא חודש.
- **ציטוט-מלא כ-`case_number` (מורשת).** השדה המקורי `case_number TEXT UNIQUE NOT NULL`
  (`cases` `db.py:76`, `case_law` `db.py:368`) לא אכף צורה — מה שאפשר אחסון מחרוזת-ציטוט בשדה
  זה (החלטות "סופר"). הוחלף ב-partial unique מודע-`source_kind` ב-V15 (`db.py:902-909`), אך
  **ללא ולידציית-צורה בכתיבה**. **יעד:** ולידציית-כתיבה שדוחה ערך שאינו מספר-תיק מנורמל ומפנה
  ציטוט ל-`citation_formatted`.
- **שני מרחבי-מזהים, סיכון-בלבול בקוד-קריאה.** `get_case_by_number` (`db.py:1214`) פונה
  ל-`cases` בלבד; `get_case_law_by_citation` (`db.py:2503`) פונה ל-`case_law` בלבד — נכון, אך
  שמות-הפונקציות אינם מבדילים את מרחב-המזהים בבירור. **יעד:** תיעוד מפורש (קובץ זה) + עקביות
  שמות שמשקפת `cases` מול `case_law` כשני מרחבים נפרדים ללא FK.

---

## 6. הפניות-אחיות

- [00-constitution.md](00-constitution.md) — [G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)
  (מזהה קנוני מנורמל בכתיבה) + כלל-ההנדסה "נרמול לא תיקון-תסמין" (§6).
- [02-data-model.md](02-data-model.md) — [INV-DM2](02-data-model.md#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות)
  (מזהה קנוני יחיד) + החוזה הקונקרטי של `case_law`; X1 הוא ה-deep-dive על אותו מזהה.
- [01-ingest.md](01-ingest.md) — נקודת-הכתיבה שבה הנרמול-בכתיבה צריך להיאכף.
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור (הציטוט כשדה-תצוגה נגזר).
