Skip to content

Coercions, seals, and nested drafts - #369

Merged
joeldrapper merged 9 commits into
mainfrom
coercions-and-seals
Jul 31, 2026
Merged

Coercions, seals, and nested drafts#369
joeldrapper merged 9 commits into
mainfrom
coercions-and-seals

Conversation

@joeldrapper

@joeldrapper joeldrapper commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What this does

Three related changes, one commit each:

Literal::Coercion and Literal::Seal

Splits the property pipeline into two explicit stages:

  • Coercions normalize input before the type check. They run only at input boundaries — the initializer, writers, and draft assignment — never on final-value paths like from_props or marshal_load.
  • Seals fix a value's final representation (freezing it, for example), running after any coercion and before the type check, on every constructing path — the initializer, writers, and from_props, which takes values that may never have been through construction and computes defaults itself. They don't run on marshal_load: a loaded object was constructed (and sealed) before it was dumped, and the frozen state recorded at dump is what gets restored. Seals must be idempotent and type-preserving.

Both compose with >> and <<. Composing two coercions yields a coercion; composing a coercion into a seal yields a seal whose coercion runs first. Any arrangement that would run a coercion after a seal is rejected at composition time — and Seal is deliberately not callable, so raw_proc >> seal fails immediately instead of silently demoting the seal.

Properties take either through the existing block slot — the proc built by Literal::Coercion() / Literal::Seal() carries its pipeline structure, and prop splits it into the property's coercion and seal. Collection generics compose too: Literal::Array(String) >> Immutable.

The built-in Immutable and DeepImmutable become seals; NilIfEmpty becomes an explicit coercion.

Frozen state through Marshal

Marshal.load rebuilds contained objects unfrozen, so a property value that was frozen when dumped (a _Frozen-typed value, for example) came back mutable and failed the load-time type check. marshal_dump now records which values were frozen (payload version 2) and marshal_load re-freezes them before checking. Version 1 payloads still load, and old readers ignore the new payload element.

Nested drafts

A draft slot typed as a Literal::Properties class now also accepts a draft of that type (or a subtype), and finalize builds nested drafts depth-first — unless the drafted type's property wanted a draft as-is, in which case it stays one. Finalize never mutates the draft: finalizing twice builds two independent values.

Under the hood, relaxation is a first-class type: _DraftState(T) matches what a slot typed T may hold while drafting — one walk that relaxes both representation (stripping _Frozen) and finality (widening T to accept drafts of T). Draft properties are _Union(Undefined, _DraftState(T)), so errors and introspection name what a draft slot actually accepts. The union member is Literal::Draft::Type, a lightweight matcher with Literal::Draft(T)'s === semantics but no generated class, so recursive types (a Person with a Person property) don't recurse forever at draft-class definition. Draft coercions skip nested drafts the way they skip Undefined — a draft isn't the drafted type's input yet; it meets the coercion's output contract at finalize through its own construction.

Deliberately out of scope: drafts inside collections (_Array(T), Literal::Array(T)). Widening element types would force a type-driven relax/restore pair over the whole type algebra; holding that until there's a real need.

Testing

Each commit is individually green against the full suite (3172 → 3172 → 3176 → 3192 tests). New tests cover marshalling frozen-state round-trips, version 1 payload compatibility, and nested drafts: slot acceptance (subtypes, through _Nilable), depth-first finalize with nested defaults, inner missing-property errors, purity, draft-typed slots keeping their drafts, and coercion passthrough. Known gap: the >>/<< composition error paths have no direct tests yet.

🤖 Generated with Claude Code

Canonical draft classes

Literal::Draft(Foo) now returns the same generated class on every call until Foo's properties change, instead of a fresh class per call. The cache is keyed by the class's schema in an ObjectSpace::WeakKeyMap, with each entry recording the schema's sorted-properties snapshot — an array the schema replaces on every mutation, so its identity changes exactly when the schema does. Reopening a class invalidates lazily with no mutation tracking, and anonymous classes (and their stale drafts) stay collectable.

Building through drafts

Draft#finalize takes an optional block, yielded the draft after any props are assigned and before the value is built — for last touches like conditional assignment. Draft classes gain build — construct, yield, finalize in one call, with arguments going to the constructor and the block to finalize — and Literal::DataStructure.build layers the same onto Data and Struct themselves (structs are mutable, but required properties still make incremental construction impossible without a draft):

Foo.build(id: 1) do |draft|
  draft.name = name if bar?
end

Review follow-ups from the Codex findings are also in: deferred property types relax lazily (and the draft union now puts Undefined first so unresolved forward references never materialize early), and composed coercion stages run in the property context via instance_exec, with the composition algebra now under direct test.

joeldrapper and others added 3 commits July 31, 2026 13:53
Coercions normalize input before the type check and run only at input
boundaries — the initializer, writers, and draft assignment. Seals fix a
value's final representation (freezing it, for example) and run on every
path that stores a value into a real object, including from_props and
marshal_load. Both compose with >> and <<; any arrangement that would
run a coercion after a seal is rejected at composition time.

A property takes either through its existing block slot: the proc built
by Literal::Coercion() or Literal::Seal() carries its pipeline
structure, and prop splits it into the property's coercion and seal.
Collection generics compose too, via Literal::Coercions::Composable.

The Immutable and DeepImmutable coercions become seals; NilIfEmpty
becomes an explicit coercion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Marshal.load rebuilds contained objects unfrozen, so a property value
that was frozen when dumped — a _Frozen-typed value, for example — came
back mutable and failed the load-time type check. marshal_dump now
records which values were frozen (payload version 2) and marshal_load
re-freezes them before checking. Version 1 payloads still load; old
readers ignore the extra element.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A draft slot typed as a Literal::Properties class now also accepts a
draft of that type (or of a subtype), and finalize builds nested drafts
depth-first — unless the drafted type's property wanted a draft as-is.
The draft itself is never mutated: finalizing twice builds two
independent values.

The union member is Literal::Draft::Type, a lightweight matcher with
Literal::Draft(T)'s semantics but no generated class, so recursive
types don't recurse forever at draft-class definition. __thaw__
becomes __relax__: one walk relaxing both representation (_Frozen)
and finality. Draft coercions skip nested drafts the way they skip
Undefined — a draft isn't the drafted type's input yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e729cf03b7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/literal/draft.rb Outdated
Comment thread lib/literal/data_structure.rb
Comment thread lib/literal/coercion.rb Outdated
joeldrapper and others added 3 commits July 31, 2026 14:32
_Deferred property types — how recursive and forward-referenced models
are declared — passed through __relax__ unchanged, so their draft slots
were never widened to accept nested drafts. The relaxation now wraps the
deferral in another _Deferred that relaxes on materialization, keeping
constants that don't exist yet at draft-definition time unresolved.

The draft union also puts Undefined first: union members are tried in
order, so matching the unset sentinel by identity keeps deferred members
from materializing during prop's default check and unset-slot type
checks — previously drafting a type with an unresolvable deferred
property raised NameError at definition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A plain block coercion runs via instance_exec, so self is the object
being constructed — but composed stages were invoked with Proc#call,
leaving them bound to their definition site. A coercion that worked
alone would raise NameError or read the wrong state once composed.
__compose__ now instance_execs each stage against the current self.

Also adds the first direct tests for the composition algebra: both
compose directions, coercion-into-seal splitting, the coercion-after-
seal error paths, and seals being uncallable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Literal::Draft(Foo) now returns the same generated class on every call
until Foo's properties change, instead of generating a fresh class per
call. The cache key is the class's schema, and each entry records the
schema's sorted-properties snapshot — an array the schema replaces on
every mutation, so its identity changes exactly when the schema does.
Reopening a class invalidates lazily with no mutation tracking, and the
weak keys let anonymous classes and stale drafts be collected.

The schema is the key rather than the snapshot because WeakKeyMap
compares keys with eql?: a subclass's snapshot is eql? to its parent's,
but each class has its own schema, compared by identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@joeldrapper
joeldrapper force-pushed the coercions-and-seals branch 2 times, most recently from 7f56d84 to fa62e01 Compare July 31, 2026 14:14
Draft#finalize now yields the draft to an optional block — after any
props passed to finalize are assigned, before the value is built — for
last touches like conditional assignment. Draft classes gain build,
which constructs, yields, and finalizes in one call: positional and
keyword arguments go to the draft's constructor, the block to finalize.

Literal::DataStructure gets the same build, so Data and Struct can be
conditionally constructed without Literal::Draft at the call site —
structs are mutable, but required properties still make incremental
construction impossible without a draft:

  Foo.build(id: 1) do |draft|
    draft.name = name if bar?
  end

Deliberately not on Literal::Properties itself — build is a common
user-defined factory name, and classes extending Properties directly
can use Literal::Draft(self).build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@joeldrapper
joeldrapper force-pushed the coercions-and-seals branch from fa62e01 to bb22a5a Compare July 31, 2026 14:16
joeldrapper and others added 2 commits July 31, 2026 15:32
Relaxation was a private walk inside draft-class generation — a shadow
type transformation invisible to the rest of the algebra. It's now a
first-class type: _DraftState(T) matches what a slot typed T may hold
while drafting, with representation (_Frozen) and finality relaxed so a
Literal::Properties class also admits a draft of itself.

Same semantics, relocated: draft properties become
_Union(Undefined, _DraftState(T)), errors and introspection name what a
draft slot actually accepts, and the question every new combinator must
answer — what does relaxation mean here? — now has one visible home.
The unset sentinel stays outside in the union, where its exact
containment marks the property omittable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seals belong to construction: they establish a value's final
representation as it enters an object. A marshal-loaded object was
constructed — and sealed — before it was dumped, and marshal_dump
already records each value's frozen state for marshal_load to restore.
Re-running seals on load conflated restoring with constructing, and a
seal that dups would even produce different objects than were dumped.

from_props keeps sealing: it takes values that may never have been
through construction, and computes defaults itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@joeldrapper
joeldrapper merged commit 17685d8 into main Jul 31, 2026
3 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant