Repository navigation
String enums can be declared open #411
Description
Problem
Adding values to a string enum is additive within a version today (#372). That makes the derived union a promise the data doesn't keep: an older app's typed read can meet a value its union excludes. #409 reports such records as unknown-enum misfits rather than failing a page, but there's no way for a schema author to say which enums are expected to grow, so readers can't tell an enum whose list is the whole lifecycle from one that will pick up new values.
Decision
String enums can declare open: true, reusing the open vocabulary arrays and objects already have.
status: { kind: 'string', enum: ['want', 'reading', 'finished', 'abandoned'], open: true, required: true },
// ContentOf: status: 'want' | 'reading' | 'finished' | 'abandoned' | (string & {})-
Meaning: the value list may grow within a version. Writes are still validated against the stack's current list;
opendescribes what readers must expect, not what writers may store. -
Typing:
ContentOfwidens an open enum with(string & {}), so editors still suggest the listed values and readers must handle the rest. The patch type stays closed, so a write can only use values the handle knows. -
Narrowing: an open enum never produces an
unknown-enummisfit. -
A closed enum's list is part of what the version means. That's what lets a closed-enum reader stay exhaustive. The drift rules change accordingly, replacing "adding values or removing an
enumis always additive":Change Closed enum Open enum Add values version bump in place Remove values version bump version bump Remove the enumentirelyversion bump in place Toggle open(closed → open)version bump n/a Toggle open(open → closed)n/a in place Opening a closed enum breaks the exhaustiveness promise for existing readers, so it's a bump. Closing an open one breaks no reader.
-
Hashing:
openis part of the canonical schema, so toggling it is a change. Value order still isn't. -
With these rules, a well-behaved stack can't produce an
unknown-enummisfit for a closed enum: a new value means a new version, which arrives as anewer-versionmisfit instead.unknown-enumstays a reason because a typed read can't assume every writer validates (a foreign server, say).
Rejected
- Widening every enum. Exhaustiveness goes for every enum, including ones that will never grow, and typo'd comparisons compile.
- Making every enum widening a version bump with no opt-out. Every new status would cost a migration, even for lists that are meant to grow.
Actions
-
openon stringenuminFieldDefandReadonlyFieldDef, schema-shape validation, canonical hashing, and drift detection with the table above. -
ContentOfwidens an open enum with(string & {});PatchOfand the collection's patch type don't. - The collection's narrowing (Typed access through collections, with records that don't fit reported rather than thrown #409) skips open enums when looking for unknown values.
- Check
@haverstack/wire-types,@haverstack/adapter-apiand@haverstack/conformance-fixturesfor anywhere a schema's shape is pinned. As of this writingenumappears only in core, soopenlikely does too. - Tests: every row of the drift table; open-enum values never misfit; the derived type widens for open and stays exact for closed; a patch through an open enum accepts only listed values.
- Spec,
data-model.md:§ Types(openon enums),§ Additive evolution within a versionand§ Schema drift detection(the table). - Changeset:
minorfor@haverstack/core, plus any of the three packages above that change.
Order
Blocked by #409, which classifies misfits. Can run in parallel with #410; once both have landed, check #410's filter value types against an open enum.
Activity
cuibonobo commentedon Oct 13, 2026
Closing as superseded by #446.
This issue was written on the assumption that closed enums would read as an exact union, with records holding other values reported as
unknown-enummisfits. #446 did it the other way (commit 2216fa5): a typed read treats every enum as its listed values plusUnlistedValue(string & {}), andunknown-enumwas never added as a misfit reason. Writes still accept only the listed values.So readers already have to handle values the handle doesn't list, which is the problem this issue set out to solve. Adding
openwould only bring back the closed-enum exhaustiveness thatdata-model.md § Type handlesnow rules out ("a closed union would promise what no handle can check"). The drift rules stay as they are: widening an enum is additive.There's also nothing left to check against #410: its filter values are typed the way writes take them, and the spec covers filtering for an unlisted value with a cast.
Generated by Claude Code