|
| 1 | +# FR-019 — Shared + externally-provided enums |
| 2 | + |
| 3 | +**Status:** Design (ready for implementation) |
| 4 | +**Created:** 2026-06-06 |
| 5 | +**Decision:** [ADR-0026](../../../spec/decisions/ADR-0026-shared-and-provided-named-types.md) |
| 6 | +**Builds on:** [enum datatype design](2026-05-23-enum-datatype-design.md) (D6 — abstract `field.enum` + `extends`) |
| 7 | + |
| 8 | +## Why this doc exists |
| 9 | + |
| 10 | +`field.enum` members are the cross-port SSOT, but codegen **redeclares them inline |
| 11 | +in every consuming entity** (a nested `public enum AuthType` per C# class; a |
| 12 | +per-entity union in TS). The enum design's D6 sanctioned *reuse* via an abstract |
| 13 | +`field.enum` + `extends`, yet the emitted code still copies the members into each |
| 14 | +consumer. Two adopter needs remain unmet — **shared reuse** (materialize one type) |
| 15 | +and **external ownership** (reference a hand-written type, don't emit). |
| 16 | + |
| 17 | +Per [ADR-0026](../../../spec/decisions/ADR-0026-shared-and-provided-named-types.md), |
| 18 | +the ownership concept is a **`@provided` flag on the named-type declaration**, |
| 19 | +applying uniformly to enums and value objects — **not** a field attr and **not** |
| 20 | +enum-specific. This FR implements the concept **for enums** (the driving need); |
| 21 | +value-object `@provided` is a noted follow-up using the same attribute. |
| 22 | + |
| 23 | +## Scope |
| 24 | + |
| 25 | +1. **Shared materialization:** a **package-level abstract `field.enum`** is |
| 26 | + materialized ONCE per port as a standalone enum type; concrete `extends`-ing |
| 27 | + fields reference it. |
| 28 | +2. **`@provided: true`** on the named-type declaration: suppress materialization; |
| 29 | + consuming fields reference an existing type, resolved via per-port codegen |
| 30 | + config. |
| 31 | +3. Cross-port conformance fixtures pinning both. |
| 32 | + |
| 33 | +Out of scope (unchanged): int-backed values, display labels, native PG enum. |
| 34 | +**Retired:** the C#-only `@csEnumType` FQN attr (ADR-0026 §3). |
| 35 | +**Follow-up (not here):** `@provided` on `object.value` (reference a hand-written |
| 36 | +value class) — same attribute, separate slice gated on a real need. |
| 37 | + |
| 38 | +## Metamodel (Tier 1 — invariant) |
| 39 | + |
| 40 | +No new type or subtype. Reuses the D6 vocabulary: |
| 41 | +- An **abstract `field.enum`** declared at the **package/root level** (a sibling |
| 42 | + of `object.entity`, not nested in one): `abstract: true`, `@values: [...]`. Its |
| 43 | + `name` is the enum's cross-port identity + the materialized type name. |
| 44 | +- A concrete `field.enum` on an entity `extends`-ing it (D6). |
| 45 | + |
| 46 | +New attr **on the named-type declaration** (the abstract `field.enum`), own-only: |
| 47 | +- **`@provided`** — boolean. `true` ⇒ do NOT materialize the enum type; consuming |
| 48 | + fields reference an existing type. Default `false`. **Not a field attr** — it |
| 49 | + lives on the (abstract) declaration; placing `@provided` on a concrete |
| 50 | + consuming field is invalid. Loader: `ERR_BAD_ATTR_VALUE` for a non-boolean. |
| 51 | + Validation parity across all five ports (own-only). |
| 52 | + |
| 53 | +Unchanged invariants: `@values` non-empty, member regex, no dupes; the wire form |
| 54 | +is the string symbol; the discriminator / `CHECK` / validation contracts are |
| 55 | +identical whether the enum is inline, shared, or `@provided` (the values stay the |
| 56 | +SSOT — a port referencing a provided type still validates against `@values` + |
| 57 | +emits the `CHECK`). |
| 58 | + |
| 59 | +**No per-port type binding in metadata** (ADR-0001): the namespace/package a |
| 60 | +`@provided` enum resolves to is **codegen config** per port (e.g. an |
| 61 | +`enumNamespace` / `enumPackage` / `enumModule` setting), never a metadata attr. |
| 62 | + |
| 63 | +## Per-port codegen (Tier 2 — idiomatic) |
| 64 | + |
| 65 | +For a **shared (package-level abstract, non-provided) `field.enum` named `E` with |
| 66 | +values `[…]`**, materialize ONE standalone type and reference it from consumers: |
| 67 | + |
| 68 | +| Port | Materialize the shared type | Field references it | |
| 69 | +|---|---|---| |
| 70 | +| TS | exported `export type E = "A" \| "B"` (+ a shared `z.enum(["A","B"])`) at module/package level | property typed `E`; Zod uses the shared `z.enum` | |
| 71 | +| C# | namespace-level `public enum E { A, B }` + `…HasConversion<string>()` | property typed `E` (no nested redeclare) | |
| 72 | +| Kotlin | package-level `enum class E { A, B }` | property typed `E` | |
| 73 | +| Python | module-level `class E(str, Enum): A="A"; B="B"` | field typed `E` | |
| 74 | +| DB | one shared `CHECK (col IN ('A','B'))` per consuming column (values identical) | — | |
| 75 | + |
| 76 | +For **`@provided: true`**: materialize **nothing** for `E`; each consuming field |
| 77 | +references `E` resolved to the port's configured namespace/package + `E` (C# |
| 78 | +`<cfg.enumNamespace>.E`, Kotlin/Java `<cfg.enumPackage>.E`, Python an import of |
| 79 | +`E` from `<cfg.enumModule>`, TS an import of `E`). A missing config for a |
| 80 | +referenced `@provided` enum is a **codegen-time error** (clear message naming the |
| 81 | +enum + the config key), not a metadata error. |
| 82 | + |
| 83 | +**Byte-identical default:** an enum that is NOT a package-level shared/abstract |
| 84 | +enum (the common inline case) emits exactly as today — the per-entity nested |
| 85 | +declaration. Shared and `@provided` are additive branches gated on "package-level |
| 86 | +abstract `field.enum`". Drift/golden gates enforce no change to the inline default. |
| 87 | + |
| 88 | +Each port's generators are subclass-extensible (the extensibility rounds), so an |
| 89 | +adopter can further tailor the materialized enum or its reference without forking. |
| 90 | + |
| 91 | +## Conformance (Tier 5) |
| 92 | + |
| 93 | +Add to `fixtures/conformance/` (metamodel/loader) + the per-port codegen golden |
| 94 | +suites: |
| 95 | +- **`enum-shared-materialized-once`** — a package-level abstract `field.enum` |
| 96 | + extended by two entities; assert (per port) the enum type is materialized ONCE |
| 97 | + at package/module level and both entities reference it (no nested redeclaration). |
| 98 | +- **`enum-provided-not-materialized`** — `@provided: true`; assert no enum type is |
| 99 | + emitted and consuming fields reference the configured external name; assert the |
| 100 | + `@values` still drive validation + the `CHECK`. |
| 101 | +- **`error-provided-not-boolean`** — `@provided: "yes"` → `ERR_BAD_ATTR_VALUE` |
| 102 | + (all five ports, own-only; byte-identical envelope where provenance allows — |
| 103 | + respect the Java override-field provenance deferral if it applies). |
| 104 | +- A codegen-config fixture exercising the per-port `enumNamespace`/`enumPackage` |
| 105 | + resolution for the `@provided` case. |
| 106 | + |
| 107 | +The corpus is the oracle: TS reference green first, then each port matches. |
| 108 | + |
| 109 | +## Realization status |
| 110 | + |
| 111 | +- **Unimplemented.** This spec + ADR-0026 lock the decision. Implementation order: |
| 112 | + (1) loader `@provided` attr + validation + conformance fixtures (TS green); |
| 113 | + (2) TS shared-materialize + provided-reference codegen; (3) per-port fan-out |
| 114 | + (C#/Java/Kotlin/Python) against the fixtures; (4) retire `@csEnumType`. |
| 115 | + |
| 116 | +## Cross-references |
| 117 | +- [ADR-0026](../../../spec/decisions/ADR-0026-shared-and-provided-named-types.md) — `@provided` as a cross-type provenance flag (enums + value objects); shared vs provided orthogonal; Option 2 upgrade path. |
| 118 | +- [ADR-0001](../../../spec/decisions/ADR-0001-cross-language-type-binding.md) — metadata→native type binding is per-port build-time config, not metadata. |
| 119 | +- [enum datatype design](2026-05-23-enum-datatype-design.md) — D6 abstract-enum reuse; deferred int-backed/display-label/native-PG-enum. |
0 commit comments