Skip to content

0.23.2 — int-backed field.enum, and a versioning rule corrected

Choose a tag to compare

@dmealing dmealing released this 17 Aug 11:42
· 989 commits to main since this release

A coordinated PATCH across all four registries.

It is a PATCH because the versioning rule changed in this cut, and that is the headline.
The old policy said any registry addition forces a MINOR, which had spent 0.22.0 and
0.23.0 on changes a project could not observe at all. expected-registry.json 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. Vocabulary now
sorts by consumer impact: attribute ⇒ PATCH, top-level type ⇒ MINOR, subtype ⇒ PATCH when
inert.
This line adds one attribute and one inert attr subtype, so it lands as a patch.

The feature is int-backed field.enum storage, and its own lesson is about seams. The
@intValueMap codec had to be written five times, once per port, and the corpus caught two
ports that looked finished and were not: Kotlin's cross-port oracle is a hand-written Exposed
table nobody added the column to, and TypeScript has two persistence seams — generated
Drizzle code and the metadata-driven ObjectManager — of which only the first had a codec, so
generated code worked while om.create() bound the member symbol into an integer column and
Postgres rejected the statement. Both were invisible until a shared fixture existed to run.
Also in the cut: a design decision reversed after the ports disagreed with it (@isArray +
@intValueMap is now a load error, because four of five ports got it silently wrong), a
uniform throw on a stored integer that maps to no member (previously four different behaviours
across five ports), and three unrelated fixes that were riding on the branch.

Added — int-backed field.enum storage via @intValueMap (all five ports)

This is a PATCH, not a MINOR — and the reasoning is itself a change, so it is worth
stating. The old policy read "any registry addition ⇒ MINOR", which spent 0.22.0 and
0.23.0 on changes a project could not observe at all. expected-registry.json 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. Vocabulary
now sorts by what it can do to a consumer — a new attribute is a PATCH, a new top-level
type is a MINOR, and a new subtype is a PATCH when nothing but authoring it can reach
it. This line adds one attribute (@intValueMap) and one inert attr subtype (attr.intMap),
so: PATCH. See docs/RELEASING.md → "The vocabulary rule" and ADR-0035 Amendment 1.

Purely additive on its own terms too: a field.enum that declares no @intValueMap is
byte-identical to before, string-backed as always.

@intValueMap is an optional {memberSymbol: int} map on field.enum that declares each
member's stored integer. The column becomes integer with an integer CHECK instead of
varchar with a string one — while the wire format, the generated enum type, and every
runtime return value stay the member symbol, unchanged. The provenance is an
integer-coded column an adopter already has: the map is how you say "1 means LOW here",
rather than accepting whatever ordinal a language happens to assign. This is why it is a map
and not a second array parallel to @values — a positional array would silently re-map every
member the day someone reorders @values.

The loader enforces the map's content identically everywhere: keys must equal @values
exactly (no missing, no extra), every value must be a 32-bit integer, and no two members may
share one. Both halves are read RESOLVING, so a field that extends a shared abstract enum
inherits the members and their mapping — and an own @intValueMap declared against a
shared enum is rejected for the same reason an own @values is (#246's twin: one shared enum
type has one mapping).

Persistence ships in every port: EF Core HasConversion (C#), OMDB's JdbcFieldCodec
(Java), Exposed customEnumeration (Kotlin), ObjectManager coercion (Python), and — in
TypeScript — both seams, since TS has two: a Drizzle customType for generated code and
ObjectManager read/write/filter coercion for the metadata-driven runtime. The second was
missing until the corpus caught it: generated code worked while om.create() bound the member
symbol straight into the integer column and Postgres rejected the statement. All gated
cross-port by the AllTypes round-trip corpus against real Postgres.

Two decisions are worth naming because each closes a way the feature could have shipped
half-true:

  • Int-backing is scalar-only. @intValueMap together with isArray: true is a load
    error — ERR_ENUM_INT_VALUE_MAP_ARRAY, in every port. The original design said an
    array-of-enum composed unchanged; 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[], Java and Kotlin emitted a scalar codec, and TypeScript's sqlite branch
    serialized the array as JSON text before the enum case was ever 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 the field.byte/short/class mistake, vocabulary that
    reads as supported and is not. Rejecting it at LOAD delivers the guarantee that was
    actually missing: identical behaviour in every port. An array-of-enum stays string-backed.
  • A stored integer that maps to no member THROWS on read, in every port. The row holds
    data the model says is impossible — a hand-written INSERT, or a member removed without a
    migration — and neither alternative is honest: surfacing the raw integer hands the caller a
    "member" that is not one, and 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. C# reaches this 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. The WRITE side is deliberately left to the database: an unmapped symbol binds
    unchanged, so the column type and its CHECK reject it.

Adopter-visible beyond the new attribute: the filter-operator band is now decided
per field, not per subtype, so an int-backed field.enum no longer offers like — the
column holds integers, and LIKE against one is a type error, not a query. A projection's
@filter over an int-backed enum lowers to the integer literal rather than the symbol.

Migration safety is unchanged and deliberate: adding or removing @intValueMap on a field
that already has a column is a cross-kind change-column-type, which meta migrate already
blocks by default and requires an explicit allow.typeChange to pass. There is no
auto-recast — the tool will not rewrite your data behind a metadata edit.

Do not RE-map a member's integer on a populated table. Nothing understands that change:
the column holds bare integers, and neither introspection nor the committed snapshot records
which member an integer stood for. A remap changes the rendered CHECK list, so it trips the
blocked drop-check and meta migrate refuses — but that refusal is incidental (dropping a
CHECK is destructive), and once allowed the migration only refreshes the constraint and
never touches your rows. Swap two members' integers and the new CHECK admits the same set,
applies cleanly, and every stored row has quietly changed meaning. Reorder @values to
compensate and the diff is empty outright. Treat a remap as the same two-step backfill a
backing-mode change needs.

Design: docs/superpowers/specs/2026-07-23-int-backed-enum-values-design.md. Adopter view:
docs/features/field-types.md.

Changed — registry vocabulary no longer forces a MINOR (policy)

docs/RELEASING.md's versioning table said "PATCH (MINOR if it adds registry vocabulary —
cross-port conformance surface)", and ADR-0035's cadence bullet listed "a newly-supported
vocab member" among the MINOR triggers. Read literally, that made any registry addition a
MINOR — 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. Four registries move per cut here, so a
wasted minor is not free — and a minor spent on an unobservable change is a gate you no longer
have when something real needs it. Corrected to sort by consumer impact: attribute ⇒ PATCH,
top-level type ⇒ MINOR, subtype ⇒ PATCH when inert
(nothing but authoring it can reach it;
MINOR when it narrows something previously permitted, changes existing metadata's meaning, or
headlines a release on purpose). Recorded as ADR-0035 Amendment 1; the post-1.0 compat promise
is untouched — a breaking vocabulary change still requires a MAJOR.

Fixed — an FK into a table whose key carries @column phantom-diffed forever (npm)

buildForeignKeys resolved a target FK field's PHYSICAL column by applying the naming
strategy to its raw logical name, so a target primary key with an explicit @column override
(id"Id") made every foreign key into that table diff on every run — expected the
naming-strategy name, actual the override, nothing an adopter could do to converge. It now
resolves through the target entity's own field, which is how fkCols already handled the
source side; the two halves simply disagreed.

Fixed — views in a table-less schema were excluded from the diff entirely (npm)

Generated-output change — the first meta migrate after upgrading may emit view changes
that were always due.
declaredSchemas was built from expected.tables only, so a model
declaring views in a schema with no table of its own (an API/read-model schema beside an
all-public entity model) never brought that schema into scope — and a schema out of scope is
excluded from both sides of the diff. Its views were never compared, so a genuine missing or
extra view, or real drift inside an opaque @sql body, went undetected rather than reported.
View schemas now join the scope set. Same shape as 0.21.6's ON DELETE fix: a PATCH that
surfaces drift which was already there.

Fixed — a chained abstract field.enum emitted a broken Kotlin type (Maven)

KotlinTypeMapper.enumTypeName named a chained abstract enum after the TOP-MOST root of the
extends chain (via resolveSuperRoot) while its own FR-019 arm resolved the shared
declaration from the IMMEDIATE super — the rule TS, C#, Java and Python all use. Not a rival
model, a split-brain: the two halves disagreed about which declaration is "the type", and on a
chained declaration (a root abstract Money extends a root abstract @provided Currency)
that produced a flatly broken emit. Naming now uses the immediate super per ADR-0026 §2 (a
materialized type is named for its own declaration), so a chain yields one type per
declaration, each carrying the members it inherits. Non-chained output is byte-identical —
with no further super, the root walk already returned the immediate super. resolveSuperRoot
had exactly one caller and is deleted. The chained alias stays LEGAL rather than being
rejected: it cannot mutate the vocabulary it inherits (a chained declaration carrying its own
@values — or its own @intValueMap — already errors ERR_ENUM_EXTENDS_VALUES_CONFLICT),
and banning it would carve an enum-only hole in ADR-0029's general extends grammar to delete
a provably harmless construct. Newly gated by enum-abstract-chained-extends (positive) and
error-enum-chained-extends-values-conflict (negative), plus the chained declaration restored
to the shared-provided-enum codegen corpus all five ports load — the decl-level #246 check
had been code-only in every port with no fixture behind it.