Source: ddx-documentation/entity-kernel.md
One descriptor per entity drives the API routes, the access check, the response shape, the audit chain, the events, the attachments and the web views. Decision record: [ADR 0015](/en/docs/adr/0015-entity-kernel). Path-scoped rules: `.claude/rules/entity-kernel.md`. Binding rows: `context/_invariants.md` 27-30. Shipped descriptors (5): `user`, `organization`, `member`, `project`, `task` in `packages/ddx-contracts/src/descriptors/`. `MedicalVisit` and its children are **not** in the code; the recipe at the end is the worked plan for the next entity. ## Where the kernel lives | Layer | Path | Role | |---|---|---| | Contracts | `packages/ddx-contracts/src/{kernel,kernel-fields,kernel-access,attachments}.ts` | `defineEntity`, field catalog, access model, attachment registry | | Descriptors | `packages/ddx-contracts/src/descriptors/<name>.ts` | the one source per entity (subpath import, no barrel) | | API | `ddx-api/src/kernel/{access,projection,audit,events,attachments,router}/` | `AccessPolicyService`, `EntityAccessGuard`, `ProjectionService`, `ProofChainService`, `EntityRouterModule` | | Web | `ddx-web/src/lib/kernel/*`, `ddx-web/src/components/entity/*` | entity service, form spec, view modes, `EntityView`, `Entity*Panel` | | Gate | `scripts/check-entity-kernel.mjs`, `scripts/entity-kernel-exempt.json` | EK0-EK8 | ## Field catalog `FIELD_KINDS` (18). Every field carries `name`, optional `labelKey`, and the flags `required`, `readOnly`, `sortable`, `filterable`, `searchable`, `pii: true`, `writableBy`. | Kind | Value | Notes | |---|---|---| | `text`, `richText` | string · tiptap JSON | `max` on text; `format: 'tiptap-json'` on richText | | `number`, `money` | number · minor-unit integer | money names a `currencyField` | | `date`, `datetime`, `dateRange` | ISO string · `{start, end}` | dateRange names `startField` / `endField` columns | | `enum` | one of `values` | optional `lifecycle`: `initial` + `transitions` (from → to[]) | | `boolean`, `email`, `phone`, `url`, `address` | as named | `address` is the fixed `AddressSchema` | | `image`, `file`, `signature` | media id | never a URL; `attachmentType` names the registry row | | `relation` | id or id[] | `target`, `cardinality`, `inverse`, `through`, `readOnly` | | `computed` | read-only | `type` from `COMPUTED_TYPES`, `dependsOn` | `writableBy` takes `FIELD_WRITERS` = org roles, `super-admin`, `owner`. Reserved field names: `id`, `version`, `page`, `limit`, `sort`, `q`. List page size: default 20, max 100 (`LIST_LIMIT_*` in `kernel.ts`). ## Projections `BASE_PROJECTIONS` = `summary`, `detail`, `owner`, `admin`, `public`, plus `service:<name>` (for example `service:ai`). `summary` and `detail` are required. Each projection lists fields and an `audience` (`roles`, `owner`, `grantOps`). - A `pii` field is allowed only in `owner`, `admin` and `service:*` (`projectionAllowsPii`). EK2 enforces it. - `ProjectionService.project()` picks the projection by the caller's audience and answers `meta.projection`. The client never names a projection its audience lacks. - A patient with a read grant on a project gets `detail` (the project holds no pii field). ## Access model - **Ops** (`OPS`): `read`, `list`, `create`, `update`, `delete`, `share`, `attach`, `audit`. - **RBAC**: `access.rbac[role]` per `RBAC_ROLES` (org roles + `super-admin`). Enforced by `EntityAccessGuard` after `RolesGuard`. - **Owner**: `access.ownerField` names the owner column; `ownerOps` lists the ops the owner holds. - **Grants** (`acl`): `acl.enabled`, `grantable` ops, `principalTypes` (`user`, `role`, `service`). Stored as `access_grant` rows. - **Parent inheritance**: `access.aclParent = '<relation field>'`; a grant on the parent opens the child (`task` → `project`). - **Decision**: `resolveOps()` is pure and shared; `AccessPolicyService.assert` applies it to one record, `listScope` applies it to a list **before** rows are read. An unreadable row is 404, not 403. Rules decided in task 033 (security review): - `share` on project and task: org-admin and super-admin by RBAC, plus the project owner. The doctor role does **not** hold `share`. - `PUT :id/acl` stays **replace-all**. A `user` principal must be an active member of the org; a `role` principal must be in `ORG_ROLES`; otherwise 400. - The `acl` audit row stores `before` / `after` as `{grants: [{principalType, principalId, ops}]}`, sorted, ids only. - `task.assignee` and `task.project` are `writableBy` org-admin, super-admin, doctor. A `relation → user` value must be an active org member (400), on create and patch. `writableBy` also holds on POST. ## Capability flags `CAPABILITY_KEYS`: `audit`, `proofChain`, `acl`, `attachments`, `events`, `softDelete`, `search`. All default to `true`; a descriptor turns one off with `capabilities: { acl: false }`. Today `organization` and `member` set `acl: false`; `user` sets `acl: false` and keeps `proofChain`. EK7 warns when a flag is not boolean. ## Audit and proof chain `EntityAuditService` writes one audit row per mutation. With `proofChain` on, it goes through `ProofChainService.append` on stream `<orgId|platform>:<entity>:<recordId>` (the `user` entity uses `platform`). Row = `seq`, `prevHash`, `hash` = sha256 over canonical JSON; first `prevHash` is `GENESIS`. Head `audit_stream_head` moves by compare-and-set; `(stream, seq)` is unique. `GET :id/audit` lists rows; `GET :id/audit/verify` re-walks the chain and answers `ok` or `brokenAt`. ## Event catalog `ENTITY_EVENT_SUFFIXES`: `created`, `updated`, `deleted`, `acl-changed`, `attached`. Name = `<entity>.<suffix>`. The wire frame is `entity.changed` with `entity`, `entityId`, `change`, `version`, `orgId`, `ts` — ids only, no body, no audience. The entity name must be in `SSE_ENTITY_NAMES` (EK3). ddx-web invalidates the entity's query keys; server-rendered `EntityView` modes refresh through `EntityLiveRefresh`. ## Attachment registry `ATTACHMENT_TYPES` in `@ddx/contracts/attachments`. Size and MIME come from `MEDIA_LIMITS[storedKind]`; the registry never restates them. | Type | Source store | Stored kind | Bucket role | |---|---|---|---| | `avatar` | `auth.user_media` | `avatar` | `avatars` | | `signature` | `auth.user_media` | `signature` | `avatars` | | `logo` | `main.media_object` | `logo` | `images` | | `cover` | `main.media_object` | `image` | `images` | | `document` | `main.media_object` | `document` (reads `attachment`) | `images` | | `recording` | `main.media_object` | `audio` | `images` | A descriptor lists the types it accepts (`attachmentTypesOf(desc)`); the router serves `GET|POST :id/attachments` and `GET :id/attachments/:attachmentId/content` (Range → 206). A new store = one more `ATTACHMENT_SOURCES` value in the kernel plus one registry row. A Prisma `MediaKind` value lands in `MEDIA_KINDS` and `MEDIA_LIMITS` in the same change (EK8). ## Routes (per descriptor) `GET /orgs/{orgSlug}/<plural>` · `POST` · `GET|PATCH|DELETE …/{id}` · `GET …/{id}/audit` · `GET …/{id}/audit/verify` · `GET|PUT …/{id}/acl` · `GET|POST …/{id}/attachments` · `GET …/{id}/attachments/{attachmentId}/content` · child lists `GET|POST …/{id}/<child plural>`. Identity routes outside the factory: `/me/profile`, `/me/avatar`, `/me/signature`, `/orgs`, `/orgs/{orgSlug}/profile`, `/orgs/{orgSlug}/logo`, `/orgs/{orgSlug}/members`. Reference: [api/openapi.json](./api/openapi.json) (generated). ## View modes `ENTITY_VIEW_MODES` (`ddx-web/src/lib/kernel/view-modes.ts`): `table`, `cards`, `list`, `kanban`, `calendar`, `detail`, `form`, `wizard`, `print`, `export`. The mode lives in `?view=`. `kanban` is offered when the descriptor has a lifecycle enum; `calendar` when it has a date field; a value the descriptor does not offer falls back to the first offered mode. Components: `EntityView` plus `EntityAccessPanel`, `EntityAttachmentsPanel`, `EntityAuditPanel` (inventory family `@/inventory/entity`). Pattern: [P23](/en/docs/patterns). ## Conformance gate (EK0-EK8) `./ddx-manage.sh kernel` runs `node scripts/check-entity-kernel.mjs` (`--quiet-ok` hides clean output and EK7 warnings). It also runs inside `./ddx-manage.sh lint` and in `.github/workflows/entity-kernel.yml`. | Code | Check | |---|---| | EK0 | each descriptor module loads and exports exactly one descriptor | | EK1 | every Prisma model has a descriptor (`descriptorModels`) or an exemption row with a reason; stale or double rows fail | | EK2 | `summary` and `detail` exist; no `pii` field in `summary` or `public` | | EK3 | the descriptor name is in `SSE_ENTITY_NAMES` | | EK4 | every `labelKey` exists in `ddx-web/messages/{en,fr,de,es}/kernel.json` | | EK5 | registered in ddx-api (`forEntities` / `registry.register`) and in `WEB_DESCRIPTORS` | | EK6 | no descriptor mirror (`defineEntity(`, `satisfies EntityInput\|EntityDescriptor`) outside contracts; no re-declared lifecycle tuple, drifted `z.enum`, hand union of entity names or per-entity map | | EK7 | capability flags are booleans (warning only) | | EK8 | Prisma enum ↔ contract tuple parity (`enumParity` rows + the `<Entity><Field>` naming rule) | ## Add an entity — recipe (worked: MedicalVisit) The steps for any entity are exactly these: **descriptor · Prisma model · mapper · seed · `kernel.json` labels · registry lines** (`SSE_ENTITY_NAMES`, `forEntities()`, `WEB_DESCRIPTORS`). No controller, no DTO, no web service. Run `./ddx-manage.sh kernel` after. Proposed layout (design, not shipped). Databases follow [ADR 0004](/en/docs/adr/0004-database-split). | Entity | DB | `aclParent` | Attachment types | Notes | |---|---|---|---|---| | `MedicalVisit` | `main` | — | `document` | root; owner = treating doctor | | `MedicalVisitRecord` | `main` | `visit` | `document` | clinical notes; pii fields | | `MedicalVisitAppointment` | `main` | `visit` | — | `dateRange` field → calendar view | | `MedicalVisitGeneratedReport` | `main` | `visit` | `document` | report file; owner = generating user | | `MedicalVisitAiAssistantSession` | `ai` | `visit` | `recording` | needs one more `ATTACHMENT_SOURCES` value (the `ai` store) and one registry row | | `MedicalVisitOnlineRoom` | `communication` | `visit` | — | live room; `service:` projection not needed | Projections per audience (child entities repeat the pattern; `pii` fields only where marked): | Audience | Projection | Content | |---|---|---| | staff (doctor, nurse, receptionist) | `summary` | status, dates, patient display name | | doctor, owner | `detail` | adds record text, report link | | org-admin, super-admin | `admin` | every field | | patient with a read grant | `detail` | own visit; clinical `pii` fields only if the descriptor lists them for `owner` | | `service:ai` | `service:ai` | the minimal fields the AI session needs, set by `audience.roles`/service principal; the only non-human projection that may hold `pii` | Steps: 1. Descriptor `descriptors/medical-visit.ts` with `defineEntity`; children set `access.aclParent` to the relation field (see `descriptors/task.ts`). Export it from the contracts package `exports` map. 2. Prisma model in the DB named above; add a `descriptorModels` row in `scripts/entity-kernel-exempt.json` and `enumParity` rows for every enum (EK1, EK8). 3. Mapper (row ↔ record bag) and `forEntities([{ descriptor, mapper }])`. 4. Seed rows in `ddx-api/prisma/seed/`. 5. `kernel.json` labels in en, fr, de, es (`entity.<name>.fields.<field>`, enum `values`, `views.entities.<name>`). 6. Registry lines: `SSE_ENTITY_NAMES`, `WEB_DESCRIPTORS`. 7. Route tree under `portal/[orgSlug]/[role]/<plural>/**` composing `EntityView` (expected per entity; see the code review for the web sites that still need per-entity code). Last reconciled: 2026-10-23 · working tree on 8ef2dc6
One descriptor per entity drives the API routes, the access check, the response shape, the audit chain, the events, the attachments and the web views. Decision record: . Path-scoped rules: .claude/rules/entity-kernel.md. Binding rows: context/_invariants.md 27-30.
Shipped descriptors (5): user, organization, member, project, task in packages/ddx-contracts/src/descriptors/. MedicalVisit and its children are not in the code; the recipe at the end is the worked plan for the next entity.
FIELD_KINDS (18). Every field carries name, optional labelKey, and the flags required, readOnly, sortable, filterable, searchable, pii: true, writableBy.
writableBy takes FIELD_WRITERS = org roles, super-admin, owner. Reserved field names: id, version, page, limit, sort, q. List page size: default 20, max 100 (LIST_LIMIT_* in kernel.ts).
BASE_PROJECTIONS = summary, detail, owner, admin, public, plus service:<name> (for example service:ai). summary and detail are required. Each projection lists fields and an audience (roles, owner, grantOps).
pii field is allowed only in owner, admin and service:* (projectionAllowsPii). EK2 enforces it.ProjectionService.project() picks the projection by the caller's audience and answers meta.projection. The client never names a projection its audience lacks.detail (the project holds no pii field).OPS): read, list, create, update, delete, share, attach, audit.access.rbac[role] per RBAC_ROLES (org roles + super-admin). Enforced by EntityAccessGuard after RolesGuard.access.ownerField names the owner column; ownerOps lists the ops the owner holds.acl): acl.enabled, grantable ops, principalTypes (user, role, service). Stored as access_grant rows.access.aclParent = '<relation field>'; a grant on the parent opens the child (task → project).resolveOps() is pure and shared; AccessPolicyService.assert applies it to one record, listScope applies it to a list before rows are read. An unreadable row is 404, not 403.Rules decided in task 033 (security review):
share on project and task: org-admin and super-admin by RBAC, plus the project owner. The doctor role does not hold share.PUT :id/acl stays replace-all. A user principal must be an active member of the org; a role principal must be in ORG_ROLES; otherwise 400.acl audit row stores before / after as {grants: [{principalType, principalId, ops}]}, sorted, ids only.task.assignee and task.project are writableBy org-admin, super-admin, doctor. A relation → user value must be an active org member (400), on create and patch. writableBy also holds on POST.CAPABILITY_KEYS: audit, proofChain, acl, attachments, events, softDelete, search. All default to true; a descriptor turns one off with capabilities: { acl: false }. Today organization and member set acl: false; user sets acl: false and keeps proofChain. EK7 warns when a flag is not boolean.
EntityAuditService writes one audit row per mutation. With proofChain on, it goes through ProofChainService.append on stream <orgId|platform>:<entity>:<recordId> (the user entity uses platform). Row = seq, prevHash, hash = sha256 over canonical JSON; first prevHash is GENESIS. Head audit_stream_head moves by compare-and-set; (stream, seq) is unique. GET :id/audit lists rows; GET :id/audit/verify re-walks the chain and answers ok or brokenAt.
ENTITY_EVENT_SUFFIXES: created, updated, deleted, acl-changed, attached. Name = <entity>.<suffix>. The wire frame is entity.changed with entity, entityId, change, version, orgId, ts — ids only, no body, no audience. The entity name must be in SSE_ENTITY_NAMES (EK3). ddx-web invalidates the entity's query keys; server-rendered EntityView modes refresh through EntityLiveRefresh.
ATTACHMENT_TYPES in @ddx/contracts/attachments. Size and MIME come from MEDIA_LIMITS[storedKind]; the registry never restates them.
A descriptor lists the types it accepts (attachmentTypesOf(desc)); the router serves GET|POST :id/attachments and GET :id/attachments/:attachmentId/content (Range → 206). A new store = one more ATTACHMENT_SOURCES value in the kernel plus one registry row. A Prisma MediaKind value lands in MEDIA_KINDS and MEDIA_LIMITS in the same change (EK8).
GET /orgs/{orgSlug}/<plural> · POST · GET|PATCH|DELETE …/{id} · GET …/{id}/audit · GET …/{id}/audit/verify · GET|PUT …/{id}/acl · GET|POST …/{id}/attachments · GET …/{id}/attachments/{attachmentId}/content · child lists GET|POST …/{id}/<child plural>. Identity routes outside the factory: /me/profile, /me/avatar, /me/signature, /orgs, /orgs/{orgSlug}/profile, /orgs/{orgSlug}/logo, /orgs/{orgSlug}/members. Reference: (generated).
ENTITY_VIEW_MODES (ddx-web/src/lib/kernel/view-modes.ts): table, cards, list, kanban, calendar, detail, form, wizard, print, export. The mode lives in ?view=. kanban is offered when the descriptor has a lifecycle enum; calendar when it has a date field; a value the descriptor does not offer falls back to the first offered mode. Components: EntityView plus EntityAccessPanel, EntityAttachmentsPanel, EntityAuditPanel (inventory family @/inventory/entity). Pattern: .
./ddx-manage.sh kernel runs node scripts/check-entity-kernel.mjs (--quiet-ok hides clean output and EK7 warnings). It also runs inside ./ddx-manage.sh lint and in .github/workflows/entity-kernel.yml.
The steps for any entity are exactly these: descriptor · Prisma model · mapper · seed · kernel.json labels · registry lines (SSE_ENTITY_NAMES, forEntities(), WEB_DESCRIPTORS). No controller, no DTO, no web service. Run ./ddx-manage.sh kernel after.
Proposed layout (design, not shipped). Databases follow .
Projections per audience (child entities repeat the pattern; pii fields only where marked):
Steps:
descriptors/medical-visit.ts with defineEntity; children set access.aclParent to the relation field (see descriptors/task.ts). Export it from the contracts package exports map.descriptorModels row in scripts/entity-kernel-exempt.json and enumParity rows for every enum (EK1, EK8).forEntities([{ descriptor, mapper }]).ddx-api/prisma/seed/.kernel.json labels in en, fr, de, es (entity.<name>.fields.<field>, enum values, views.entities.<name>).SSE_ENTITY_NAMES, WEB_DESCRIPTORS.portal/[orgSlug]/[role]/<plural>/** composing EntityView (expected per entity; see the code review for the web sites that still need per-entity code).Last reconciled: 2026-10-23 · working tree on 8ef2dc6
adr/
deploy/
research/