Int-backed field.enum (@intValueMap) — metamodel + persistence, all five ports - #291
Merged
Conversation
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.
…otlin persistence layer)
…ersistence layer)
…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.
…ues for DB persistence
…s for DB persistence
…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>
…s for DB persistence
…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>
…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
force-pushed
the
feat/int-backed-enum-values
branch
from
August 16, 2026 15:16
b0d23d3 to
f1e35c8
Compare
…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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Int-backed
field.enum(@intValueMap) — the whole train: metamodel + persistence inall five ports + the cross-port round-trip gate.
A
field.enummay declare@intValueMap(member symbol → integer) so it persists as aninteger column with an integer
CHECKinstead ofvarcharwith a string one. Everylanguage-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.
What is here
Metamodel (all five ports):
attr.intMap+@intValueMap, registry + conformancefixtures, the #246 int-backed twin rule (an own
@intValueMapagainst a shared enum isrejected for the same reason an own
@valuesis), and@providedcorrected to an own-onlyread (ADR-0039 amended to charter it as the second deliberately-own-only attr).
Persistence, all five ports: Drizzle
customType(TS), EF CoreHasConversion(C#),OMDB
JdbcFieldCodec(Java), ExposedcustomEnumeration(Kotlin),ObjectManagercoercion(Python) — plus
migrate-ts's integer column, integerCHECKand@defaultlowering.The TS codec is a Drizzle
customTyperather 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
The filter-operator band is field-level.
likeis meaningless against an integercolumn and is the one operator in the string/enum band that cannot be rescued by
encoding.
opsForSubTypeis subtype-keyed and structurally cannot express that — it onlyever sees
"enum"— so the generated allowlist offeredlikeon an int-backed fieldbyte-identically to a string-backed one. Fixed as one loader rule per port
(
opsForField) rather than five codegen filters, so an authoredattr.filterfails atLOAD. C#'s codegen carried its own duplicate band table; it was deleted rather than
extended.
A
meta migrateblocker. A projection row-scope@filter(Projection / entity read-view: semantic row-scope @filter (view-level WHERE) reusing the shipped attr.filter AST #207) and anorigin.aggregate @filterrender as literal SQL and never touch Drizzle, so they emittedWHERE p.status = 'PUBLISHED'against an integer column — rejected by Postgres at CREATEVIEW time, aborting the migration. Affected every operator, not just
like, and neededfixing in two separate resolvers.
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'sEnumCodecandKotlin's
customEnumerationbind one value, and TS's sqlite branch serialized the arrayas 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/classmistake. NowERR_ENUM_INT_VALUE_MAP_ARRAYat 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
isArrayis declared by the consuming field — anown-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.
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 ternarychain to the LAST member — handing the caller
ARCHIVEDfor 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 csharpdocker 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
emitandintrospecthad 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
customTypedecision — it is the one that commits the other four ports.@intValueMapread 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 sharedroot-level abstract declaration and consuming fields inherit it, so an own-only read sees
undefinedon the canonical authoring shape and silently emits a string codec into aninteger column — data corruption with no compile error.
opsForFieldsplit:opsForSubTypeis deliberately KEPT for the one caller with nofield in hand (the expression grammar's declared operand type).
Known, and deliberately not fixed here
ADDING or REMOVING
@intValueMap; it does not cover changing a value inside a map thatstays present. Moving a member to an int not already in the set changes the
CHECK, so themigration applies a constraint the existing rows violate and the database refuses it —
loud. But swapping two members' ints leaves the value set identical: the
CHECKisbyte-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.
@filterpass, while TS does, sothe load-time
likerejection lands in TS only for that surface. Pre-existing hole inprojection-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 oftruth). Adopter view:
docs/features/field-types.md.CHANGELOG.mdgains 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