Skip to content

Int-backed field.enum (@intValueMap) — metamodel + persistence, all five ports - #291

Merged
dmealing merged 53 commits into
mainfrom
feat/int-backed-enum-values
Aug 17, 2026
Merged

Int-backed field.enum (@intValueMap) — metamodel + persistence, all five ports#291
dmealing merged 53 commits into
mainfrom
feat/int-backed-enum-values

Conversation

@dmealing

@dmealing dmealing commented Aug 15, 2026

Copy link
Copy Markdown
Member

Int-backed field.enum (@intValueMap) — the whole train: metamodel + persistence in
all five ports + the cross-port round-trip gate.

A field.enum may declare @intValueMap (member symbol → integer) so it persists as an
integer column with an integer CHECK instead of varchar with a string one. Every
language-facing type and the wire format stay the member-string union; this is a
persistence-layer concern only.

Provenance: a live requirement from a downstream consumer modelling an existing
integer-coded schema.

The earlier "REVIEW ONLY — do not merge" banner is withdrawn. It existed because the
metamodel had shipped with no persistence behind it (the field.byte/short/class
precedent — registration-only vocabulary gets cut), and because adding intEnumVal to the
shared persistence-conformance corpus reddens every port whose codec has not landed.
Both conditions are now cleared: all five codecs ship here, and the corpus carries the
field.

What is here

Metamodel (all five ports): attr.intMap + @intValueMap, registry + conformance
fixtures, the #246 int-backed twin rule (an own @intValueMap against a shared enum is
rejected for the same reason an own @values is), and @provided corrected to an own-only
read (ADR-0039 amended to charter it as the second deliberately-own-only attr).

Persistence, all five ports: Drizzle customType (TS), EF Core HasConversion (C#),
OMDB JdbcFieldCodec (Java), Exposed customEnumeration (Kotlin), ObjectManager coercion
(Python) — plus migrate-ts's integer column, integer CHECK and @default lowering.

The TS codec is a Drizzle customType rather than the plan's Zod-write-transform-plus-
generated-read-decode, and that decision is the load-bearing one: the vanilla read path
returns raw Drizzle rows verbatim and has no decode seam, so the original route meant
inventing one and wrapping every generated read of every entity. Binding through the column
type means insert encodes, select decodes, and filter comparisons encode for free — and it
is the direct analogue of the other four ports' existing codec seams, so all five ports land
on ONE design.

Four things the plan got wrong, found by probing

  1. The filter-operator band is field-level. like is meaningless against an integer
    column and is the one operator in the string/enum band that cannot be rescued by
    encoding. opsForSubType is subtype-keyed and structurally cannot express that — it only
    ever sees "enum" — so the generated allowlist offered like on an int-backed field
    byte-identically to a string-backed one. Fixed as one loader rule per port
    (opsForField) rather than five codegen filters, so an authored attr.filter fails at
    LOAD. C#'s codegen carried its own duplicate band table; it was deleted rather than
    extended.

  2. A meta migrate blocker. A projection row-scope @filter (Projection / entity read-view: semantic row-scope @filter (view-level WHERE) reusing the shipped attr.filter AST #207) and an
    origin.aggregate @filter render as literal SQL and never touch Drizzle, so they emitted
    WHERE p.status = 'PUBLISHED' against an integer column — rejected by Postgres at CREATE
    VIEW time, aborting the migration. Affected every operator, not just like, and needed
    fixing in two separate resolvers.

  3. D7 is reversed — int-backing is scalar-only. The design said an array-of-enum
    "composes unchanged", assuming the element codec falls out of the scalar one. It does
    not. Int-backing is a persistence-layer CODEC and every port's codec seam is scalar by
    construction: Python bound the symbol LIST into an integer[], OMDB's EnumCodec and
    Kotlin's customEnumeration bind one value, and TS's sqlite branch serialized the array
    as JSON text before the enum case was reached — storing symbols. Only TS/Postgres and C#
    composed, and two ports composing while four silently get it wrong is not a feature
    it is exactly the field.byte/short/class mistake. Now ERR_ENUM_INT_VALUE_MAP_ARRAY
    at LOAD, in every port, which delivers the guarantee that was actually missing: identical
    behaviour everywhere. Both halves are read RESOLVING, because post-Kotlin codegen: generated *Table / *RepositoryBase reference a cross-package shared field.enum without importing it #246 the map lives on
    the shared abstract declaration while isArray is declared by the consuming field — an
    own-only read would see them on different nodes and never fire, which is why the
    inherited case gets its own fixture rather than being assumed to follow.

  4. A stored int that maps to no member now THROWS, in all five. Java surfaced the raw
    int as the "member" ("7"), Python returned it verbatim, and C# fell through its ternary
    chain to the LAST member — handing the caller ARCHIVED for a row that is not archived.
    TS and Kotlin already threw, so one corrupt row behaved four different ways across five
    ports. Neither alternative is honest: the raw value is not representable in the ports that
    type the property as a closed enum, and null hides the corruption behind a nullable
    column. C# reaches it via a generated static helper called from the provider→model lambda
    — CS8188 bans a throw-expression in an expression tree, but a method CALL is legal
    there. The WRITE side is deliberately left to the database.

Task 9 needed no product change. Every TPH path is Drizzle-mediated, so the column-level
codec already covers the per-subtype read schemas and the discriminator. The int-backed
discriminator is SUPPORTED — the documented fallback (reject it with a named loader
error) was not taken. Proven with seven real-Postgres tests rather than by reading generated
source.

Gates

Re-run on the final tree: TS metadata 2387 · codegen-ts 1122 · migrate-ts 739
(+22 skip) · conformance 55 · Python 1724 · Java metadata 1400 (conformance
564, OMDB codec round-trip 8) · C# Conformance 899 / Codegen 345 / Render
291 / Cli 46 / IntegrationTests model 2. The full ci-local.sh --only java --only python --only csharp docker matrix is running; result posted below.

Earlier real-Postgres work stands: the enum suite (22, +7 TPH) verified both migrate fixes
load-bearing by disabling them and confirming the PG tests go red. That gate is the one
that matters here — this repo's 0.15.21 line was a family of destructive migrate bugs that
survived thousands of tests because emit and introspect had never been in the same room.

The two new fixtures were verified non-vacuous per port rather than trusted: both appear
by name in the Java surefire XML, match 2 collected pytest cases, and run under the TS and C#
directory-scan runners with no expected-failures ledger entry.

Review focus

  • The customType decision — it is the one that commits the other four ports.
  • Every @intValueMap read must be RESOLVING. Post-Kotlin codegen: generated *Table / *RepositoryBase reference a cross-package shared field.enum without importing it #246 the map lives on a shared
    root-level abstract declaration and consuming fields inherit it, so an own-only read sees
    undefined on the canonical authoring shape and silently emits a string codec into an
    integer column — data corruption with no compile error.
  • The opsForField split: opsForSubType is deliberately KEPT for the one caller with no
    field in hand (the expression grammar's declared operand type).

Known, and deliberately not fixed here

  • Re-mapping a member's integer is undetected, and one shape of it is silent. D8 covers
    ADDING or REMOVING @intValueMap; it does not cover changing a value inside a map that
    stays present. Moving a member to an int not already in the set changes the CHECK, so the
    migration applies a constraint the existing rows violate and the database refuses it —
    loud. But swapping two members' ints leaves the value set identical: the CHECK is
    byte-identical, the diff is EMPTY, no migration is emitted, and every stored row changes
    meaning. Nothing in the pipeline can see it, because the column holds bare integers and
    neither introspection nor the committed snapshot records which member an integer stood
    for. Closing it needs the mapping carried in gen-state or the snapshot — a design
    decision, not a patch. Named in the design doc's follow-ups rather than left implied by
    D8's "migration safety" heading.
  • C# and Python validate no ops at all in their projection @filter pass, while TS does, so
    the load-time like rejection lands in TS only for that surface. Pre-existing hole in
    projection-filter validation generally — it affects every subtype, not just int-backed
    enums.

Docs

Design: docs/superpowers/specs/2026-07-23-int-backed-enum-values-design.md (the source of
truth). Adopter view: docs/features/field-types.md. CHANGELOG.md gains the [Unreleased]
entry — this is registered vocabulary, so the line carrying it is a MINOR. The five
implementation plans are banded SUPERSEDED: they are kept for provenance but actively
mislead now, since every array-of-enum fixture and element-wise codec in them describes
vocabulary that cannot load, and some sketched tests call APIs that do not exist.

🤖 Generated with Claude Code

@dmealing dmealing changed the title Int-backed field.enum (@intValueMap) — TS persistence [REVIEW ONLY, do not merge] feat(metadata): int-backed field.enum storage via @intValueMap Aug 16, 2026
dmealing and others added 29 commits August 16, 2026 11:03
Explores the deferred D4 non-goal from the original field.enum design
(2026-05-23) — explicit, sparse, per-member integer DB storage. Researched
prior art (Rails, protobuf, EF Core, Django, GraphQL, OpenAPI, JPA, Prisma)
before locking the shape: a name-keyed @intValueMap object, not a parallel
array index-matched to @values, since positional correspondence is the one
documented failure mode in the survey (OpenAPI's x-enum-varnames).
… declaredSchemas to views too

buildForeignKeys resolved a target FK field's PHYSICAL column by applying the
naming strategy to its raw (logical) name — a target PK with an explicit
@column override phantom-diffed every FK into that table (expected the
naming-strategy name, actual the override). It now resolves through the
target entity's own field, matching how fkCols already handles the source
side.

declaredSchemas previously came only from expected.tables, so a model that
declares views in a schema with no table of its own (an API/read-model
schema sitting alongside an all-public entity model) never brought that
schema into scope — its views were silently excluded from both sides of the
diff rather than gated on it as owned.

Also scopes the source-less-object skip (added alongside these two fixes) to
non-entity subtypes: a plain object.entity with no declared source.rdb keeps
the pre-Project-E default of an implicit writable table; only object
subtypes other than entity (an adopter-registered config/reference type with
no physical table) are excluded from the table diff.
…l layer)

Covers vocabulary + validation + conformance across all five ports per the
approved design (docs/superpowers/specs/2026-07-23-int-backed-enum-values-design.md).
Persistence (DDL, codecs, migration-safety guard) is scoped to follow-on
plans, written separately per the writing-plans Scope Check — this plan is
independently testable and shippable on its own.
…s attr subtype

Foundation piece for field.enum's upcoming @intValueMap (Task 2). Generic
shape-only validation (object, every value an integer); regenerates the
embedded attr definition and the metamodel-docs fixture accordingly.
…for DB persistence

Ports the TS/C#-shipped @intValueMap vocabulary to Java: a new attr.intMap type
(IntMapAttribute, Map<String,Integer>-backed — NOT PropertiesAttribute, which
coerces every value to String and would corrupt int fidelity) and field.enum's
optional @intValueMap attribute, with field.enum-specific validation (key-set
exactly equals @values, no duplicate int values) in ValidationPhase, reusing
ERR_BAD_ATTR_VALUE.

Also fixes CanonicalJsonSerializer's object-attr serialization to match the
TS/C# cross-port value-shape heuristic: an all-scalar-valued object attr
(attr.properties, attr.intMap) sorts its keys alphabetically in canonical form;
an attr carrying nested object/array values (attr.filter, attr.expression)
preserves declaration order. Required for the enum-int-backed /
enum-int-backed-array conformance fixtures to pass — IntMapAttribute uses
DataTypes.OBJECT (matching FilterAttribute) rather than DataTypes.CUSTOM, since
CUSTOM has no Map-shaped serialization branch and would emit Java's
Map#toString() form instead of JSON.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ia extends

field.enum's @intValueMap content-rule check (Rule 4: key-set-must-match
@values, no duplicate int values) lived inside the same loop iteration that
`continue`d early whenever `node.attr(FIELD_ATTR_VALUES)` returned None (i.e.
@values inherited via extends rather than owned). A concrete field.enum that
inherits @values from an abstract parent but declares its own @intValueMap
locally therefore had that map completely unvalidated.

Extract the check into a standalone `_validate_enum_int_value_map`, called
unconditionally (mirroring TS/C#/Java, which all treat this check as
independent of value ownership), using `_effective_enum_values(node)`
(own-or-inherited) as the membership set instead of the own-only `own_values`.

Adds regression tests covering the inherited-@values + own-@intValueMap
scenario (missing key, extra key, and a valid positive case).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…C#/Python

Java's IntMapAttribute already rejected an @intValueMap member value outside
the 32-bit signed int range, but TS's IntMapAttr, C#'s ValueMatchesType, and
Python's _type_ok accepted any integer (int64+ range, since TS numbers and
Python ints have no fixed width). The eventual DB column for an int-backed
enum is a 32-bit Postgres/SQLite integer (design doc D5, matching field.int's
existing mapping) — a value outside [-2147483648, 2147483647] can never
actually be persisted, so it should fail at load time on every port, not just
Java. Tightens all three to reject out-of-range values via the existing
ERR_BAD_ATTR_VALUE code (no new error code), mirroring Java's inclusive bound
check exactly. Adds a negative test to each port's @intValueMap test file.
No shared fixture exercised "inherited @values via extends, own @intValueMap
locally" — exactly the bug class that broke Python's @intValueMap validation
(fixed in 5fdb34c). TS and C# were independently verified by reading their
source to handle it correctly, but nothing gated that behavior going forward.

Modeled on enum-abstract-extends: an abstract field.enum declares @values;
a concrete field.enum extends it (inheriting @values) while owning its own
@intValueMap with a valid key set and unique ints. Verified green against
all four loaders (TS conformance.test.ts, C# ConformanceTests, Java
ConformanceTest, Python test_conformance.py).
…backed twin)

FR-019 materializes a root-level abstract field.enum ONCE per port as a single
named type. #246 already forbids a consuming field from re-declaring that shared
type's @values. @intValueMap is @values' numeric half -- the symbol->int mapping
belongs to the enum VOCABULARY, not to one column that uses it -- but it had no
equivalent rule: the #246 check is nested inside the own-@values block, so a
field declaring its own @intValueMap while inheriting @values sailed through.

Left alone, that gap detonates when the int-backed persistence plans land:

  - Kotlin emits per-package lookup maps keyed by enum CLASS name
    (${enumClassName}_TO_INT). Under the shared collapse two consuming fields
    produce two top-level vals with the same name -- a compile error, and one
    that fires even when the two maps are identical (the emitter iterates
    (class, field) pairs with no dedupe).
  - TS/Java/Python read the map per-field via resolving accessors, so divergent
    maps "work": one logical type silently gets N storage encodings, with one
    field possibly int-backed while another stays string-backed.

Nothing was broken before this commit -- @intValueMap is still metamodel-only
(no codegen, runtime or migrate layer reads it in any port), so the hazard was
latent, not live.

The rule mirrors #246 exactly: an own @intValueMap whose IMMEDIATE super is an
abstract, metadata-root-level field.enum is ERR_ENUM_EXTENDS_VALUES_CONFLICT.
The existing error code is reused with attr-specific message text -- the
semantic ("a concrete field redefines a shared enum's owned contract") is the
same, and a new code would cost four error ledgers plus registry gates for no
behavioral gain. Each port now derives "is the super shared?" from ONE predicate
(sharedEnumSuper / _shared_enum_super / SharedEnumSuper) used by both halves,
immediate-super-only so the validator and codegen's resolveSharedEnumDecl agree
on what "shared" means. A non-root abstract super (one nested inside an object)
stays legal and keeps a per-field map.

Fixture enum-int-backed-inherited-values was blessing the now-illegal shape in
all five ports; rewritten so the shared declaration owns @values AND
@intValueMap and the consuming field inherits both -- the pattern the design doc
intended all along, and what the fixture's name already claimed. Added the
negative twin error-enum-extends-intvaluemap-conflict alongside
error-enum-extends-values-conflict.

Python's three Task-10 regression tests (own map validated against INHERITED
@values) used a root-level abstract, so they now trip the new rule; re-pointed
at an abstract enum nested inside an abstract object -- the non-root shape
upstream already pinned as legal -- which preserves exactly the bug class they
were written to guard.

Verified: TS metadata 2342/0, conformance corpus 546/0, fixture lint 277 clean,
codegen-ts 1074/0, workspace typecheck clean; Python 1681/0; C# 1558/0 (1
pre-existing skip); Java 1326/0; Kotlin 313/0 (JDK 21 -- the Kotlin plugin still
can't parse JDK 26, pre-existing).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…/C#/Python

@provided marks a shared enum declaration as supplied by hand-written /
third-party code: the port emits nothing and references the existing type
(ADR-0026). TS, C# and Python read it RESOLVING; Java and Kotlin read it
own-only and documented that as deliberate. One of them had to be wrong.

The JVM side is right. @provided is a provenance fact about the declaration
ITSELF -- like `abstract` -- not a property of the values it carries, so it must
not flow down an extends chain. All five ports already read it on the resolved
DECLARATION and never on the consuming field, so for the ordinary
`field extends @provided decl` shape own and resolving agree; the divergence is
reachable only through a CHAINED declaration -- a root-level abstract enum
`B extends` a root-level abstract `@provided A`. Verified against the real
loader: that model loads clean (zero errors), B's own @provided is absent while
its resolving read is true. So the resolving ports classify B as provided and
emit a reference to a hand-written `B` THE ADOPTER NEVER DECLARED (the marker
was authored on A), instead of materializing B from its inherited @values.

Python's docstring justified resolving with "a concrete enum extending an
abstract @provided enum inherits the flag, so an own-only read would misclassify
it" -- wrong about its own call graph, since is_provided() is only ever passed
the decl. C#'s comment just cited TS. Neither was a reasoned position.

Blast radius is nil on existing gated output: every currently-pinned model shape
yields the same answer under both reads, which is exactly why this survived.

ADR-0039 amended: its "@dbColumnType is the *only* attribute deliberately read
own-only" line was false as written no matter which way this ruled, since the
JVM own-reads already existed. @provided is now chartered as the second, with
the chained-decl rationale and an explicit note that the member set it
accompanies (@values, and its numeric half @intValueMap) stays RESOLVING.

No conformance fixture yet -- see the follow-up below.

Verified: TS codegen-ts 1074/0 + workspace typecheck clean; Python 1681/0;
C# 1558/0 (1 pre-existing skip); Java codegen-spring Fr019 conformance 3/0.

FOLLOW-UP (deliberately not in this commit): adding a chained-decl case to
fixtures/codegen-conformance/shared-provided-enum -- the corpus all five ports
gate -- surfaced a SECOND, deeper divergence that needs a design ruling of its
own. Kotlin deliberately names a chained abstract enum after the TOP-MOST root
(KotlinTypeMapper.enumTypeName, "a chain of abstract enums still collapses onto
one type"), so Kotlin holds that Money IS Currency while every other port holds
that Money is its own type. On that input Kotlin materializes a local
Currency.kt while ALSO referencing the external com.acme.ext.Currency -- broken
under either model. Resolving it means either aligning Kotlin's collapse on the
immediate super, or rejecting chained abstract enum declarations in the loader
(post-#246 such an alias can carry neither its own @values nor its own
@intValueMap, so it adds nothing). Fixture withheld until that is decided rather
than pinning one port's accidental behavior.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion

Kotlin named a chained abstract enum after the TOP-MOST root of the extends
chain (KotlinTypeMapper.enumTypeName via resolveSuperRoot) while its OWN FR-019
arm resolved the shared declaration from the IMMEDIATE super
(Fr019SharedEnum.kt:58, `field.superField`) -- the same immediate-super rule
TS/C#/Java/Python all use. Not a rival model, a split-brain: the two halves
disagreed about which declaration is "the type".

On a chained declaration -- root abstract `Money extends` root abstract
`@provided Currency` -- that produced a flatly broken emit: the
materialize-vs-reference decision saw `Money` (own @provided absent -> materialize)
while the NAME collapsed to `Currency`, so Kotlin wrote a local `Currency.kt`
that collided with the `com.acme.ext.Currency` reference emitted for fields
extending `Currency` directly. Wrong under ANY model, so there was no reading in
which the old code was correct.

Naming now uses the immediate super, per ADR-0026 §2 (a materialized type is
named for its own declaration). A chain yields one type per declaration, each
carrying the members it inherits (KotlinEnumEmitter.readEnumValues is
inheritance-aware across any number of hops). resolveSuperRoot had exactly one
caller and is deleted.

Non-chained output is byte-identical: with no further super the root walk
already returned the immediate super. The #259 two-hop projection guard is the
`declaringObject == null` condition evaluated BEFORE this branch and keys on
CONCRETE supers, so it is untouched -- KotlinProjectionTwoHopEnumTest stays green.

Why the chained alias stays LEGAL rather than being rejected in the loader: it
cannot mutate the vocabulary it inherits. Verified against the real loader -- a
chained declaration carrying its own @values errors ERR_ENUM_EXTENDS_VALUES_CONFLICT,
and so does its own @intValueMap (60dd3c8). #246's Check 4 is gated on any
field.enum node, "concrete or abstract", so the decl-level case was already
enforced in code. The alias may rename a vocabulary for a bounded context;
nothing more. Banning it would carve an enum-only hole in ADR-0029's general
`extends` grammar to delete a construct that is provably harmless, and cost four
loaders plus error-ledger entries to do it.

Gating added:
  - the chained declaration is restored to fixtures/codegen-conformance/
    shared-provided-enum, the corpus ALL FIVE ports load, with explicit
    assertions in the TS and Kotlin FR-019 conformance tests (Money materialized
    under its own name with the inherited members; Currency still NOT
    materialized; the consumer references the local type, not the external one).
  - fixtures/conformance/enum-abstract-chained-extends (positive) pins that the
    chain loads clean and canonical-serializes identically cross-port -- legality
    previously rested on a single ad-hoc run.
  - fixtures/conformance/error-enum-chained-extends-values-conflict (negative)
    pins the DECL-level #246 firing, which until now was code-only in all
    five ports with no fixture behind it.

Verified: TS metadata 2354/0, corpus 552/0, codegen-ts 1085/0, fixture lint 280
clean, typecheck clean; Python 1687/0; C# 1564/0 (1 pre-existing skip, conformance
879->885 picking up the new fixtures); Java metadata 1373/0; Kotlin 313/0 with the
FR-019 class at 4/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#246 tree

Written 2026-07-23 against a pre-#246 / pre-FR-019-hardening tree. Tasks 1-6
survive (every file anchor re-verified against the current tree and all resolve),
but four things changed underneath it and three persistence surfaces were never
covered.

Amendment 1 -- the metamodel moved. #246's int-backed twin now puts @intValueMap
on the SHARED declaration with consuming fields inheriting it; @provided became
declaration-layer; chained abstract declarations are legal and each materializes
under its own name. The load-bearing consequence: every codegen read of
@intValueMap MUST resolve through extends. An own-only read sees undefined on
every consuming field of a shared enum and silently emits a STRING codec into an
INTEGER column -- silent data corruption, not a compile error. Also pins that a
per-TYPE codec artifact must be emitted once per declaration, not once per
consuming field (the shape that collides in the Kotlin plan; TS's per-field
naming is safe, now deliberately rather than accidentally).

Amendment 2 -- three verified gaps, added as Tasks 7-9:
  7. @default lowering. buildColumn emits DEFAULT 'DRAFT' on what Task 1 makes an
     integer column (expected-schema.ts:943-949) -- un-appliable DDL. Same defect
     in column-mapper's Drizzle .default().
  8. The filter path. parseFilterParams coerces by subType and binds the result
     (filter-parser.ts:156-186, coerce at :215), so ?filter[status][eq]=DRAFT
     binds 'DRAFT' against an integer column. Fix follows the EXISTING dateValues
     precedent exactly -- the generated allowlist carries the per-column datum and
     the parser honours it, keeping the parser metadata-free.
  9. TPH per-subtype read schemas. renderTphSubtypeReadSchema parses DB rows, so
     an integer row value hits a string z.enum and is rejected. Same class of miss
     as #203/#229, where every TPH per-subtype path needed @autoset wired
     separately after the vanilla path had it. The plan called TPH "a follow-up if
     discovered incomplete" -- it is incomplete.

Amendment 3 -- Task 6 edits the SHARED persistence-conformance corpus and reddens
the other four ports on landing. Run it last, or hold it for the joint train.

Amendment 4 -- array-of-enum needs explicit element-wise codec tests; the enum
CHECK is already skipped for arrays, so membership stays app-level as it is today.

Also corrects the now-false Global Constraint "do not touch the metamodel layer --
it's already done": #246 falsified that premise before this plan ever ran.
Metamodel changes now need justification rather than being forbidden.

The other three port plans are deliberately left unamended -- they get rewritten
from what this execution actually learns, not from paper analysis, since TS owns
the schema layer (ADR-0015). Their known port-specific defects are recorded under
"After this plan lands" so nothing is lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… (Tasks 1, 2, 7)

The DDL half of the TS int-backed-enum persistence plan. An @intValueMap turns a
field.enum's physical column from text into integer; every TS-facing and wire type
is untouched (that is Tasks 4/5, still to come).

  Task 1 — column type. subtypeToSqlType gains an explicit FIELD_SUBTYPE_ENUM case
    (it previously fell through to the `text` default) returning integer{32} when a
    map is present, and arrayElementSqlType does the same so an int-backed enum[]
    is integer[] rather than text[] (design D7).

  Task 2 — membership CHECK. buildChecks emits `IN (0, 5, 9)` unquoted instead of
    `IN ('DRAFT', …)`. @values stays the SSOT: the integers are read THROUGH the map
    keyed by member, so a member with no mapping cannot silently disappear from the
    constraint -- it throws instead. Arrays keep getting NO field-level CHECK, as
    they already did for string-backed enums (membership stays app-level).

  Task 7 — @default. buildColumn lowered a string @default straight to a literal, so
    an int-backed enum emitted `DEFAULT 'DRAFT'` on an integer column: un-appliable
    DDL, and permanent false drift anywhere it landed. It now lowers through the map.
    Pinned including the DRAFT->0 case, since a zero-valued member is falsy and is
    exactly the kind of value a truthiness check would drop (cf. #235).

All three read @intValueMap RESOLVING via a new shared `intValueMapOf` helper, and
that is the load-bearing detail rather than an incidental one: post-#246 an own
@intValueMap against a shared enum is ERR_ENUM_EXTENDS_VALUES_CONFLICT, so the map
lives on the SHARED DECLARATION and consuming fields INHERIT it. The inherited case
is therefore the canonical authoring shape, not an edge case, and an own-only read
would emit a text column for an integer-encoded value on every consuming field of
every shared enum -- silent data corruption with no compile error. Two tests pin the
inherited shape directly (map inherited; map AND array-ness inherited).

Verified: 13 new tests; full migrate-ts suite 736 pass / 0 fail (22 pre-existing
skips); workspace typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Tasks 3, 4)

  Task 3 — migration-safety proof, no new code. Toggling @intValueMap on a field
    that already has a column is a text<->integer change-column-type, and
    isWidening returns false for ANY cross-kind pair (sql-type.ts:63), so
    blockedReasonFor already blocks it without allow.typeChange. Proven end-to-end
    through the real metadata path rather than asserted about hand-built snapshots:
    adding the map is blocked, REMOVING it is blocked too, an explicit
    allow.typeChange unblocks the documented manual-recast path, and an unchanged
    backing emits no change-column-type at all.

  Task 4 — the Drizzle column mapper follows migrate-ts. An int-backed enum is an
    `integer` column on postgres AND sqlite (two separate switch arms — the sqlite
    one was missed on the first pass and caught by its own test), its CHECK lists
    unquoted integers, and the `{ enum: [...] }` literal-union option is suppressed
    since that is a text-column affordance that would type a numeric column as a
    string union. Arrays get a native integer array and, as before, no CHECK.

The CHECK expressions here are deliberately the mirror of migrate-ts's buildChecks
and are pinned in both packages, because a disagreement between codegen and the
expected schema is exactly the drift `meta verify --codegen` exists to report --
it would surface to an adopter as permanent, unfixable drift on a correct model.

Both packages now key off `intValueMapOf`, a RESOLVING read (ADR-0039). Post-#246
the map lives on the SHARED DECLARATION and consuming fields inherit it, so the
inherited shape is canonical, not exotic; each package pins it directly. Member ->
integer lookups go through `intValueForMember`, which throws rather than falling
back to the symbol: the loader pins key-set-equals-@values in every port, so a miss
is unreachable, and emitting the symbol would defer the failure to INSERT time
against a live database.

Verified: 7 new codegen-ts tests + 4 new migrate-ts tests; codegen-ts 1092 pass /
0 fail, migrate-ts 762 pass / 0 fail; workspace typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d enums

The requirement is a live downstream-consumer need (modelling an existing
integer-coded schema), but it was recorded nowhere in this repo's issues or
roadmap. On review that absence made the whole program look speculative and
nearly got it de-scoped -- the demand was real, just invisible from inside the
repo. Recorded genericized, per the public-repo hygiene rule.

Also notes, for whoever reads this next: if an adopter's reason is purely storage
SIZE rather than matching an encoding they don't control, native Postgres enum is
the better instrument (also 4 bytes, keeps string semantics, needs no codec in any
port). Int-backing's unique value is matching a foreign encoding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The symbol<->int translation now lives in the COLUMN DEFINITION, as a generated
`customType` with toDriver/fromDriver, emitted ahead of the table:

  const STATUS_TO_INT = { "DRAFT": 0, ... } as const satisfies Record<..., number>;
  const STATUS_FROM_INT: Record<number, "DRAFT" | ...> = { 0: "DRAFT", ... };
  const statusIntEnum = customType<{ data: "DRAFT" | ...; driverData: number }>({
    dataType: () => "integer",
    toDriver: (value) => STATUS_TO_INT[value],
    fromDriver: (value) => { ...throw on unmapped... },
  });

This REPLACES the plan's Zod-write-transform-plus-generated-read-decode design.
Tracing Task 5 turned up an asymmetry the plan had not seen: the vanilla read path
returns raw Drizzle rows verbatim (`return row ?? null` / `return row!` at
queries-file.ts:168/:237/:249) and has NO decode seam at all, so that route meant
inventing one and wrapping every generated read function of every entity. The TPH
read path, by contrast, already parses through a schema (:311/:319/:342) -- so the
task flagged as broken (Task 9) is the one that already had the seam.

Binding through the column type instead means nothing downstream changes:
db.insert().values() encodes on bind, a selected row decodes on read, and a filter
comparison encodes for free -- which collapses most of Task 8 (the filter-parser
and generated allowlist need no int map threaded through them).

It is also the more PORTABLE choice, which is the opposite of how it first looked.
TS appeared uniquely expensive only because its generated queries hand back raw
rows; every other port already has a MetaField-level codec seam -- EF Core
HasConversion, OMDB JdbcFieldCodec, Exposed customEnumeration, Python
ObjectManager coercion. customType IS the TS analogue, so all five ports land on
the same design instead of TS carrying a bespoke one.

Details worth keeping: fromDriver THROWS on an integer outside the map rather than
returning undefined (a value the model says is impossible means hand-written data
or a member removed without a migration; yielding undefined for a non-nullable
field would surface far from the cause). The maps are keyed by member in @values
order, so @values stays the SSOT. Helper consts are named from the field and are
per-file, so a shared enum consumed by N entities emits N small identical helpers
rather than forcing a cross-module import -- the same self-contained tradeoff the
per-entity enum union already makes. Emission is sorted by const name so output is
deterministic regardless of field order, and a string-backed enum emits nothing
new (byte-identical output, pinned).

Verified: 6 new emission tests + 8 column-mapper tests; codegen-ts 1099 pass /
0 fail; workspace build + typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…onverge, enforce

The first end-to-end validation of this program. Everything before it was unit
assertions plus reading generated source: migrate-ts SAYS integer, codegen-ts SAYS
customType, both SAY the CHECK lists unquoted ints. None of that proved the DDL
applies, that a second migrate converges, or that the constraint enforces the
integers it claims to.

Pulled forward ahead of Tasks 8/9 deliberately -- finishing those on unproven
foundations is how this repo got the 0.15.21 line, where destructive migrate bugs
survived thousands of tests because nothing ever ran the pipeline twice against a
real engine. `emit` and `introspect` had never been in the same room.

Seven scenarios, all green:
  - the emitted DDL APPLIES, and a second migrate CONVERGES (empty diff) -- the
    false-drift gate, which is the failure mode a codegen/expected-schema
    disagreement would have produced.
  - the physical column is `integer`, read back from information_schema.
  - the CHECK enforces the MAPPED INTEGERS: status=5 (PUBLISHED) inserts, status=7
    (no member) is rejected. A CHECK emitted over member strings, or omitted, would
    have let 7 through.
  - an ORDINAL-looking value is rejected: ARCHIVED is index 2 in @values but maps to
    9, so anything deriving the stored int from member POSITION -- design Goal 3's
    named hazard, the OpenAPI x-enum-varnames failure mode -- would accept 2. It
    does not.
  - @default lands as the mapped integer (5, not 'PUBLISHED'), and reaching that
    assertion at all is part of the proof: DEFAULT 'PUBLISHED' on an integer column
    would not have applied.
  - the string-backed control still gets `text` and still converges.
  - toggling the backing on an existing table is BLOCKED, against a real
    introspected schema rather than a hand-built snapshot.

The model puts @intValueMap on a SHARED root-level abstract declaration with the
field inheriting it -- the shape #246 steers authors toward, and the one an
own-only read would silently get wrong -- so the resolving-read decision is now
validated against a real database, not just a unit test.

Verified: 7/7 against Testcontainers Postgres; workspace typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Postgres

Extends the PG gate from the schema to the codec. The previous block drove raw
SQL, so customType's toDriver/fromDriver had still never executed against a
database -- the codec was verified only by reading the source we generate.

This runs the real `runGen` pipeline into .gen-tmp/, imports the emitted entity
module UNMODIFIED, and drives Drizzle through it. Six scenarios:

  - a member symbol written through Drizzle lands as the mapped INTEGER, asserted
    by reading the physical value with raw SQL -- bypassing the codec, so it proves
    what is on disk rather than that the two directions cancel out.
  - an integer inserted by raw SQL (never through toDriver) reads back as its
    member symbol, which is the half a round-trip-only test cannot distinguish.
  - every member round-trips INCLUDING the zero-valued one. DRAFT -> 0 is falsy and
    therefore exactly what a truthiness-based codec drops (the #235 bug class);
    0 is also asserted on disk.
  - a WHERE comparison on a member symbol encodes through the column type. This is
    the load-bearing claim behind choosing customType -- that filters work with NO
    filter-layer change -- and it is now demonstrated rather than argued, which
    collapses most of Task 8.
  - an `in` comparison encodes every member in the list.
  - an unmapped stored integer THROWS on read rather than yielding undefined
    (CHECK dropped first to simulate data written before a member was removed, or
    by a hand-written migration).

Two path assumptions were wrong and are now settled empirically rather than
guessed: the generated module is emitted FLAT (`Order.ts`, not `acme/Order.ts`),
alongside an `enums.ts` because the shared root-level abstract `Status` is an
FR-019 shared enum -- so this also incidentally covers the codec working when the
member union comes from a shared enum module rather than an inline union.

Verified: 13/13 in this file against Testcontainers Postgres (7 schema + 6 codec);
workspace typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cked enum drops `like`

An int-backed `field.enum` (`@intValueMap`, design D5) persists as an INTEGER
column, so `like` -- a substring match -- is meaningless against it. It is also
the ONE operator in the string/enum band that cannot be rescued by encoding:
`eq`/`ne`/`in` all lower the member symbol to its integer before the value
reaches SQL, but `like` has no such encoding, and an unencoded `LIKE 'DRAFT'`
against an integer column is a request-time type error.

`opsForSubType` cannot express this -- it only ever sees the subtype `"enum"`,
so the generated `<Entity>FilterAllowlist` offered `like` on an int-backed field
byte-identically to a string-backed one. The band is therefore a property of the
FIELD, not the subtype. Every port gains an `opsForField` peer; `opsForSubType`
is deliberately left unchanged for the one caller that genuinely has no field in
hand (the expression grammar's declared operand type).

Fixed as ONE loader rule per port rather than five per-port codegen filters, on
the precedent of #210 and the `@objectRef` payload rule -- an authored
`attr.filter` / dataGrid `@filter` using `like` on an int-backed enum now fails
at load, not later at the SQL layer.

Ports:
- TS      `opsForField` in query-constants; loader dataGrid + projection filter
          passes, filter-allowlist, filter-type, conformance binding.
- Java    `FilterOps.opsForField`; ValidationPhase, SpringFilterAllowlistGenerator,
          ScriptRunner.
- Kotlin  KotlinFilterAllowlistGenerator (its own copy of the call, shared band).
- C#      `QueryConstants.OpsForField`; ValidationPasses, CapabilityBinding, and
          FilterAllowlistGenerator -- whose OWN duplicate per-subtype band table is
          DELETED rather than extended, since two tables is exactly how bands drift.
- Python  `ops_for_field` (loader) + `ops_for_field_ordered` (codegen); dataGrid
          pass, allowlist generator seam, conformance capability.

Every port reads `@intValueMap` RESOLVING (ADR-0039). Post-#246 the map lives on
a shared root-level abstract declaration and consuming fields INHERIT it, so an
own-only read would see it absent on exactly the shape adopters are steered
toward and wrongly keep `like`.

Gated cross-port by a new `fEnumInt` case in `fixtures/conformance/filter-ops-matrix`
-- the `field.filter-ops` capability was already field-level in all five ports, so
the fixture pins `fEnum` (with `like`) against `fEnumInt` (without) with no runner
change. Plus a TS unit test covering the inherited-map shape.

Known and NOT addressed here: C# and Python validate no ops at all in their
projection `@filter` pass (TS does), so the load-time rejection lands in TS only
for that one authoring surface. That is a pre-existing cross-port gap in
projection-filter validation generally, not something this change introduces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…NTEGER literal

A projection row-scope `@filter` (#207) and an `origin.aggregate @filter` both
render as literal SQL text, so neither touches Drizzle -- the customType that
rescues the runtime query path does nothing for them. On an int-backed
`field.enum` (`@intValueMap`, design D5) they emitted the member SYMBOL against
an `integer` column:

    WHERE p.status = 'PUBLISHED'

Postgres rejects that at CREATE VIEW time (`invalid input syntax for type
integer`), which aborts the whole migration -- so this was a `meta migrate`
blocker, not a wrong-rows bug, and it applied to every operator rather than just
the `like` case the plan anticipated.

Both resolvers now map the member through the RESOLVING `@intValueMap`
(ADR-0039 -- post-#246 the map commonly lives on a shared abstract declaration
two hops up, and the projection's own field reaches the base entity's through
`extends`, so own-only at either hop would silently emit the symbol). `in`
encodes element-wise; `isNull` is skipped (its value is a boolean); `like`
throws rather than emitting `LIKE NaN` -- unreachable now that `opsForField`
removes it from the band, so the throw is a loud backstop, as is the
unmapped-member throw (the key set is loader-pinned equal to `@values`).

`resolveAggregateFilter` is a SEPARATE resolver from `resolveViewFilter` and
would not have been reached by fixing only the one the plan named -- the same
"assume a sibling code path exists" miss as the sqlite arm of column-mapper.

Same defect class as Task 7's `@default` (`DEFAULT 'DRAFT'` on an integer
column): wherever metadata puts an enum member into SQL text, the member has to
go through @intValueMap first.

Gated by 10 unit tests (incl. the DRAFT->0 falsy case, which a truthiness-guarded
encode would silently skip -- #235 is the precedent) AND a real-Postgres test
that APPLIES the view, converges a second migrate, and asserts the view returns
only the mapped rows. A unit assertion on the emitted string cannot show that the
DDL applies; only applying it can. Verified load-bearing by disabling the encode
and confirming both real-PG tests go red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dmealing and others added 5 commits August 16, 2026 11:14
…uncovered

Task 8's steps 1-3 turned out unnecessary (the Drizzle customType already
encodes filter comparison values), while its step 4 -- written as "confirm the
existing gating already excludes \`like\`; if so this is a test only" -- was the
whole task, and the optimistic reading was wrong.

Also records Task 8b, the projection/aggregate view-@filter blocker that probing
step 4 surfaced, and folds its durable lesson into the notes the other three port
plans will be rewritten from: a column-level codec seam does not reach anywhere a
port renders SQL text by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 9 asked two questions and flagged the second as possibly needing its own
design, with "reject @intValueMap on a discriminator with a named loader error"
as the documented fallback:

  1. does a per-subtype read schema tolerate an int-backed enum COLUMN?
  2. does an int-backed enum work AS the discriminator?

Both work, and the discriminator case is SUPPORTED — no product change needed.
The reason is structural rather than lucky: every TPH path goes through Drizzle
(`db.select()`, `eq(auths.type, "Bridge")`, `.values()`, and the routes tier's
`discriminatorCond` likewise), so the Task 5 customType encodes and decodes at
the COLUMN and the schemas only ever see member symbols. `z.literal("Bridge")`
and `parseAuth`'s `z.enum` head parse are therefore correct as emitted.

Reading the generated source says all that. #203/#229 is the precedent for TPH
being a separate code path everyone assumes is covered, and the 0.15.21 line is
what "the source looks right" is worth — so this commit is the run, not the read.
Seven tests over a hierarchy whose discriminator is int-backed 1/2 AND which
carries a second int-backed enum (0/7, so the zero member is live):

  - the base-table DDL applies and a second migrate converges;
  - the discriminator column is `integer` with an INTEGER `CHECK`, no 'Bridge';
  - a create through the generated per-subtype fn stores BOTH enums as integers
    (asserted with raw SQL, bypassing the codec — what is actually on disk);
  - the per-subtype read schema decodes a raw-SQL-inserted integer row;
  - the per-subtype filter compares the integer discriminator;
  - the polymorphic read dispatches on the DECODED discriminator;
  - find-by-id is scoped by the discriminator, not just the PK (asking for a
    Copay row as a Bridge must MISS — proving the AND'd predicate encoded to 1).

Two assertions failed on the first run and both were the test's fault, checked
rather than assumed: the DB check constraint is `auths_type_chk` (migrate-ts's
name; codegen's Drizzle `check()` uses `chk_auths_type`, a pre-existing naming
difference that is identical for a string-backed enum), and a TPH
`<Sub>InsertSchema` requires its `z.literal` discriminator — also pre-existing,
also identical string-backed, with the ROUTES tier being what omits and re-adds
it from the URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… change

The premise was false for a structural reason worth keeping: every TPH path goes
through Drizzle, so Task 5's column-level customType already covers the
per-subtype read schemas AND the discriminator. Step 3 is decided in the
affirmative -- an int-backed discriminator is SUPPORTED, and the documented
reject-with-a-loader-error fallback was not taken.

Folds the inverse of Task 8b's lesson into the notes the other three port plans
will be rewritten from: where a port's TPH surface goes through its ORM, a
column-level codec seam makes TPH work with zero TPH-specific code -- so prefer
that seam over a query-layer one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… any

`validation-conformance.test.ts` failed its whole suite with `Cannot find module
<tmp>/Ledger.ts` while the file was demonstrably on disk. Bun caches a
directory's listing at the FIRST import out of that directory, so a sibling
written afterwards is invisible to the resolver. The loop wrote-then-imported
per entity, so `Account` (first) resolved and `Ledger` (written after that
import) did not.

Split into write-all then import-all. 42 validation-conformance cases were
failing to run at all and now execute; the TS conformance gate in
`scripts/ci-local.sh --quick` goes from red to green.

Pre-existing and unrelated to the int-backed-enum work — confirmed by
reproducing it identically on a clean `origin/main` worktree with its own
`bun install`, and diagnosed by probe (write both files first, then import both:
green) rather than inferred. It is fixed here because it is the gate CLAUDE.md
prescribes running before opening a PR, and it was red on `main`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dmealing
dmealing force-pushed the feat/int-backed-enum-values branch from b0d23d3 to f1e35c8 Compare August 16, 2026 15:16
@dmealing dmealing changed the title feat(metadata): int-backed field.enum storage via @intValueMap Int-backed field.enum (@intValueMap) — TS persistence [REVIEW ONLY, do not merge] Aug 16, 2026
dmealing and others added 14 commits August 16, 2026 16:38
…ment step

The no-mistakes pipeline's automated "document" step added a "## Int-backed
enum storage" section to the enum design doc, but inserted it one bullet too
early -- splitting the pre-existing "Remaining follow-ups" list and leaving its
last bullet (an unrelated EF Core/Routes-ASP.NET scope note, predating this
work) dangling after the new section's closing line with no list context.

Moved the new section to come after the full original list. Content is
unchanged; only the ordering is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st in a rebase

buildForeignKeys resolved a target FK field's physical column by applying the
naming strategy to its raw (logical) name -- silently dropping the @column-
override resolution this exact function had (c86cd20, long before this
session): a target PK with an explicit @column override phantom-diffed every
FK into that table.

This is a genuine regression, not new work: expected-schema-fk-refcolumn-
override.test.ts already pinned the correct behavior and was green at this
branch's last fully-tested commit (658cacf) before the no-mistakes pipeline's
autonomous CI-repair rebase ran. That rebase silently reverted this one hunk
while replaying ~40 commits onto an advanced origin/main (0.22.1 -> 0.23.1) --
git raised no conflict marker for it, so nothing surfaced it except rerunning
the full test suite and diffing against the pre-rebase tree by hand. Confirmed
via a worktree at 658cacf (test passes there) and git history (the correct
logic traces to c86cd20, still an ancestor of HEAD, but the code at HEAD no
longer matched it).

Restored verbatim from 658cacf's tree. No other change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r the pipeline's rebase

Upstream's own registry grew independently during the 0.22.1 -> 0.23.1 window
this branch was rebased across, so the tracked snapshot's registeredSubTypeCount
(69) no longer matched the current registry (70), and attr.intMap dropped off
the exercised-list bookkeeping as a result -- not because any fixture stopped
exercising it (all 8 int-backed-enum conformance fixtures are still present and
unchanged; verified before regenerating). Regenerated per the test's own
documented fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…values

# Conflicts:
#	spec/decisions/ADR-0039-own-accessor-discipline.md
The metamodel layer shipped @intValueMap in all five ports; Python's runtime
never encoded it, so a field declaring a map still stored its member SYMBOL
into what migrate provisions as an INTEGER column.

Two halves, mirroring the TS reference:

  write  _coerce_write_value gains a field.enum branch — symbol -> declared int.
         Enum was a pure fallthrough to `return value` before.
  read   _decode_read_value is new; there was no read-side coercion in this
         module at all (select/find_by_id/find_many returned driver values
         verbatim, per ADR-0019). Wired into all THREE column->field mapping
         sites: create RETURNING, update RETURNING, and find_many.

The native and wire contract is unchanged in both directions — a caller passes
and receives the member symbol exactly as for a string-backed enum. Int-backing
stays invisible above the codec (design goal 2).

ADR-0039: the map is read RESOLVING (get_meta_attr), not own-only. It is
@values' numeric half — a logical property of the enum vocabulary that inherits
through extends — so a concrete field extending a shared abstract enum encodes
with the inherited map. Contrast @dbColumnType in the same function, which is
deliberately own-only. Pinned by a dedicated inheritance test.

Two deliberate non-behaviours, each pinned:
  - an unmapped symbol on write and an unmapped int on read pass through
    UNTOUCHED. Membership is the column's CHECK constraint to enforce; nulling
    or inventing a value here would hide real data drift.
  - DRAFT maps to 0, a falsy int, so the branch tests `value in int_map`
    rather than truthiness — the obvious `if not mapped` bug is pinned.

PROVEN NON-VACUOUS: neutering just the write branch turns 5 tests red while the
string-backed / None / unknown-symbol cases stay green (they assert unchanged
behaviour). Read tests fail at import without _decode_read_value.

Note the two plan-supplied test helpers did not exist: MetaRoot has no
find_object and MetaObject no field(name) — children()/fields() are the real
accessors.

19 new tests; full Python suite 1718 passed / 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EnumCodec read the column with getString and wrote with setString, unconditionally.
A field.enum declaring @intValueMap persists as an INTEGER, so writing it did not
merely store the wrong value -- it did not store at all: Derby rejects the symbol
with "ERROR 22018: Invalid character string format for type INTEGER". Proven by
running the new fixture against the real engine BEFORE the fix.

Both directions now branch on the map, with the caller's contract unchanged: the
SYMBOL goes in and comes out either way. Int-backing stays invisible above the
codec, so every OMDB call site (ObjectManagerDB, GenericSQLDriver,
SimpleMappingHandlerDB) is untouched -- they all route through
JdbcCodecs.forField(f).

ADR-0039: the map is read RESOLVING (hasMetaAttr/getMetaAttr, not the ,false
own-only overload). It is @values' numeric half -- a logical property of the enum
vocabulary that inherits through extends.

Two deliberate non-behaviours: an unmapped SYMBOL on write is bound unchanged so
the column rejects it, and an unmapped INT on read is surfaced as its digits
rather than null. Membership is the database's to enforce; both alternatives turn
a loud error into a silently wrong row.

GATED AGAINST A REAL DATABASE, and asserting the STORED form, not just symmetry.
The fixture gains a second field.enum `priority` (INTEGER column, DRAFT/PUBLISHED/
ARCHIVED -> 0/5/9) beside the existing string-backed `status`, so one test covers
both modes. Critically it then reads the raw column over plain JDBC and asserts it
holds 5: a round-trip alone cannot distinguish a working int codec from one that
wrote the symbol in both directions, because a symmetric bug is self-consistent.

The plan's test sketch assumed Mockito and JUnit 5; omdb has neither -- it uses
JUnit 4 and a real embedded Derby round-trip, which is the stronger gate anyway.

JdbcCodecRoundTripTest: 7 run, 0 failures, 0 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Exposed table generator emitted enumerationByName for every field.enum, so an
enum declaring @intValueMap got a VARCHAR column holding the member symbol -- while
migrate provisions INTEGER and every other port stores the int.

Both emission branches (the own-field loop and the TPH subtype-fold loop) now route
through one enumColumnSpec() helper: enumerationByName when string-backed,
customEnumeration("col", "INTEGER", read, write) when the map is present. The
Kotlin-side property type is the same generated enum class either way -- int-backing
is invisible in the entity's API.

DEVIATION FROM THE PLAN, deliberately. The plan called for a generated lookup-map
support file; the mapping is inlined as `when` expressions instead. A `when` over an
enum is exhaustive, so a member with no mapping becomes a COMPILE error in the
adopter's build rather than a runtime surprise, and it allocates nothing per row --
a mapOf(...) inside the lambda would rebuild the map on every read and every write.
The read side keeps an else that fails loudly: a stored int with no member is data
the model does not describe, and substituting a member would hide it.

ADR-0039: @intValueMap is read RESOLVING, so a field extending a shared abstract
enum inherits the members AND their mapping.

THE COMPILE GATE CAUGHT A REAL BUG. The generator hand-rolls its file body as a
string, and the first emission separated the one-line `when` branches with spaces --
`OrderPriority.DRAFT 5 -> ...`, which does not parse. Every text assertion passed;
only compiling the emitted tree failed it. Branches are separated with `; ` now.
This is why the new test compiles the output rather than grepping it.

The fixture carries BOTH modes on one entity so the test also pins that string-backed
emission is byte-unchanged (feature is additive).

codegen-kotlin: 315 run, 0 failures, 0 errors, 0 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ort gate

Two halves of one gap. The metamodel layer shipped @intValueMap in all five ports,
but only TypeScript ever persisted it -- and NO shared corpus covered persistence,
so four ports could ignore the attribute and every gate stayed green. This adds the
last port and the fixture that makes silence impossible.

C#. DbContextGenerator emitted HasConversion<string>() for every field.enum, so an
int-backed one stored the member symbol into what migrate provisions as INTEGER.
Both enum sites now route through one EnumConversionCall(): the generic
HasConversion<string>() when string-backed, or a model->provider / provider->model
lambda pair built from the declared map. The scalar, array-element
(PrimitiveCollection) and projection/view loops all use it -- the view needed it as
much as the table, since reading an INTEGER column as text fails materialization the
same way the ordinal default does. The generated C# `enum` declaration is byte-
identical either way; int-backing is invisible in the entity's API.

  - The mapping is a TERNARY CHAIN, not a switch expression: EF converts these
    lambdas to EXPRESSION TREES and a switch expression is not legal in one (CS8155).
  - KNOWN PORT ASYMMETRY, deliberate and documented at the call site: the
    provider->model chain ends on the last member instead of rejecting an int with no
    member, where Python/Java/Kotlin surface the unmapped value. C# cannot match them
    -- an expression tree may not contain a throw-expression (CS8188) -- and the
    column's CHECK constrains the value anyway.
  - Ints are read THROUGH the map keyed by member, so @values stays the SSOT and a
    member with no mapping throws at codegen rather than vanishing from the converter.

CONFORMANCE. AllTypes -- the write-roundtrip kitchen-sink every port runs -- gains
`intEnumVal`, so all five ports now write an int and read back the symbol against
real Postgres. It is NULLABLE deliberately: the sibling update-delete scenario seeds
all_types by raw SQL and a NOT NULL column would break that seed. DRAFT maps to 0 so
the falsy-zero case is covered on the shared corpus, not just in per-port unit tests.
The migration fixture additionally pins the lowering -- "intEnumVal" INTEGER with
CHECK IN (0, 5, 9), integers unquoted -- because emitting the member symbols there
would be un-appliable DDL against an integer column.

The committed C# integration fixtures are regenerated, and the diff is exactly four
lines: the enum declaration (unchanged in shape), the nullable property, and the
conversion. No other enum's HasConversion<string>() moved, which is the evidence that
this is additive for string-backed enums.

C#: 1579 passed / 0 failed across all four test projects (Render 291, Conformance
897, Cli 46, Codegen 345 + 1 intentionally-skipped regen harness). TS migration
conformance 6/0 against real Postgres, proving the hand-edited canonical schema
matches what migrate-ts generates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 0.23.1 release commit bumped pyproject.toml but left uv.lock pinning 0.23.0;
running the suite regenerated it. Release hygiene, unrelated to int-backed enums.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Behaviour-preserving; every suite re-verified after.

ONE REAL DEFECT, in Java. The EnumCodec change added its new Javadoc block WITHOUT
removing the existing one, leaving two stacked /** */ comments. Java binds only the
closest to the class, so the original block -- why enum is registered explicitly
rather than riding the generic ObjectCodec fallback, and that the DB CHECK enforces
membership -- became an orphaned comment invisible to Javadoc. Merged into one block
preserving every rationale point from both.

PYTHON, aligned with its siblings. It was the only port inlining the
get_meta_attr + isinstance(dict) read twice, duplicating the ADR-0039 rationale at
both sites; Java, Kotlin and C# each already factor this into a helper. Extracted
_int_value_map() to match. Also hoisted the loop-invariant int/bool check out of the
decode loop: a non-int value used to iterate the whole map, match nothing, and fall
through to `return value` -- now it returns immediately. Equivalent by case analysis
(a bool or non-int could never satisfy the old per-entry condition either).

C#, pure reordering. The new IntValueMapOf/EnumConversionCall pair had been inserted
BETWEEN the `#214 [0]` comment block and EmitFieldTypeConfig, the method that comment
describes -- so ~60 lines of a reader's attention would misattribute it. Moved above
that comment. Verified pure: the diff's added and removed line sets are identical.

Deliberately NOT changed: the `e.getValue() != null` guard in Java's int-map loop is
provably dead today (values are built via .intValue()), but it guards the helper's
construction changing underneath it and costs nothing. Kotlin needed no change.

Verified after: Python 19 passed; Java JdbcCodecRoundTripTest 7 run / 0 failures / 0
skipped; C# 345 passed / 0 failed / 1 skipped (the regen harness).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… encoding, a leaked home path

Three confirmed defects from an adversarial cross-port review. Each was verified
against the code before fixing; each fix is proven non-vacuous.

1. CRITICAL (C#) -- non-compiling EF config for the ONLY legal way to int-back a
   shared enum. EnumConversionCall named the enum type {owner}.{EnumTypeName}, but
   EntityGenerator deliberately does NOT nest a shared/@provided enum (it references
   it: "shared/provided -> referenced, not nested"), so the emitted
   HasConversion(v => v == Ticket.Priority.LOW ? 1 : ...) is CS0426. Not an edge
   case: ERR_ENUM_EXTENDS_VALUES_CONFLICT makes declaring @intValueMap on the
   CONSUMING field a load error, so hanging it on the shared declaration is the only
   legal authoring shape. It stayed latent because HasConversion<string>() names no
   type at all. Now routed through Fr019SharedEnum.SharedEnumForField /
   SharedEnumTypeReference, the same resolution EntityGenerator's own
   EnumPropertyTypeName uses.

   GATED by extending the EF-Core-8 Roslyn compile fixture with both shapes: a
   shared root-level abstract int-backed enum consumed via extends, and an inline
   int-backed enum. PROVEN NON-VACUOUS -- reverting just the type-name resolution
   turns that compile test red.

2. HIGH (Python) -- every query on an int-backed enum failed. _compile_filter and
   _op_clause bound values RAW; the write codec was applied only to INSERT/UPDATE
   params and the PK. The other four ports all encode on this path (TS via the
   Drizzle customType, Java via GenericSQLDriver.setStatementValue -> EnumCodec,
   Kotlin via Exposed toDb, C# via the EF converter), so Python alone bound the
   member SYMBOL against an INTEGER column -> pg 22P02. That silently contradicted
   the filter-band rationale every port carries: eq/ne/in survive for an int-backed
   enum BECAUSE the symbol encodes to its integer before reaching SQL. Bound values
   now go through _coerce_write_value, per-element for `in`, skipped for isNull.
   5 new tests pin eq / shortcut-equality / in / string-backed-unchanged / isNull.

3. PUBLIC-REPO HYGIENE -- a developer's absolute home path (/Users/<name>/...) was
   committed in a plan doc, twice. Replaced with <repo-root>. The pre-commit guard
   matched /home/<user>/ but NOT the macOS /Users/ shape, which is exactly why it
   passed; the pattern now covers both.

Also extends the shared corpus: update-delete-all-types now sets intEnumVal, so the
UPDATE write path and its RETURNING decode are covered cross-port. They had NO
coverage -- the roundtrip scenario only exercises INSERT.

Verified: C# 345 passed / 0 failed; Python 24 targeted + 27 real-Postgres
integration scenarios green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverses design D7 ("array-of-enum composes unchanged"), which assumed the
element codec would fall out of the scalar one. It does not. Int-backing is a
persistence-layer CODEC and every port's codec seam is scalar by construction:
Python's ObjectManager tests `value in int_map`, false for a list, so it bound
the symbol LIST into an integer[]; OMDB's EnumCodec and Kotlin's
customEnumeration bind one value; and TypeScript's sqlite branch serializes an
array as JSON text before the enum case is ever reached, storing symbols. Only
TS/Postgres and C# composed.

Two ports composing while four silently get it wrong is not a feature — it is
the field.byte/short/class mistake, vocabulary that reads as supported and is
not. Rejecting at LOAD delivers the guarantee that was actually missing:
identical behaviour in every port. An array-of-enum stays string-backed.

New cross-port error ERR_ENUM_INT_VALUE_MAP_ARRAY, in all four loaders (Kotlin
inherits the JVM one). Both halves are read RESOLVING, unlike the @intValueMap
content rules: the illegal thing is the EFFECTIVE combination. Post-#246 the map
must live on the shared abstract declaration while isArray is declared by the
consuming field, so an own-only read would see the two halves on different nodes
and never fire — which is why the inherited case gets its own fixture rather
than being assumed to follow.

The positive enum-int-backed-array fixture becomes the negative
error-enum-intvaluemap-array (its input is unchanged — the same metadata, now
rejected), joined by error-enum-intvaluemap-array-inherited for the canonical
shared-enum authoring shape. Corpus goes 271 -> 286 in CONFORMANCE.md/CLAUDE.md
(the count had drifted independently of this change).

Verified non-vacuous per port rather than trusted: both fixtures appear by name
in the Java surefire XML, match 2 collected pytest cases, and run under the TS
and C# directory-scan runners with no expected-failures ledger entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…member

An int-backed field.enum column holding a value that maps to no member is data
the model says is impossible — a hand-written INSERT, or a member removed
without a migration. Java surfaced the raw int as the "member" ("7"), Python
returned it verbatim, and C# fell through its ternary chain to the LAST member,
handing the caller ARCHIVED for a row that is not archived. TypeScript and
Kotlin already threw, so the same corrupt row behaved four different ways across
five ports.

Neither alternative to throwing is honest. Surfacing the raw value hands the
caller a member that is not one, and it is not even representable in C#, Kotlin
or TypeScript, which type the property as a CLOSED enum. Returning null hides
the corruption behind a nullable column. So all five throw now, naming the
stored value and @intValueMap.

C# reaches it through a generated static helper called from the provider->model
lambda: CS8188 bans a throw-EXPRESSION inside an expression tree, but a method
CALL is legal there and the throw happens in the helper's ordinary body. The
helper is emitted only when the model carries an int-backed enum, so a model
without one is byte-identical. Only the READ side needs this — the model->
provider chain is exhaustive over the enum by construction, since @intValueMap's
keys are loader-validated to match @values exactly. The WRITE side is
deliberately left to the database: an unmapped symbol binds unchanged, so the
column type and its CHECK reject it.

Gated by executing the converter rather than asserting over generated text: the
C# test runs both directions off the finalized EF model (and pins that the
DECLARED map reaches the column, not EF's ordinal default), and the Java test
writes a legal member through OMDB, corrupts it with raw SQL, and reads back —
the same shape as the drift this guards against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…feature

The design doc records the two rulings (scalar-only int-backing, throw on an
unmapped stored int) and gains a follow-up the D8 "migration safety" heading was
quietly implying it covered: D8 handles ADDING or REMOVING @intValueMap, but
RE-mapping a member inside a map that stays present is undetected — and one
shape of it is silent. Moving a member to an int not already in the set changes
the CHECK, so the migration applies a constraint the existing rows violate and
the database refuses it, loudly. But SWAPPING two members' ints leaves the value
set identical: the CHECK is byte-identical, the diff is EMPTY, no migration is
emitted, and every stored row changes meaning. Nothing in the pipeline can see
it — the column holds bare integers, and neither introspection nor the committed
snapshot records which member an integer stood for. Named rather than left
implied; closing it needs the mapping carried in gen-state or the snapshot,
which is a design decision, not a patch.

field-types.md gains the two rules an adopter can actually hit, since both are
new ways their metadata or their database can now fail.

The five implementation plans are banded SUPERSEDED. They are kept for
provenance but are actively misleading now: every array-of-enum fixture, column
shape and element-wise codec in them describes vocabulary that cannot load, and
some sketched tests call APIs that do not exist (MetaRoot.find_object,
MetaObject.field(name)) or assume test libraries a module does not depend on.

CHANGELOG gets the [Unreleased] entry for the whole feature — it had none, and
this is registered vocabulary, so the line carrying it is a MINOR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dmealing dmealing changed the title Int-backed field.enum (@intValueMap) — TS persistence [REVIEW ONLY, do not merge] Int-backed field.enum (@intValueMap) — metamodel + persistence, all five ports Aug 17, 2026
dmealing and others added 5 commits August 16, 2026 20:45
…about

The previous commit claimed a member re-mapping is invisible to `meta migrate`
and that a swap emits no migration at all. Probing the real diff says otherwise,
and the corrected picture is both narrower and more useful:

- The CHECK list renders in @values order, so ANY remap changes its text. That
  emits drop-check + add-check, and drop-check is BLOCKED by default
  (allow.dropCheck) — migrate refuses. The refusal is an ACCIDENT: it fires
  because dropping a CHECK is destructive, not because anything recognises that
  the meaning of stored data just changed.
- Once allowed, the migration only refreshes the constraint and never touches
  the data. Moving a member to an unused int then applies a CHECK the existing
  rows violate and the database refuses it, loudly. SWAPPING two members leaves
  the admitted set identical, so it applies cleanly and every row has quietly
  changed meaning.
- Only one shape is invisible to the diff: a remap plus a compensating @values
  reorder renders a byte-identical CHECK, so there is no diff to block.

All three are now pinned in expected-schema-enum-intvaluemap.test.ts rather than
asserted in prose — including the known gap, marked KNOWN so a future change that
closes it updates the test instead of reverting the behaviour. Pinning the
accident matters most: nothing else stops `allow.dropCheck` from being relaxed
into a general auto-allow and taking the only protection with it.

CHANGELOG gains the adopter-facing version: do not remap on a populated table;
treat it as the same two-step backfill a backing-mode change needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mVal

`persistence-conformance` has no skip valve — every port runs every fixture — so
adding `intEnumVal` to the shared corpus obliged all five ports to carry it. Four
did: Java and Python drive their columns from metadata, and C# regenerates its
AppDbContext. Kotlin's oracle is a HAND-WRITTEN Exposed table, and nobody added
the column, so QueryScenarioConformanceTest[20] and [27] died with

    IllegalStateException: No column 'intEnumVal' on table 'all_types'

The lane that catches this runs on release tags and manual dispatch only, so the
branch would have merged with a standing red nothing on a PR would have shown.

Fixed by supplying the missing piece, not by narrowing the corpus. The new
IntBackedEnumColumnType is the harness analogue of the customEnumeration the
Kotlin generator emits: a Column<String> carrying the member SYMBOL over an
INTEGER column, translating through @intValueMap in both directions, and THROWING
on a stored int that maps to no member — the same ruling every port now
implements. It is String-typed rather than enum-typed for the reason already
documented for the jsonb columns beside it: this generic runner moves plain YAML
scalars and has no generated enum class to bind.

Both coercion paths need an explicit guard ahead of the sqlType dispatch, whose
`int` branch would otherwise try "PUBLISHED".toInt() — the column is physically
INTEGER while its authoring value is a symbol, which is the one shape that
dispatch cannot infer.

QueryScenarioConformanceTest: 27/27.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule read "PATCH (MINOR if it adds registry vocabulary — cross-port
conformance surface)" in RELEASING.md, and ADR-0035's cadence bullet listed "a
newly-supported vocab member" among the MINOR triggers. Read literally that makes
ANY registry addition a MINOR — which is the exact churn ADR-0035 was written to
prevent. 0.22.0 and 0.23.0 were both cut MINOR for additions a project declaring
no requirement.* nodes could not observe at all, each changelog saying so in its
own opening paragraph.

The error is treating expected-registry.json as consumer surface. It is an
INTERNAL gate: five ports byte-matching one manifest is how we stop the ports
drifting from each other, and it says nothing about whether an adopter's project
changes. "New public surface, not code size" was the right instinct; "vocab
member" was the wrong unit. Vocabulary sorts by what it can do to a consumer:

  - a new ATTRIBUTE is a PATCH — reachable only by authoring it, every existing
    document loads unchanged and emits byte-identical output;
  - a new top-level TYPE is a MINOR — a new modeling concept with its own
    children, validation and usually tooling surface (requirement.* brought a
    verify pass and summary output);
  - a new SUBTYPE goes either way, and the test is whether it is INERT: PATCH
    when nothing but authoring it can reach it, MINOR when it narrows something
    previously permitted (closing a wildcard, promoting a reserved-not-registered
    member), changes what existing metadata means or emits, or headlines a
    release you want behind a range bump.

Also stops the caret rule being inverted. "^0.22.x resolves <0.23.0, so a
consumer adopts a MINOR deliberately" is a reason to CHOOSE minor when that gate
is wanted, not a reason additive vocabulary must be minor. Four registries move
per cut here, so a minor spent on an unobservable change is a gate you no longer
have when something real needs it.

Recorded as ADR-0035 Amendment 1 rather than a silent rewrite, with the operative
table + worked rows in RELEASING.md and a pointer from the releasing skill (which
is where the level actually gets chosen). The post-1.0 compat promise is
untouched: a BREAKING vocabulary change still requires a MAJOR, attribute,
subtype or type alike.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`create()` bound the member SYMBOL straight into the INTEGER column and Postgres
rejected the statement outright:

    invalid input syntax for type integer: "PUBLISHED"
      at kysely-driver.ts:118 -> object-manager.ts:394 (create)

The TS codec shipped as a Drizzle `customType`, which covers the CODEGEN path
only. `runtime-ts`'s metadata-driven ObjectManager is a different seam — the one
the persistence-conformance corpus drives — and nothing taught it about
@intValueMap, so int-backed enums were unusable through the runtime while the
generated code worked. Same class as the Kotlin oracle gap in the previous
commit, found the same way: by the corpus, once it had a fixture to run.

Both directions now live in type-coercer.ts beside the jsonb/boolean coercions:

  - WRITE encodes symbol -> declared int, dialect-independent (the column is
    INTEGER on every dialect; SQLite has one integer storage class), so unlike
    the boolean mapping it is NOT gated on `dialect === "sqlite"`. An unmapped
    symbol passes through for the column's CHECK to reject — matching Python's
    write codec, and every port leaves the write side to the database.
  - READ decodes int -> symbol so the runtime's return value is the symbol in
    both backing modes (ADR-0019: int-backing is invisible above this codec). A
    stored int with no member THROWS, like every other port and like the
    generated customType's fromDriver.

Filter values needed the same treatment at `compileEntry` — one seam every
operator passes through with the field in hand. Without it a WHERE on an
int-backed enum 500s the same way a create did. `like` is unreachable for such a
field (the loader's field-level band drops it) and `isNull` carries no value.

`intValueMapOf` reads RESOLVING per ADR-0039 and is duplicated from codegen-ts
rather than shared: runtime-ts must not depend on a codegen package, and only one
of the two ships to a server at runtime.

Gated by 20 unit tests covering both directions, both dialects, the falsy 0
member, a driver-stringified int, the unmapped-throw, and every filter operator.
One of them initially passed for the wrong reason — `makeOrder(undefined)` fires
the default parameter and hands back the int-backed shape — so the helper now
takes `null` for "no map" and says why.

integration-tests (ts) on Testcontainers PG: 219/0, up from 216/3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…odec

The entry claimed "Drizzle customType codecs (TS)" as if that were the whole TS
story. It is the codegen half; runtime-ts's metadata-driven ObjectManager is a
second seam with its own codec, and it was missing entirely — generated code
worked while om.create() bound the symbol into an integer column. Naming both is
the accurate claim, and the near-miss is worth stating: a port with two seams can
ship one of them and look finished.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dmealing
dmealing marked this pull request as ready for review August 17, 2026 11:03
@dmealing
dmealing merged commit ee6e9ff into main Aug 17, 2026
1 check passed
@dmealing
dmealing deleted the feat/int-backed-enum-values branch August 17, 2026 11:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant