---
phase: rules
kind: level
level: elaborate
---

# Level 2 — ELABORATE

> Turn a classified rule (Level 1) into a vibecoding-ready specification:
> a natural `Condition`, a pseudo-code `Expression`, an error code, and
> **at least one valid + one invalid example**. This is the most important
> level — the quality of the generated code and tests depends on it. Persist
> by re-Writing the scope's `règles-métier.md` (see `SKILL.md`).

## Anatomy of a complete rule

### Condition — natural language QUAND…ALORS / WHEN…THEN
Always `QUAND <trigger> ALORS <constraint>`. Unambiguous, no passive voice,
reference fields by name.

| Weak | Strong |
|------|--------|
| "Users are validated" | "QUAND un utilisateur est créé/modifié ALORS son email doit être unique parmi les non-supprimés" |
| "Status check" | "QUAND le statut d'une commande change ALORS le nouveau statut doit être un successeur valide" |
| "Auto compute" | "QUAND une ligne de facture est ajoutée ALORS sous-total = quantité × prix unitaire" |

### Expression — pseudo-code
Compact, deterministic, a developer can implement without guessing (SQL-like,
C#-like or math). It is a hint, not a contract.

```
// uniqueness
NOT EXISTS(SELECT 1 FROM users WHERE LOWER(email)=LOWER(@email) AND id!=@id AND deleted_at IS NULL)
// calculation
vatAmount = round(baseAmount * vatRate, 2)
// state transition
@to IN { draft:[submitted,cancelled], submitted:[shipped,cancelled], shipped:[delivered] }[@from]
// ownership
@user.id == @ticket.assigneeId OR 'manager' IN @user.roles
```

### Error code — stable, namespaced
`lower.dotted` (matching the module, e.g. `pipeline.discount.cap`) or
`UPPER_SNAKE` (`USER_EMAIL_DUPLICATE`). Namespaced by module/entity.

## Examples — the heart of the rule

Each rule MUST have at least **1 valid** and **1 invalid** example. Non-negotiable
(both the success and the failure path are needed to generate meaningful tests).
Write them as concrete one-liners under `## Cas valides` / `## Cas invalides`, in
Given/When/Then form when it helps:

```
- Email unique : aucun utilisateur 'alice@x.com' n'existe → POST /users {email:'alice@x.com'} → 201 créé.
```

### Two patterns: enforcement vs filter/visibility

**Enforcement rule** — declares an error code. The invariant rejects an action;
the invalid example names the error (e.g. "→ 409 `USER_EMAIL_DUPLICATE`").
Examples: uniqueness, illegal transition, ownership check.

**Filter / visibility rule** — **no error code** (leave it blank). The invariant
silently excludes records (repository WHERE clause, soft-delete guard,
anonymisation tombstone, tenant isolation). There is no user-facing error — the
record is simply absent. The invalid example describes the **observable absence**
("le contact n'apparaît PAS dans les résultats — filtré au niveau repository"),
never a fake error.

| Pattern | Rule error code | Invalid example |
|---------|:---------------:|-----------------|
| Enforcement | present | names the expected error |
| Filter/visibility | blank | observable absence, no error |

### Numbering rules — the extra spec

A `numbering` rule defines how an entity's business code/reference is
**generated** (not merely validated). Beyond the standard fields, capture in the
Condition + Expression:

- **Format** — a template of literals + tokens, e.g. `AFF-{YY}-{SEQ:4}` (`{YY}` /
  `{YYYY}` year, `{MM}` month, `{SEQ:n}` zero-padded counter, `{TENANT}`…).
- **Sequence scope** — what the counter is partitioned by: `tenant` (default for
  tenant-isolated data), `global` (platform-wide), or `parent:<Entity>` (sub-count
  inside a parent). MUST be consistent with the entity's `tenantMode`.
- **Reset** — when the counter restarts: `none` / `yearly` / `monthly` / `daily`.
  A reset MUST align with a matching date token in the format (yearly ⇒ `{YY}`).
- **Gapless** — `true` (no gaps allowed — legal/accounting; needs transactional
  allocation) or `false` (gaps tolerated — faster, e.g. on rollback).
- **Immutable** — once assigned the code never changes (state it in the rule).

These map 1:1 to the data-model `codePattern` (`create-data-model`) and drive the
sequence allocator the Studio provides at runtime. The valid examples MUST show the
sequence **progressing** and the **reset boundary**; an invalid example MUST show
**no duplicate** under concurrency / across tenants.

```markdown
### BR-007 — Numéro de demande
- **Type** : numbering
- **Sévérité** : err
- **Portée** : RH / DEMANDES
- **Condition** : QUAND une demande est créée ALORS son numéro = `AFF-{YY}-{NNNN}`
  (séquence par tenant, remise à zéro chaque année, sans trou, immuable).
- **Expression** : `reference = "AFF-" + fmt(year,"yy") + "-" + pad(nextSeq(scope=tenant, reset=yearly), 4)`
- **Code d'erreur** : `demande.reference.duplicate`
- **Cas valides** :
  - 1re demande 2026 (tenant A) → `AFF-26-0001` ; suivante → `AFF-26-0002`.
  - 1re demande 2027 → `AFF-27-0001` (reset annuel).
  - 1re demande tenant B en 2026 → `AFF-26-0001` (séquence isolée par tenant).
- **Cas invalides** :
  - Deux créations concurrentes → JAMAIS le même numéro (allocation atomique).
  - Numéro modifié après création → rejeté (immuable).
- **Cas d'usage liés** : UC-RH-DEMANDES-001 (postcondition)
```

### Coverage — aim for 3–5 on rules with edge cases
1 valid happy path · 1 invalid clear failure · 1 invalid edge case · 1 valid edge
case on the right side. Pick 2–3 relevant edge cases per type:

| Type | Edge cases worth covering |
|------|---------------------------|
| `validation` | null, empty, max length, format mismatch, case-insensitive duplicate, whitespace-only |
| `calculation` | zero, negatives, rounding (2 vs 4 decimals), currency conversion, division by zero |
| `state-transition` | same-state (idempotent?), skipped state, concurrent transition, terminal state, re-entry |
| `ownership` | owner ok, manager bypass, deactivated owner, ownership transfer, service account |
| `constraint` | exactly at limit, just-above, just-below, zero, negative, null vs 0 |
| `derivation` | missing/empty inputs, recompute on update vs create |
| `workflow` | threshold exactly at boundary, multiple approvers, delegation, timeout |
| `integrity` | soft-deleted parent, cascade delete, circular/self FK, orphaned children |
| `cross-cutting` | system operation with no user, batch of N, async, retry |
| `compliance` | retention expiry, erasure conflict, cross-border transfer, retroactive change |
| `numbering` | first-of-period, sequence progression, per-tenant isolation, reset boundary (year rollover), concurrent allocation (no duplicate/gap if gapless) |

Application-level rules: cover inter-module scenarios (audit trail across modules
A→B). Module-level: show both authorized and unauthorized access patterns.

## Worked example — a fully elaborated rule (doc form)

```markdown
### BR-001 — Email unique
- **Type** : validation
- **Sévérité** : err
- **Portée** : HR / EMPLOYEES
- **Condition** : QUAND un utilisateur est créé/modifié ALORS son email ne doit
  matcher aucun email d'utilisateur non-supprimé (insensible à la casse).
- **Expression** : `NOT EXISTS(SELECT 1 FROM users WHERE LOWER(email)=LOWER(@email) AND id!=@id AND deleted_at IS NULL)`
- **Code d'erreur** : `USER_EMAIL_DUPLICATE`
- **Cas valides** :
  - Aucun 'alice@x.com' → créer {email:'alice@x.com'} → 201 créé.
  - Email d'un utilisateur soft-deleted réutilisable → 201 créé (tombstone).
- **Cas invalides** :
  - 'bob@x.com' existe → créer {email:'bob@x.com'} → 409 `USER_EMAIL_DUPLICATE`.
  - 'BOB@X.COM' → 409 `USER_EMAIL_DUPLICATE` (comparaison insensible à la casse).
- **Cas d'usage liés** : UC-HR-EMPLOYEES-DIRECTORY-001 (précondition)
```

Filter-rule variant — `Code d'erreur` blank, invalid case = absence:

```markdown
### BR-014 — Contacts anonymisés exclus des recherches
- **Type** : constraint
- **Sévérité** : err
- **Portée** : CRM / CONTACTS
- **Condition** : QUAND une recherche de contacts s'exécute ALORS les contacts
  avec `anonymizedAt` non nul sont exclus.
- **Expression** : `WHERE contact.anonymizedAt IS NULL`
- **Code d'erreur** : —
- **Cas valides** : contact actif (anonymizedAt null) → présent dans la réponse.
- **Cas invalides** : contact anonymisé → ABSENT de la réponse (filtré au
  niveau repository, aucune erreur renvoyée).
- **Cas d'usage liés** : —
```

## Workflow
1. Pick the next rule with no examples (or no condition).
2. Fill `Condition`, `Expression`, `Code d'erreur` (blank for filter rules).
3. Write the valid example(s) first (the happy path), then the invalid one(s).
4. Review: could a developer with no context turn this into code AND a test? If
   not, add detail.
5. Repeat for all rules at the scope, then re-Write the doc with the full set.

## Ask the user only when truly ambiguous
You usually have enough context to draft. Ask (via AskUserQuestion, 2–4 closed
options) only for: a choice between two plausible error codes; a threshold
constant ("max discount: 50 / 100 / no limit"); or an edge-case decision
("soft-deleted users still block email reuse, or release it?").

## Quality checks before writing
For every rule: `Condition` is `QUAND…ALORS`; `Expression` is non-empty; the
pattern is identified (enforcement → error code + invalid examples name it;
filter → error code blank + invalid examples describe absence); every example has
a concrete given/when/then; at least 1 valid + 1 invalid; a developer could code
it and test it.
