Source: ddx-documentation/adr/0015-entity-kernel.md
- **Status:** Accepted (plan `entity-kernel`, 2026-10-23). - **Refines:** [ADR 0003](/en/docs/adr/0003-route-shape), [ADR 0011](/en/docs/adr/0011-media-proxy), [ADR 0013](/en/docs/adr/0013-super-admin-platform-role) - **Code:** `packages/ddx-contracts/src/{kernel,kernel-fields,kernel-access,attachments}.ts`, `packages/ddx-contracts/src/descriptors/*`, `ddx-api/src/kernel/**`, `ddx-web/src/lib/kernel/**`, `ddx-web/src/components/entity/**`, `scripts/check-entity-kernel.mjs` - **Guide:** [entity-kernel.md](/en/docs/entity-kernel) ## Context - Project and Task each carried a hand-written controller, DTO, role list and web service. A new entity meant a copy of all of them. - A list route could return a raw Prisma row, so a personal field (e-mail, tax id) could reach a role that must not see it. - Access was ad-hoc role checks; a record grant (a patient reading one project) had no home. - Audit rows were plain inserts. Nothing showed that a row had been removed or reordered. - The MediaKind enum drift (task 006) showed the cost of a value that lives in two places: the watcher kept the old build, `/health` stayed 200, and the next restart failed typecheck. ## Decision - **One descriptor per entity**, written with `defineEntity` in `@ddx/contracts/descriptors/<name>`. It holds fields (18 kinds), lifecycle enums, projections, access (rbac, grants, owner, `aclParent`), capability flags and attachment types. ddx-api and ddx-web import it as a **value** at runtime (Node type stripping on the API side). A mirror is forbidden (EK6). - **One access seam.** `AccessPolicy` decides every op. `EntityAccessGuard` runs after `RolesGuard` for RBAC. Record checks and list scope run **before** rows are read; an unreadable row answers 404. - **One projection seam.** `ProjectionService.project()` is the only path from a stored row to a response. A `pii` field never appears in `summary` or `public`. - **One proof chain per stream.** `ProofChainService.append` is the only writer of chained audit rows. Each row stores `seq`, `prevHash`, `hash` (sha256 over canonical JSON). The head moves under a compare-and-set on `audit_stream_head`; a lost race rolls back and retries, a stuck chain answers 503, never an unchained row. `GET :id/audit/verify` re-walks the stream. - **Descriptor-driven routes.** `EntityRouterModule.forEntities([{ descriptor, mapper }])` builds the CRUD, `acl`, `audit`, `audit/verify` and `attachments` routes with one controller factory. A descriptor entity has no hand-written controller or DTO. - **Events by name.** Every mutation emits `<entity>.created|updated|deleted|acl-changed|attached` as an `entity.changed` SSE frame (ids only, no body). ddx-web invalidates by descriptor name. - **Attachments through ddx-api only.** One registry (`ATTACHMENT_TYPES`) names source store, stored kind and bucket role; size and MIME come from `MEDIA_LIMITS`. The browser streams through the BFF ([ADR 0011](/en/docs/adr/0011-media-proxy)). - **One gate.** `./ddx-manage.sh kernel` (`scripts/check-entity-kernel.mjs`, codes EK0-EK8) runs inside `./ddx-manage.sh lint` and in CI. ## Consequences - A new entity is a descriptor, a Prisma model, a mapper, a seed, `kernel.json` labels (4 locales) and registry lines (`SSE_ENTITY_NAMES`, `forEntities()`, `WEB_DESCRIPTORS`). Nothing else. - Every descriptor edit reaches api and web in one change; drift is a typecheck or gate failure, not a runtime surprise. - ddx-web still holds some per-entity code (route union in `portal-registry.ts`, legacy-key list in `lib/kernel/registry.ts`, project wizard). Task 029 lists them; they are follow-ups, not part of the decision. - A chained stream serialises writers per record. Contention is bounded by the in-process queue plus the CAS; a second process may see a 503 retry. ## Alternatives rejected - **Per-entity mirrors** (a descriptor copy in api and web, as unified settings did with `satisfies`). Rejected: a mirror drifts; EK6 fails the build instead. - **Presigned media URLs** for attachments. Rejected by [ADR 0011](/en/docs/adr/0011-media-proxy): MinIO stays behind ddx-api, so the access check runs on every byte. - **Advisory locks (`pg_advisory_xact_lock`) for the audit chain.** Rejected: a lock needs a connection pinned across the transaction, and a stale lock hides a forked chain. The unique `(stream, seq)` index plus the head CAS fails loudly instead. Last reconciled: 2026-10-23 · working tree on 8ef2dc6
entity-kernel, 2026-10-23).packages/ddx-contracts/src/{kernel,kernel-fields,kernel-access,attachments}.ts, packages/ddx-contracts/src/descriptors/*, ddx-api/src/kernel/**, ddx-web/src/lib/kernel/**, ddx-web/src/components/entity/**, scripts/check-entity-kernel.mjs/health stayed 200, and the next restart failed typecheck.defineEntity in @ddx/contracts/descriptors/<name>. It holds fields (18 kinds), lifecycle enums, projections, access (rbac, grants, owner, aclParent), capability flags and attachment types. ddx-api and ddx-web import it as a value at runtime (Node type stripping on the API side). A mirror is forbidden (EK6).AccessPolicy decides every op. EntityAccessGuard runs after RolesGuard for RBAC. Record checks and list scope run before rows are read; an unreadable row answers 404.ProjectionService.project() is the only path from a stored row to a response. A pii field never appears in summary or public.ProofChainService.append is the only writer of chained audit rows. Each row stores seq, prevHash, hash (sha256 over canonical JSON). The head moves under a compare-and-set on audit_stream_head; a lost race rolls back and retries, a stuck chain answers 503, never an unchained row. GET :id/audit/verify re-walks the stream.EntityRouterModule.forEntities([{ descriptor, mapper }]) builds the CRUD, acl, audit, audit/verify and attachments routes with one controller factory. A descriptor entity has no hand-written controller or DTO.<entity>.created|updated|deleted|acl-changed|attached as an entity.changed SSE frame (ids only, no body). ddx-web invalidates by descriptor name.ATTACHMENT_TYPES) names source store, stored kind and bucket role; size and MIME come from MEDIA_LIMITS. The browser streams through the BFF ()../ddx-manage.sh kernel (scripts/check-entity-kernel.mjs, codes EK0-EK8) runs inside ./ddx-manage.sh lint and in CI.kernel.json labels (4 locales) and registry lines (SSE_ENTITY_NAMES, forEntities(), WEB_DESCRIPTORS). Nothing else.portal-registry.ts, legacy-key list in lib/kernel/registry.ts, project wizard). Task 029 lists them; they are follow-ups, not part of the decision.satisfies). Rejected: a mirror drifts; EK6 fails the build instead.pg_advisory_xact_lock) for the audit chain. Rejected: a lock needs a connection pinned across the transaction, and a stale lock hides a forked chain. The unique (stream, seq) index plus the head CAS fails loudly instead.Last reconciled: 2026-10-23 · working tree on 8ef2dc6
adr/
deploy/
research/