|
| 1 | +# Design notes: enums-as-domain-concept |
| 2 | + |
| 3 | +> Synthesized design document for `enums-as-domain-concept`. Read this to understand |
| 4 | +> **what the design is**, **what principles it serves**, and **what alternatives were |
| 5 | +> considered and rejected**. It captures the settled design, standing independently of |
| 6 | +> the discussion that produced it. The spec (`./spec.md`) is the authoritative, |
| 7 | +> requirement-mapped statement; this document is the rationale behind it. |
| 8 | +
|
| 9 | +## Principles this design serves |
| 10 | + |
| 11 | +- **A codec is a type; an enum is a restriction on it.** A column's type is its codec |
| 12 | + (the set of assignable values). An enum does not replace the codec — it narrows the |
| 13 | + permitted values to a named subset. Every field/column keeps its codec, always. |
| 14 | +- **Domain concept vs storage projection (ADR 172).** The application's enum (named, |
| 15 | + ordered members) is a domain concept; the permitted physical values are a storage |
| 16 | + concept. Each lives in its own plane, referenced within that plane. |
| 17 | +- **Single source, emitted projections.** The domain enum is the one authored source. |
| 18 | + Storage and runtime copies are emitted from it, so they cannot drift — the same |
| 19 | + redundancy ADR 172 already accepts for nullability and native types. |
| 20 | +- **Structure carries strategy (no markers).** As with polymorphism and ownership, the |
| 21 | + persistence strategy is implied by the shape (text column + value-set + check), not a |
| 22 | + separate flag. Changing the strategy is a visible structural diff. |
| 23 | +- **One reference rule everywhere (ADR 221 / PR #745).** References use the full |
| 24 | + space-aware entity coordinate, never bare names — uniform with relations and FKs. |
| 25 | +- **Delete native enums; keep the seam.** Native `CREATE TYPE … AS ENUM` carries |
| 26 | + operational pain (no value removal without rebuild, transaction caveats, text-only). |
| 27 | + It is removed now and, because the strategy is structural, can return later as a |
| 28 | + different storage shape under the same unchanged domain enum. |
| 29 | + |
| 30 | +## The model |
| 31 | + |
| 32 | +An enum is an ordered map from a member **name** (a code identifier) to a member |
| 33 | +**value** (the runtime value the column stores). The two are independent; the **value** |
| 34 | +is the runtime identity used in the ORM, the query builder, raw SQL, and the wire. |
| 35 | + |
| 36 | +### Domain plane — the concept |
| 37 | + |
| 38 | +`domain.namespaces[ns].enum[Name]` carries an explicit `codecId` and ordered |
| 39 | +`members: [{ name, value }, …]`. The codec is required (declared, never inferred) and |
| 40 | +its input type constrains the member value type. A field that uses the enum keeps its |
| 41 | +always-present `codecId` and adds a `valueSet` restriction referencing the enum. |
| 42 | + |
| 43 | +### Storage plane — the physical projection |
| 44 | + |
| 45 | +`storage.namespaces[ns].valueSet[Name]` carries ordered `values: […]` — a bare, named |
| 46 | +set of permitted physical values (no member names, no application semantics; a |
| 47 | +storage-legitimate concept). A column keeps its `codecId` + `nativeType` and adds a |
| 48 | +`valueSet` restriction referencing the storage value-set. The value-set is referenced, |
| 49 | +not inlined, so the values live once per plane. |
| 50 | + |
| 51 | +### Restriction and enforcement are separate jobs |
| 52 | + |
| 53 | +- The column's **`valueSet` property** is the *notional* restriction — read the column |
| 54 | + in isolation and you know its value space. This types the client (ORM from the domain |
| 55 | + field, query builder from the storage column), present whether or not the database |
| 56 | + enforces anything. |
| 57 | +- The **check constraint** (`StorageTable.checks[]`, referencing the same value-set) is |
| 58 | + the *server-side* enforcement. A column may carry the restriction with or without the |
| 59 | + check. |
| 60 | + |
| 61 | +### References |
| 62 | + |
| 63 | +`valueSet` (and the `enumMember` default) carry a discriminated, space-aware coordinate: |
| 64 | +`kind` (the source entity-kind) + `namespaceId` (admitting the `__unbound__` sentinel) + |
| 65 | +`name`, plus an optional `spaceId` whose presence is the cross-space discriminator — the |
| 66 | +TML-2500 / PR #745 carrier convention. Domain → domain and storage → storage references |
| 67 | +are intra-plane; the `enumMember` default is storage → domain, permitted by ADR 221's |
| 68 | +directional invariant. |
| 69 | + |
| 70 | +### Typing and surface |
| 71 | + |
| 72 | +Read/write types are the codec's `Output`/`Input` narrowed to the value-set's values |
| 73 | +(`string` → `'user' | 'admin'`). `db.enums.<Name>` exposes the ordered, literal-typed |
| 74 | +value tuple and member accessors. `ORDER BY` follows declaration order, rendered per |
| 75 | +target from the ordered values. |
| 76 | + |
| 77 | +## Alternatives considered |
| 78 | + |
| 79 | +- **Enum as a storage-plane entity (the original approach).** Attractive: it was already |
| 80 | + half-built (`PostgresEnumType`). **Rejected because:** it puts the source of truth in |
| 81 | + the wrong plane, forcing every application-facing feature to reach down into storage |
| 82 | + for the values — the breakage this project removes. |
| 83 | +- **Native `CREATE TYPE … AS ENUM` as the storage realization.** Attractive: a real, |
| 84 | + shared, introspectable type. **Rejected because:** Postgres-only, no value removal |
| 85 | + without rebuild, transaction caveats, text-only. Value-set + check works on every SQL |
| 86 | + target with ordinary `ALTER TABLE`s; native can return later as a different structure. |
| 87 | +- **Field/column type as a `codec | enum` union.** Attractive: explicit. **Rejected |
| 88 | + because:** it breaks the "every field/column has a codec, always" invariant — a |
| 89 | + foundational change that ripples everywhere. A codec *is* the type; the enum is |
| 90 | + additive. |
| 91 | +- **A named enum entity in the storage plane.** Attractive: symmetry with the domain |
| 92 | + enum. **Rejected because:** with native types gone there is no physical object to name; |
| 93 | + a storage "enum" would be a domain concept in a plane meant for concrete artifacts. The |
| 94 | + bare value-set is the storage-legitimate version. |
| 95 | +- **Inlining permitted values on each column/check.** Attractive: storage fully |
| 96 | + self-contained for DDL. **Rejected because:** it duplicates the list per site. The |
| 97 | + named value-set, referenced intra-plane, keeps values once and storage still resolves |
| 98 | + without leaving its plane. |
| 99 | +- **A literal default instead of an `enumMember` variant.** Attractive: no new |
| 100 | + `ColumnDefault` shape. **Rejected because:** a column now openly carries an enum |
| 101 | + restriction, so a member-referenced default is the natural corollary and records |
| 102 | + intent; the fixture cost is small. |
| 103 | +- **An explicit persistence-strategy marker.** **Rejected because:** the structure |
| 104 | + declares the strategy (as in polymorphism/ownership); a marker would be a second source |
| 105 | + of truth. |
| 106 | +- **Bare-name references.** **Rejected because:** names collide and need lexical context; |
| 107 | + the full space-aware coordinate is the uniform rule. |
| 108 | +- **Authoring as a `Map` / bare object / array of pairs.** **Rejected because:** a `Map` |
| 109 | + erases literals; an object reorders integer-like keys and collides the accessor with |
| 110 | + type properties; pairs are unergonomic. The `member()` variadic preserves order and |
| 111 | + literals. |
| 112 | +- **A per-enum runtime validator (e.g. arktype).** **Rejected because:** the compile-time |
| 113 | + union and the database check already enforce membership; a third check is redundant |
| 114 | + defense. |
| 115 | +- **An ecosystem enum library** (Zod / Effect / enumify / …). **Rejected because:** each |
| 116 | + either collapses name into value or uses runtime classes (against no-runtime-codegen); |
| 117 | + none gives ordered + independent name/value + literal inference. The ~30-line |
| 118 | + `enumType` is hand-rolled. |
| 119 | + |
| 120 | +## Open questions |
| 121 | + |
| 122 | +- **Realization layer** — implement value-set + check at the SQL-family layer |
| 123 | + (MySQL/SQLite inherit) or Postgres-only now? **Working position:** family-layer; the |
| 124 | + structured check is dialect-agnostic. |
| 125 | +- **PSL surface** for declaring an enum's codec and per-member values. **Working |
| 126 | + position:** an explicit codec annotation on the `enum` block + per-member `@map` for |
| 127 | + the value; exact syntax settled at slice-plan time. |
| 128 | +- **`db.enums` scope** — local to this project or the first instance of a broader |
| 129 | + domain-client surface for IR-modelled entities? **Working position:** ship it here, |
| 130 | + shaped so a later generalization is non-breaking. |
| 131 | +- **Reference-carrier coupling** — the `valueSet`/default refs track TML-2500 / PR #745; |
| 132 | + if that convention shifts before this lands, these refs shift with it. **Working |
| 133 | + position:** conform to the merged M1 carrier; local refs need no `spaceId`. |
| 134 | + |
| 135 | +## References |
| 136 | + |
| 137 | +- Project spec: [`./spec.md`](./spec.md) |
| 138 | +- Project plan: [`./plan.md`](./plan.md) |
| 139 | +- [ADR 172 — Contract domain-storage separation](../../docs/architecture%20docs/adrs/ADR%20172%20-%20Contract%20domain-storage%20separation.md) |
| 140 | +- [ADR 221 — Contract IR two planes, uniform entity coordinate, pack-contributed kinds](../../docs/architecture%20docs/adrs/ADR%20221%20-%20Contract%20IR%20two%20planes%20with%20uniform%20entity%20coordinate%20and%20pack-contributed%20entity%20kinds.md) |
| 141 | +- TML-2500 / PR #745 — cross-contract-space FK reference carrier (the reference coordinate convention) |
0 commit comments