docs(merise): extend MCD with security-by-design entities and percentage stock model

Adds audit_log + login_throttle; security columns on user/customer_order; fixes
product_ingredient drift (quantity -> quantity_normal/quantity_maxi); percentage stock
model and computed product availability. 21 entities, cross-validation 21/21.
This commit is contained in:
Imugiii 2026-06-12 09:29:51 +00:00
parent fadf0bd630
commit a1692b6b80

View file

@ -1,10 +1,10 @@
# Conceptual Data Model (MCD) — Wakdo # Conceptual Data Model (MCD) — Wakdo
**Merise phase** : P1 - Conception, step 2 (data dictionary first, mantra #33) **Merise phase** : P1 - Conception, step 2 (data dictionary first, mantra #33)
**Version** : v0.2 — prod-like, 19 entities **Version** : v0.2 — prod-like, 21 entities (19 prod-like + security-by-design layer)
**Date** : 2026-06-04 **Date** : 2026-06-04 (security-by-design additions 2026-06-11)
**Branch** : `feat/p1-conception` **Branch** : `feat/p1-conception`
**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) **Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer (audit_log + accountability/auth columns) in progress
**Author** : BYAN (methodology layer) **Author** : BYAN (methodology layer)
--- ---
@ -21,7 +21,7 @@ structure: how many X per Y, whether participation is mandatory, whether associa
their own attributes. their own attributes.
**Sources**: **Sources**:
- `docs/merise/dictionary.md` (v0.2 — 19 entities, source of truth for all names, types, ENUMs) - `docs/merise/dictionary.md` (v0.2 — 21 entities, source of truth for all names, types, ENUMs)
- `docs/notes/revue-alignement-p1.md` §7 (decision table D1-D8 + stock) - `docs/notes/revue-alignement-p1.md` §7 (decision table D1-D8 + stock)
- `docs/PROJECT_CONTEXT.md` (business rules: menu composition, order flow, RBAC, service modes) - `docs/PROJECT_CONTEXT.md` (business rules: menu composition, order flow, RBAC, service modes)
- `docs/merise/_sources/` (school data: 9 categories, 53 products, 13 menus) - `docs/merise/_sources/` (school data: 9 categories, 53 products, 13 menus)
@ -62,7 +62,7 @@ N-N associations that carry their own attributes become **associative entities**
## 3. Decomposition by sub-domain ## 3. Decomposition by sub-domain
The 19-entity model is split into 4 sub-domains for readability. Beyond approximately The 21-entity model is split into 4 sub-domains for readability. Beyond approximately
5 entities, a single flat diagram becomes difficult to read; decomposition is the standard 5 entities, a single flat diagram becomes difficult to read; decomposition is the standard
Merise practice for models of this size. Merise practice for models of this size.
@ -71,9 +71,17 @@ Merise practice for models of this size.
| Catalogue | category, product, menu, menu_slot, menu_slot_option | 5 | | Catalogue | category, product, menu, menu_slot, menu_slot_option | 5 |
| Ingredients & Stock | ingredient, product_ingredient, allergen, ingredient_allergen, stock_movement | 5 | | Ingredients & Stock | ingredient, product_ingredient, allergen, ingredient_allergen, stock_movement | 5 |
| Order | customer_order, order_item, order_item_selection, order_item_modifier | 4 | | Order | customer_order, order_item, order_item_selection, order_item_modifier | 4 |
| RBAC | user, role, role_visible_source, permission, role_permission | 5 | | RBAC & Audit | user, role, role_visible_source, permission, role_permission, audit_log, login_throttle | 7 |
**Note on the absence of a global diagram**: a single 19-entity ER diagram would be > **Security-by-design layer (2026-06-11)**: `audit_log` (entity 20) is a cross-cutting,
> append-only log of sensitive actions; it is placed in the RBAC & Audit sub-domain because
> its references (`actor_user_id`, `actor_role_id`) are RBAC entities. `login_throttle`
> (entity 21) is a per-source-IP brute-force throttle, keyed by IP and carrying no FK; it sits
> in the same sub-domain because it guards the authentication path. New columns on existing
> entities: `user` auth-lifecycle + `pin_hash` + `anonymized_at`, `customer_order.acting_user_id`
> + `idempotency_key`. See dictionary note 13.
**Note on the absence of a global diagram**: a single 21-entity ER diagram would be
unreadable and unmaintainable. The sub-domain decomposition below is the intentional unreadable and unmaintainable. The sub-domain decomposition below is the intentional
structural choice. The `.drawio` source files will be regenerated from this document as the structural choice. The `.drawio` source files will be regenerated from this document as the
single reference once the MCD is stabilised (regeneration tracked in `docs/notes/`). single reference once the MCD is stabilised (regeneration tracked in `docs/notes/`).
@ -174,15 +182,18 @@ erDiagram
varchar name varchar name
varchar unit varchar unit
int stock_quantity int stock_quantity
int stock_capacity
smallint pack_size smallint pack_size
varchar pack_label varchar pack_label
smallint low_stock_threshold smallint low_stock_pct
smallint critical_stock_pct
tinyint is_active tinyint is_active
} }
product_ingredient { product_ingredient {
int product_id FK int product_id FK
int ingredient_id FK int ingredient_id FK
smallint quantity smallint quantity_normal
smallint quantity_maxi
tinyint is_removable tinyint is_removable
tinyint is_addable tinyint is_addable
int extra_price_cents int extra_price_cents
@ -238,13 +249,15 @@ erDiagram
### 5.3 Notes on the Ingredients & Stock sub-domain ### 5.3 Notes on the Ingredients & Stock sub-domain
**`product_ingredient` as an associative entity**: the N-N association between `product` and `ingredient` carries four attributes (`quantity`, `is_removable`, `is_addable`, `extra_price_cents`). It becomes a join table in the MLD with composite PK `(product_id, ingredient_id)`. **`product_ingredient` as an associative entity**: the N-N association between `product` and `ingredient` carries five attributes (`quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`). It becomes a join table in the MLD with composite PK `(product_id, ingredient_id)`.
**`ingredient_allergen` as a pure join table**: no own attributes. The allergen set for a product is computed at query time by joining `product_ingredient -> ingredient_allergen -> allergen`; no manual per-product entry is needed. **`ingredient_allergen` as a pure join table**: no own attributes. The allergen set for a product is computed at query time by joining `product_ingredient -> ingredient_allergen -> allergen`; no manual per-product entry is needed.
**`stock_movement` immutability**: this table is append-only. No UPDATE or DELETE is permitted at application layer. Corrections are new rows with `movement_type = 'inventory_correction'` and a signed `delta`. **`stock_movement` immutability**: this table is append-only. No UPDATE or DELETE is permitted at application layer. Corrections are new rows with `movement_type = 'inventory_correction'` and a signed `delta`.
**Low-stock alert**: computed at display time (`stock_quantity <= low_stock_threshold`); no additional stored column. **Percentage-based stock model**: stock health is anchored on a per-ingredient `stock_capacity` (the 100% reference, `CHECK > 0`). `stock_quantity` is signed and may go negative when sales outrun counted stock; the system does not block an order on a low stock read. `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` is computed, not stored. Two percentage thresholds drive a three-band behaviour: `low_stock_pct` (warning band, default 10%) and `critical_stock_pct` (auto-out-of-stock floor, default 5%), with the table-level invariant `critical_stock_pct < low_stock_pct`. Above the low band is normal; between critical and low the product stays orderable and a manager alert is raised (the manager either pulls the product via `product.is_available = 0` or restocks to clear the alert); at or below the critical band the product auto-goes out-of-stock (computed, see below).
**Computed product availability (rule RG-T21, see `mlt.md`)**: effective orderability is derived, not stored. A product is orderable when `product.is_available = 1` AND each non-removable (`is_removable = 0`) ingredient in its `product_ingredient` has `stock_quantity > stock_capacity * critical_stock_pct / 100`. A required ingredient reaching the critical band makes the product auto-out-of-stock with no write and no cascade; a manual pull (`product.is_available = 0`) is a hard override; restock above the critical band makes the product orderable again on its own. A removable/optional ingredient at the critical band does not block the product (only its add-on becomes unavailable). The dashboard distinguishes a manual pull from a stock-driven OOS.
--- ---
@ -257,7 +270,9 @@ erDiagram
customer_order { customer_order {
int id PK int id PK
varchar order_number varchar order_number
varchar idempotency_key
enum source enum source
int acting_user_id FK
enum service_mode enum service_mode
enum status enum status
int total_ht_cents int total_ht_cents
@ -358,6 +373,12 @@ the MLD).
`preparing` and `ready` are dropped (decision D4, `revue-alignement-p1.md` §7). KPI timing is `preparing` and `ready` are dropped (decision D4, `revue-alignement-p1.md` §7). KPI timing is
`delivered_at - paid_at`; KDS colour coding is computed from `NOW() - paid_at`. `delivered_at - paid_at`; KDS colour coding is computed from `NOW() - paid_at`.
**Security-by-design columns (2026-06-11)**: `idempotency_key` (client UUID, UNIQUE)
deduplicates a retried `POST /api/orders`. `acting_user_id` (FK -> `user`, ON DELETE SET NULL)
records the counter/drive staff who took the order under PIN; NULL for anonymous kiosk orders.
This adds a `customer_order |o--o| user : "taken_by"` association (cardinality: an order is
taken by (0,1) user; a user takes (0,N) orders). See dictionary note 13.
--- ---
## 7. Sub-domain: RBAC ## 7. Sub-domain: RBAC
@ -370,11 +391,15 @@ erDiagram
int id PK int id PK
varchar email varchar email
varchar password_hash varchar password_hash
varchar pin_hash
varchar first_name varchar first_name
varchar last_name varchar last_name
int role_id FK int role_id FK
tinyint is_active tinyint is_active
datetime last_login_at datetime last_login_at
smallint failed_login_attempts
datetime lockout_until
datetime anonymized_at
} }
role { role {
int id PK int id PK
@ -399,13 +424,38 @@ erDiagram
int role_id FK int role_id FK
int permission_id FK int permission_id FK
} }
audit_log {
int id PK
int actor_user_id FK
int actor_role_id FK
varchar action_code
varchar entity_type
int entity_id
varchar summary
json details
datetime created_at
}
login_throttle {
int id PK
varchar ip_address UK
smallint failed_attempts
datetime window_started_at
datetime lockout_until
datetime last_attempt_at
}
user }o--|| role : "holds" user }o--|| role : "holds"
role ||--o{ role_visible_source : "sees_source" role ||--o{ role_visible_source : "sees_source"
role ||--o{ role_permission : "grants" role ||--o{ role_permission : "grants"
permission ||--o{ role_permission : "granted_to" permission ||--o{ role_permission : "granted_to"
user |o--o{ audit_log : "performs"
role |o--o{ audit_log : "context_of"
``` ```
> `login_throttle` is a standalone entity with no association: it is keyed by source IP
> (`ip_address UNIQUE`), not by a modelled actor, so it carries no FK and connects to no
> other entity in the diagram.
### 7.2 Association cardinalities ### 7.2 Association cardinalities
| # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification | | # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification |
@ -414,6 +464,8 @@ erDiagram
| R2 | sees_source | role | (0,N) | role_visible_source | (1,1) | A role may see 0 or more order sources on the preparation dashboard (admin/manager use a global view with no source filter). Each visibility row belongs to exactly one role. | | R2 | sees_source | role | (0,N) | role_visible_source | (1,1) | A role may see 0 or more order sources on the preparation dashboard (admin/manager use a global view with no source filter). Each visibility row belongs to exactly one role. |
| R3 | grants | role | (0,N) | role_permission | (1,1) | A role may have no permissions (a newly created role before assignment) or many. Each mapping row belongs to one role. | | R3 | grants | role | (0,N) | role_permission | (1,1) | A role may have no permissions (a newly created role before assignment) or many. Each mapping row belongs to one role. |
| R4 | granted_to | permission | (0,N) | role_permission | (1,1) | A permission may be granted to no roles yet (declared at seed, not yet distributed) or to several. Each mapping row references one permission. | | R4 | granted_to | permission | (0,N) | role_permission | (1,1) | A permission may be granted to no roles yet (declared at seed, not yet distributed) or to several. Each mapping row references one permission. |
| R5 | performs | user | (0,1) | audit_log | (0,N) | A sensitive action captured under PIN records its acting user; automated/non-attributable entries carry NULL. A user may have logged any number of actions. ON DELETE SET NULL preserves the trail on user anonymisation/removal. |
| R6 | context_of | role | (0,1) | audit_log | (0,N) | Each audit row may denormalise the actor's role at action time (NULL allowed). A role may be the context of many audit rows. ON DELETE SET NULL preserves the trail. |
### 7.3 Notes on the RBAC sub-domain ### 7.3 Notes on the RBAC sub-domain
@ -430,11 +482,28 @@ erDiagram
**Seed roles** (5 roles, frozen at DDL; extendable without code change): **Seed roles** (5 roles, frozen at DDL; extendable without code change):
`admin`, `manager`, `kitchen`, `counter`, `drive`. `admin`, `manager`, `kitchen`, `counter`, `drive`.
**`audit_log` (security-by-design)**: append-only log of sensitive actions, immutable like
`stock_movement`. Both FKs (`actor_user_id`, `actor_role_id`) are nullable with ON DELETE
SET NULL, so the trail survives user anonymisation (RGPD) and role removal. The `actor_role_id`
is denormalised on purpose: even if the user is later anonymised, the role context of the
action is preserved. It carries no PII (the `details` JSON stores changed field names, not
values for user-targeted actions). See dictionary 3.20 and note 13.
**`login_throttle` (security-by-design)**: per-source-IP brute-force throttle, complementing
the per-account counter already on `user` (`failed_login_attempts` / `lockout_until`). One row
per IP (`ip_address VARCHAR(45) UNIQUE`, 45 chars to hold a full IPv6 literal), upserted on each
failed login: `failed_attempts` counts consecutive failures from this IP in the current window,
`window_started_at` marks the start of that window (which resets when expired), `lockout_until`
holds the end of the degressive backoff (NULL = not throttled), `last_attempt_at` the timestamp
of the last failed attempt. It has no FK (an IP is not a modelled entity) and no association. A
daily cron purges rows with no active lockout whose `last_attempt_at` is older than 24h. See
dictionary 3.21 and note 13.
--- ---
## 8. Cross-validation MCD <-> dictionary ## 8. Cross-validation MCD <-> dictionary
Verification that all 19 dictionary entities appear in the MCD and vice versa. Verification that all 21 dictionary entities appear in the MCD and vice versa.
| # | Dictionary entity (section 3) | Sub-domain in MCD | Present | | # | Dictionary entity (section 3) | Sub-domain in MCD | Present |
|---|---|---|---| |---|---|---|---|
@ -457,17 +526,21 @@ Verification that all 19 dictionary entities appear in the MCD and vice versa.
| 17 | `permission` (3.17) | RBAC | Yes | | 17 | `permission` (3.17) | RBAC | Yes |
| 18 | `role_permission` (3.18) | RBAC | Yes | | 18 | `role_permission` (3.18) | RBAC | Yes |
| 19 | `stock_movement` (3.19) | Ingredients & Stock | Yes | | 19 | `stock_movement` (3.19) | Ingredients & Stock | Yes |
| 20 | `audit_log` (3.20) | RBAC & Audit | Yes |
| 21 | `login_throttle` (3.21) | RBAC & Audit | Yes |
**Result**: 19/19 entities traced. No entity from the dictionary is absent from the MCD. **Result**: 21/21 entities traced (19 prod-like + `audit_log` and `login_throttle`
No entity in the MCD falls outside the dictionary. security-by-design). No entity from the dictionary is absent from the MCD. No entity in the MCD
falls outside the dictionary.
**Entities appearing in multiple sub-domains** (cross-domain shared entities): **Entities appearing in multiple sub-domains** (cross-domain shared entities):
- `product`: Catalogue (sold item, slot eligibility) + Ingredients (recipe) + Order (line reference, slot choice) - `product`: Catalogue (sold item, slot eligibility) + Ingredients (recipe) + Order (line reference, slot choice)
- `menu`: Catalogue (definition, slots) + Order (line reference) - `menu`: Catalogue (definition, slots) + Order (line reference)
- `menu_slot`: Catalogue (slot definition) + Order (slot choices via `order_item_selection`) - `menu_slot`: Catalogue (slot definition) + Order (slot choices via `order_item_selection`)
- `ingredient`: Ingredients (recipe, stock) + Order (modifiers) - `ingredient`: Ingredients (recipe, stock) + Order (modifiers)
- `customer_order`: Order (order lifecycle) + Ingredients (stock movement trigger) - `customer_order`: Order (order lifecycle) + Ingredients (stock movement trigger) + RBAC & Audit (taken_by staff via `acting_user_id`)
- `user`: RBAC (authentication) + Ingredients (stock movement author) - `user`: RBAC (authentication) + Ingredients (stock movement author) + Order (`acting_user_id` on counter/drive orders) + Audit (actor of `audit_log`)
- `role`: RBAC (permissions, visible sources) + Audit (denormalised `actor_role_id` context on `audit_log`)
This is expected in a normalised model. The sub-domain split is for readability; the actual This is expected in a normalised model. The sub-domain split is for readability; the actual
relational schema is a unified graph. relational schema is a unified graph.
@ -518,9 +591,12 @@ Pre-validation: each entity participates in at least one treatment.
| `permission` | Admin permission matrix management | | `permission` | Admin permission matrix management |
| `role_permission` | Admin permission matrix management | | `role_permission` | Admin permission matrix management |
| `stock_movement` | Automatic at `paid` transition; manual restock and inventory correction | | `stock_movement` | Automatic at `paid` transition; manual restock and inventory correction |
| `audit_log` | Written by sensitive operations: UPDATE/DELETE product/menu (8.2/8.3/8.6), CANCEL_ORDER (7.1), RESTOCK/INVENTORY_COUNT (9.1/9.2), user ops (10.1-10.3), MANAGE_RBAC (10.4), and failed/successful logins (12.1) |
| `login_throttle` | Read and written by AUTHENTICATE_USER (12.1): per-source-IP throttle upserted on each failed login, read to enforce the backoff window, purged by a daily cron |
Cross-validation MCD <-> MCT (mantra #34) to be completed exhaustively in `mct.md` Cross-validation MCD <-> MCT (mantra #34) to be completed exhaustively in `mct.md`
once the MCT is updated to the 4-state machine and 19-entity model. once the MCT incorporates the security-by-design operations (PIN-gated sensitive actions,
audit writes, reset/lockout, anonymisation). The treatment-layer additions are tracked there.
--- ---