Skip to content

String enums can be declared open #411

Closed as not planned
Closed as not planned

Description

@cuibonobo

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; open describes what readers must expect, not what writers may store.

  • Typing: ContentOf widens 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-enum misfit.

  • 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 enum is always additive":

    Change Closed enum Open enum
    Add values version bump in place
    Remove values version bump version bump
    Remove the enum entirely version 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: open is 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-enum misfit for a closed enum: a new value means a new version, which arrives as a newer-version misfit instead. unknown-enum stays 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

  • open on string enum in FieldDef and ReadonlyFieldDef, schema-shape validation, canonical hashing, and drift detection with the table above.
  • ContentOf widens an open enum with (string & {}); PatchOf and 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-api and @haverstack/conformance-fixtures for anywhere a schema's shape is pinned. As of this writing enum appears only in core, so open likely 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 (open on enums), § Additive evolution within a version and § Schema drift detection (the table).
  • Changeset: minor for @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

  1. added theissue type on Oct 5, 2026
  2. cuibonobo commented on Oct 13, 2026

    @cuibonobo
    MemberAuthor

    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-enum misfits. #446 did it the other way (commit 2216fa5): a typed read treats every enum as its listed values plus UnlistedValue (string & {}), and unknown-enum was 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 open would only bring back the closed-enum exhaustiveness that data-model.md § Type handles now 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions