field_struct 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +58 -0
  3. data/CHANGELOG.md +653 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/README.md +645 -0
  6. data/UPGRADING.md +743 -0
  7. data/USAGE.md +820 -0
  8. data/docs/getting_started.md +337 -0
  9. data/lib/field_struct/base.rb +1316 -0
  10. data/lib/field_struct/error.rb +48 -0
  11. data/lib/field_struct/errors.rb +103 -0
  12. data/lib/field_struct/field.rb +179 -0
  13. data/lib/field_struct/metadata.rb +331 -0
  14. data/lib/field_struct/model_name.rb +48 -0
  15. data/lib/field_struct/rbs.rb +299 -0
  16. data/lib/field_struct/registry.rb +85 -0
  17. data/lib/field_struct/scaffold.rb +331 -0
  18. data/lib/field_struct/types/array.rb +156 -0
  19. data/lib/field_struct/types/base.rb +162 -0
  20. data/lib/field_struct/types/big_decimal.rb +78 -0
  21. data/lib/field_struct/types/binary.rb +38 -0
  22. data/lib/field_struct/types/boolean.rb +126 -0
  23. data/lib/field_struct/types/date.rb +158 -0
  24. data/lib/field_struct/types/datetime.rb +163 -0
  25. data/lib/field_struct/types/email.rb +49 -0
  26. data/lib/field_struct/types/float.rb +80 -0
  27. data/lib/field_struct/types/immutable_string.rb +18 -0
  28. data/lib/field_struct/types/integer.rb +133 -0
  29. data/lib/field_struct/types/nested.rb +82 -0
  30. data/lib/field_struct/types/preset_resolver.rb +37 -0
  31. data/lib/field_struct/types/rfc3339_format.rb +197 -0
  32. data/lib/field_struct/types/string.rb +73 -0
  33. data/lib/field_struct/types/symbol.rb +38 -0
  34. data/lib/field_struct/types/temporal_parser.rb +93 -0
  35. data/lib/field_struct/types/time.rb +168 -0
  36. data/lib/field_struct/types/time_format_resolver.rb +54 -0
  37. data/lib/field_struct/types/union.rb +96 -0
  38. data/lib/field_struct/types/url.rb +46 -0
  39. data/lib/field_struct/types/uuid.rb +46 -0
  40. data/lib/field_struct/types/value.rb +24 -0
  41. data/lib/field_struct/types/zone_option.rb +267 -0
  42. data/lib/field_struct/version.rb +7 -0
  43. data/lib/field_struct.rb +159 -0
  44. data/sig/field_struct.rbs +2845 -0
  45. data/skills/field-struct/SKILL.md +183 -0
  46. metadata +139 -0
data/CHANGELOG.md ADDED
@@ -0,0 +1,653 @@
1
+ # Changelog
2
+
3
+ All notable changes to FieldStruct are recorded here. Format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project
5
+ adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.15.0] - 2026-10-23
10
+
11
+ Faster, with two small behaviour changes. `new` is about 45% faster and
12
+ allocates 11 objects instead of 39 for five fields; `to_json` takes about a
13
+ third less time; validation-heavy structs are nearly twice as fast (medians of
14
+ interleaved runs on an M4, PR #41). Two changes are
15
+ visible, both silent and both in `UPGRADING.md` "0.14 → 0.15": `new` no longer
16
+ runs a field's setter with `nil` before the value you gave it, and `as_json`
17
+ reads the fields directly, so an `attributes` override no longer shapes the
18
+ JSON. Every other change keeps output byte-identical.
19
+
20
+ ### Changed
21
+
22
+ - **`new` skips the nil default for keys it is given.** A field with no `default:` whose key the input carries is no longer first set to `nil` through its setter: an overridden setter runs once, with the given value, and under `coercion_policy :raise` a custom type that refuses `nil` no longer raises from `new` for a given key. Non-Hash input is refused before callable defaults run. Values, errors and ivar order are unchanged. <!-- upgrading: defaults-skip-nil-pass -->
23
+ - **`as_json` / `to_json` read the declared fields, not `attributes`.** One pass over the fields, each read through its reader, instead of building `attributes` and walking it again. An `attributes` override no longer changes the JSON; a reader override still does. `attributes` and `to_h` are unchanged. <!-- upgrading: as-json-reads-fields -->
24
+ - **Faster `new`, `from_json` and `:date` parsing; fewer allocations.** Setter and ivar names are built once per field instead of per assignment, a `:date` string is parsed once rather than twice, `from_json` reuses its reverse `serialize :json` map, arrays without nested structs skip the per-element validity scan, `Errors#empty?` no longer allocates, and the inherited class macros stop walking at `FieldStruct::Base`. A 5-field `new` drops from 39 to 19 allocations. Values, errors and messages are unchanged. <!-- upgrading: none -->
25
+
26
+ ### Documentation and tooling
27
+
28
+ - **UPGRADING.md: only silent changes need a Find on a green suite.** From a
29
+ dry run against a large app: the TL;DR now lists the eight entries that change
30
+ values or validity without raising, and says the other Finds cover code the suite
31
+ doesn't reach. The base-10 Find matches whole literals only (849 hits → the real
32
+ ones). The non-Hash Find is scoped to your struct classes, and the `dup` Find
33
+ says when a hit matters. The RBS step names wrapper tasks, ripgrep's PCRE2 check
34
+ comes first, and the effort estimate counts full suite runs. AGENTS.md and the
35
+ skill follow.
36
+
37
+ ## [0.14.0] - 2026-10-23
38
+
39
+ DST-aware zones and fuller array errors. `zone:` accepts IANA names
40
+ (`'America/Denver'`) and reads zone-less times at that zone's offset on that
41
+ date; a time in a spring-forward gap is refused, an overlap reads at the
42
+ daylight offset as ActiveSupport does. This adds `tzinfo` (`~> 2.0`) as a
43
+ runtime dependency, loaded only when a field names an IANA zone — Rails apps
44
+ already have it. Array errors now name every bad index, and `describe` lists
45
+ element options by name. See `UPGRADING.md` "0.13 → 0.14".
46
+
47
+ ### Added
48
+
49
+ - **IANA names in `zone:`** on `:time` and `:datetime`: `optional :starts, :time, zone: 'America/Denver'`. A zone-less string is read as the wall clock in that zone *at that date* (`"2026-07-01 10:00"` → -06:00, `"2026-01-15 10:00"` → -07:00); a `format:` without `%z` likewise, an omitted date being today in the zone; a `Date` becomes midnight at that day's offset; an epoch under `format: '%s'` keeps its instant at the offset in force then. Input with its own offset (strings, `Time`, `DateTime`, TimeWithZone) keeps it, as with fixed offsets. A wall-clock time the zone skips (02:30 on a spring-forward day) is refused with a class-only message naming the zone; one it repeats (01:30 on a fall-back day) takes the daylight-time offset, as ActiveSupport's TimeWithZone does; a `Date` whose midnight is skipped becomes the day's first moment. The value is a plain `Time` / `DateTime` carrying the offset (not a `TZInfo::TimeWithOffset`). Validated at declaration through tzinfo: an unknown name, a no-`/` abbreviation or alias (`"MST"`, `"EST5EDT"`) and a Rails name (`"Mountain Time (US & Canada)"`, with a hint) raise `ArgumentError` naming the option; missing zone data raises saying to add `tzinfo-data`. Identical with and without ActiveSupport. `describe`, `Metadata#to_h` and the RBS are unchanged in shape. See USAGE "Zones".
50
+
51
+ ### Breaking-ish (visible behaviour changes)
52
+
53
+ - **New runtime dependency: `tzinfo` (`~> 2.0`).** Apps without Rails gain `tzinfo` and `concurrent-ruby` in `Gemfile.lock`; a lock pinning tzinfo 1.x no longer resolves. It is required only when a field declares an IANA name, so `require 'field_struct'` and `"UTC"` / `"±HH:MM"` fields don't load it. <!-- upgrading: tzinfo-dependency -->
54
+ - **`zone:` no longer refuses IANA names, and its refusal message is reworded**: `zone: on Time expects an IANA name like "America/Denver", "UTC" or a UTC offset like "-06:00", got "MST". …` (was `… expects "UTC" or a UTC offset like "+00:00" or "-06:00", got … Zone names such as "America/Denver" are not accepted …`). <!-- upgrading: zone-accepts-iana-names -->
55
+ - **Array errors name every bad index, not only the first.** One bad element reads as before; several list their indexes and show the first one's reason: `could not be coerced: elements at index 1, 4 (index 1 shown): Integer fields expect …` for coercion (every `coercion_policy`, `:raise` included) and `elements at index 1, 4 are invalid` for `element:` validation. Past five indexes the rest are counted (`… 0, 1, 2, 3, 4 and 15 more …`). Indexes only, never values. An all-valid array costs the same as before. <!-- upgrading: array-every-index -->
56
+ - **`describe` lists an array's element options by name only**: `— element accepts in, base, strict` (was `— element accepts in (Array | Range), base (Integer), strict (TrueClass | FalseClass)`). The shapes are the element type's `option_schema`. <!-- upgrading: array-describe-names-only -->
57
+
58
+ ### Documentation and tooling
59
+
60
+ - **The UPGRADING.md drift guard asks for a decision on every Changed / Removed bullet.** Each `[Unreleased]` bullet under `### Changed` or `### Removed` must carry a `<!-- upgrading: … -->` slug marker (with its UPGRADING.md entry) or `upgrading: none` (no caller action), so a visible change filed outside a Breaking heading can't skip the guide unnoticed. `none` never satisfies a Breaking bullet, and released sections are not re-checked. Convention in CLAUDE.md.
61
+
62
+ ## [0.13.1] - 2026-10-22
63
+
64
+ Upgrade guidance, no code changes. `UPGRADING.md` walks a project from 0.10 to
65
+ 0.13 step by step — symptom, an `rg` command to find the affected code, the fix
66
+ — for people and AI agents alike, and a spec keeps it in step with this
67
+ CHANGELOG.
68
+
69
+ ### Documentation and tooling
70
+
71
+ - **`UPGRADING.md`: moving from 0.10 to 0.11, 0.12 and 0.13.** Every visible change as Symptom (the exception or failure you'll see) / Find (an `rg` command, each checked against a "before" snippet) / Fix (before/after) / Why, a symptom index, a straight 0.10 → 0.13 path, the opt-in options worth adopting, and how to bisect a failure by version. Its runnable fixes are doctested. AGENTS.md and the skill gain an "Upgrading" procedure for agents; the README links it from Installation; it ships in the gem. The skill's RBS example now shows the typed setter 0.12 generates.
72
+ - **Drift guard.** `spec/upgrading_spec.rb` fails when a CHANGELOG bullet under a Breaking heading (0.11.0 onward) has no `<!-- upgrading: … -->` marker, when a marked bullet has no UPGRADING.md entry with the same marker, when UPGRADING.md's covered range stops short of `VERSION`, or when it links a missing anchor. Breaking bullets since 0.11.0 now carry these invisible markers. Convention in CLAUDE.md.
73
+
74
+ ## [0.13.0] - 2026-10-22
75
+
76
+ Safer input and log safety. Opt-in `strict: true` on `:string` / `:integer`;
77
+ error messages never echo a value and `sensitive: true` masks one in
78
+ `inspect`; `zone:` reads zone-less times at a declared offset; `with(**changes)`
79
+ copies with changes; array fields pass `element:` options to each element.
80
+ Defaults are unchanged except the Breaking-ish items below — message wording,
81
+ `:float` refusing hex strings, the element index in array errors, and `with` /
82
+ `dup` becoming reserved field names.
83
+
84
+ ### Added
85
+
86
+ - **`strict: true` on `:string` and `:integer`** (opt-in, per field; defaults unchanged until 1.0). `optional :ref, :string, strict: true` accepts a String, Symbol, Integer or an object responding to `to_str`, and refuses a Hash, Array, boolean, Float/BigDecimal/Rational (whose `to_s` is `"0.12e2"`, `"1/2"`), Complex or any other object with a `TypeError` naming its class — by default those are stored as their `to_s` (`{a: 1}` → `"{a: 1}"`). `optional :cents, :integer, strict: true` accepts an Integer or a numeric equal to one (`12.0` → 12, `BigDecimal("12")`, `Rational(24, 2)`) and refuses `12.7` with an `ArgumentError` instead of truncating to 12; strings follow the default base-10 / `base:` rules unchanged, so `"12.0"` and `"1e3"` stay refused. Strict only narrows: an accepted value becomes exactly what the default would make of it. The string subtypes (`:immutable_string`, `:uuid`, `:url`, `:email`, `:binary`) inherit it. `strict:` must be a Boolean (`ArgumentError` at declaration otherwise) and raises on any other type, naming the types it applies to. See USAGE "Strict coercion".
87
+ - **`zone:` on `:time` and `:datetime`** (opt-in, per field; defaults unchanged). A zone-less string (`"2026-10-22 10:00"`) is read at the declared offset instead of the process zone (`:time`) or UTC (`:datetime`), so the same input is the same instant on any server: `optional :starts, :time, zone: '-06:00'`. Accepts `"UTC"` and fixed `"±HH:MM"` offsets; IANA names (`"America/Denver"`), `"Z"`, `"utc"` and colon-less offsets raise `ArgumentError` at declaration — the stdlib has no zone database and acceptance must not depend on ActiveSupport being loaded. It only fills a *missing* offset: a string with an offset, a `Time`, `DateTime` or TimeWithZone keeps its own; a `Date` becomes midnight in the zone; a `format:` without `%z` (`:db`, `'%H:%M'`) is read in the zone, filling an omitted date from today in that zone, and an epoch string under `format: '%s'` keeps its instant at that offset; a string naming a zone the stdlib cannot read (`"… XYZ"`) is refused rather than overridden; Integers stay refused. Shown in `describe` and `Metadata#to_h` like any option. See USAGE "Zones".
88
+ - **`Base#with(**changes)`** returns a new instance of the receiver's class with the given attributes changed, leaving the receiver untouched (like `Data#with`). Changed fields run the setter pipeline (coercion, `coercion_policy`, errors rewritten) and cross-field validators rerun; unchanged fields, their errors included, are carried over as `dup` carries them, and callable defaults are not called again. Shallow, like `dup`: unchanged nested structs and arrays are shared. Unknown keys follow `unknown_attributes`. Works on `immutable!` and frozen instances; the copy is immutable when the class is and frozen when the receiver is. Positional arguments raise `ArgumentError`. See USAGE "Copying with changes".
89
+ - **`element:` on `:array` fields** passes the element type's own options to every element: `optional :ids, :array, of: :integer, element: {strict: true, base: 16}`. Coercion options (`strict:`, `base:`, `round:`, `format:`, `zone:`, `values:`) apply to each element's coerce; validation options (`format:` Regexp, `enum:`, `in:`) are checked per non-nil element, and a breach is reported by index (`"element at index 2 is invalid"`). Checked at declaration against the element type's schema with the error a scalar field would get, prefixed `in element: of array field :ids, …`; unlike a field, `element:` refuses keys its element type doesn't take (listing the ones it does) and field-level options (`required:`, `sensitive:`, …). A temporal element's `format:` is what `to_json` writes, so `from_json` round-trips. Shown in `Field#inspect`, `Metadata#to_h` and `describe`, which also lists what each array's elements accept. Not supported for arrays of arrays or nested-struct elements. Generated RBS is unchanged (no element option changes an element's class). See USAGE "Element options".
90
+ - **`sensitive: true` field option** (any type). `Base#inspect` — and so `pp`, the `errors:` suffix of an invalid instance, and nested structs/arrays of them — renders `[FILTERED]` (`FieldStruct::FILTERED`) in place of a non-nil value; `nil` still shows. Data paths are deliberately **not** masked: the reader, `attributes`/`to_h`, `as_json`/`to_json`, `deconstruct_keys` and equality return the real value, so `from_json(to_json)` still round-trips. Must be `true`/`false` (`ArgumentError` at declaration otherwise). Readable as `Field#sensitive?`; shown in `Field#inspect`, `Metadata#to_h` (`sensitive: true`) and `describe` only when set; kept out of `Field#options`. See USAGE "Log safety".
91
+
92
+ ### Documentation and tooling
93
+
94
+ - **USAGE: "Payment and API boundaries"** — when and why to declare `unknown_attributes :raise` (on every struct that reads input; nested classes don't inherit it from their holder; `from_json` reverse-maps `serialize :json` names first). Doctested.
95
+ - **USAGE: "Validity is computed on assignment"** — mutating a nested struct or array element in place doesn't refresh the parent's errors (stale in both directions), and an in-place array push skips coercion; re-assign the field or use `with`. Doctested. README, AGENTS.md, the skill and getting started now cover `strict:`, `zone:`, `sensitive:`, `element:`, `with` and this.
96
+ - **`benchmarks/run.rb` and `rake bench`** (not in the default task): dependency-free timings for `new`, `from_json`, `to_json`, nested, a 100-element array, `strict:` vs default and a validation-heavy struct. README "Performance" has indicative numbers. `benchmarks/` is left out of the packaged gem.
97
+
98
+ ### Breaking-ish (visible behaviour changes)
99
+
100
+ - **`field :with` and `field :dup` are refused at declaration** (added to `FieldStruct::Base::RESERVED_FIELD_NAMES`): `with` is now a `Base` method, and `with` relies on `dup`. A field of either name would shadow it. Rename the field; `serialize :json, with_field: 'with'` keeps the external key. `Scaffold` renames them with a `_field` suffix. <!-- upgrading: reserved-with-dup -->
101
+ - **A refused array element is named by its index.** Every `:array` coercion failure is prefixed with the element's 0-based index, keeping the element type's error class: `could not be coerced: Integer fields expect …` → `could not be coerced: element at index 1: Integer fields expect …` (and so in `CoercionError` under `:raise`). `describe` lines for `:array` fields gain an `element accepts …` part, and an array field's `options` list `of_type:` first. <!-- upgrading: array-element-index -->
102
+ - **No error message echoes an input value** (payments data must not reach logs). Messages now name the field, the expected type and the input's class. If you match on message text, update the patterns: <!-- upgrading: messages-never-echo-values -->
103
+ - `:boolean`: `cannot coerce "maybe" to boolean` → `Boolean fields expect true, false, 1, 0 or one of the strings "true", "1", "false", "0", got a String that is not one` (non-Strings: `…, got Hash`). A `values:` vocabulary is listed instead of the defaults.
104
+ - `:union`: `no union member could coerce "abc"` → `no union member (Integer | Boolean) could coerce String input`.
105
+ - `coercion_policy :raise`: `could not coerce "abc" for cents: …` → `could not coerce String input for cents: …`.
106
+ - `from_json` with a non-object root: `…, got Array: ["x"]` → `…, got Array`. Invalid JSON keeps Oj's error class (`EncodingError`) but its message is now `from_json could not parse its input as JSON (unexpected character at line 1, column 7)` — Oj's own message quoted the whole input. The Oj error is no longer the `cause`.
107
+ - `:integer` / `:float` / `:decimal` messages were already class-only, but the Kernel conversion error (`invalid value for Integer(): "…"`) rode along as the exception's `cause`, which error reporters print. It is dropped.
108
+
109
+ - **`:float` refuses hex strings.** `"0x1A"` was read as 26.0 (`Kernel.Float` accepts hex) while `:integer` has refused it since 0.11; both are now base 10. A hex string — with or without sign or surrounding whitespace, including hex-float `"0x1p3"` — is an ordinary coercion failure (`Float fields expect a base-10 number, got a String that is not one`). Binary/octal prefixes were already refused; `"1_000.5"`, `" 2.5 "`, `"1e3"`, `".5"` and `"017"` (→ 17.0) read as before. <!-- upgrading: float-refuses-hex -->
110
+
111
+ ## [0.12.0] - 2026-10-22
112
+
113
+ Valid, guarded RBS. The gem's own signatures go from 215 `untyped` to 5 and
114
+ can only get more typed (CI ratchet); generated RBS for your structs carries
115
+ real element types, typed setters and an `rbs_type:` escape hatch. No runtime
116
+ behaviour changes beyond `rbs_type:` becoming a recognized option.
117
+
118
+ ### Added
119
+
120
+ - **`rbs_type:` field option.** `optional :scope, :value, rbs_type: '::ActiveRecord::Relation'` makes `FieldStruct::RBS` emit that type for the field's reader and writer instead of the derived one. Signatures only — no effect on coercion or validation. Must be a non-blank String (`ArgumentError` at declaration otherwise); it is not parsed, since `rbs` is not a runtime dependency. Readable as `Field#rbs_type` and shown in `Field#inspect`; kept out of `Field#options`. <!-- upgrading: rbs-type-option -->
121
+ - **`Types::Base#rbs_type` hook.** A custom type can return an RBS expression (`def rbs_type = '::Hash[::Symbol, ::String]'`) for every field of that type. Default `nil` (derive from `ruby_type`). A field's `rbs_type:` wins over it.
122
+ - **`FieldStruct::RBS.type_for(field, nullable: nil) -> String`.** The per-field RBS type, exactly as `generate` emits it, for other generators. `nullable: nil` follows `!field.required?`.
123
+
124
+ ### Changed
125
+
126
+ **Generated RBS changes for consumers.** Regenerating sigs for your own structs with `FieldStruct::RBS.generate` gives different output, and Steep may report new errors in code that relied on the old `untyped` writers:
127
+
128
+ - **Writers are typed `(T? value) -> T?`** (were `(untyped value) -> untyped`). The runtime setter accepts `nil` and returns the coerced value. Assigning loose input the type would coerce (`user.age = "30"`) still works in Ruby but is now a Steep error. <!-- upgrading: rbs-typed-writers -->
129
+ - **Output opens with a comment header** saying the types describe a *valid* instance — an invalid one (unset required field, `:keep_raw` failure holding raw input) can hold other values. <!-- upgrading: rbs-header -->
130
+ - **Array elements of a scalar type are nullable** (`of: :string` → `::Array[::String?]`): `["x", nil]` passes validation, so the old `::Array[::String]` was false. Nested elements stay non-nil (a nil one fails validation). A union's members use their own `rbs_type`, and an anonymous nested class raises `ArgumentError` instead of emitting `::?`. <!-- upgrading: rbs-nullable-array-elements -->
131
+ - **Generic arguments are always present.** A custom type whose `ruby_type` is a bare generic class (`::Hash`, `::Array`, `::Range`) emits `::Hash[untyped, untyped]` and so on, instead of a bare `::Hash`, which `rbs validate` rejects. <!-- upgrading: rbs-generic-arguments -->
132
+ - **Unions render a boolean member as `bool`** (`(::String | bool)`, was `(::String | ::TrueClass | ::FalseClass)`), and a union with a `:value` member collapses to `untyped`. <!-- upgrading: rbs-union-bool -->
133
+ - **Optional `:value` fields are `untyped`, not `untyped?`.** <!-- upgrading: rbs-optional-value-untyped -->
134
+
135
+ ### Signatures
136
+
137
+ - **Nil is admitted where the runtime returns it.** `Base#attributes`, `#to_h`, `#as_json` and `#deconstruct_keys` return `::Hash[Symbol, Object?]`, `#deconstruct` `::Array[Object?]`, `Field#options` is `::Hash[Symbol, Object?]` (e.g. `format: nil`), and `Metadata#to_h` is `::Hash[Symbol, ::Hash[Symbol, Object?]]` (`default: nil`, `description: nil`). They claimed non-nil values. No runtime change. Steep report-only count 190 → 189. <!-- upgrading: own-sigs-admit-nil -->
138
+
139
+ - **Core types in `Types` name the core class.** `Types::String#coerce`, `Types::Symbol#coerce`, `Types::Integer#coerce` and `Types::Float#coerce` were typed with bare `String` / `Symbol` / `Integer` / `Float`, which RBS resolves to `FieldStruct::Types::String` etc. inside `module Types`. Their YARD now says `::String` and friends, so the sig says the value is a `::String`, not a type object.
140
+ - **Keyword and rest splats are typed per value.** `**options` on `field` / `required` / `optional` / `Field.new` is `**Object` (was `**::Hash[Symbol, Object]`, which meant every option value had to be a Hash), `serialize`'s `**mapping` is `**(String | Symbol)`, and `validate`'s `*method_names` is `*Symbol` (was `*::Array[Symbol]`). No runtime change. `sigs:untyped` ceiling 215 → 212, sord allowlist 155 → 152.
141
+ - **Private helpers, generics and constants are typed.** Every private helper Sord used to write as `(untyped ...) -> untyped` now has YARD types, generic `Hash[untyped, untyped]` / `Array[untyped]` name their key and element types, and Sord runs without `--skip-constants`, so the sig declares the gem's constants (`VERSION: String`, `Base::RESERVED_FIELD_NAMES: ::Array[Symbol]`, …) from their `@return` tags. RBS has no private constants, so `private_constant`s (`Base::UNSET`, `Metadata::EMPTY_SERIALIZATION`, …) are declared too. Where a value really is anything (raw input, `:value` fields, Oj output) the type is `Object?`, never narrower. Hashes whose values can be `nil` now say so: `new` / `assign_attributes` take `::Hash[(Symbol | String), Object?]`, and the `**options` of `field` / `required` / `optional` / `Field.new` are `**Object?` (FS-6 made them `**Object`, which rejected `format: nil`). `Base.validators` is `::Array[Proc]` (it only ever holds lambdas and blocks). No runtime change. `sigs:untyped` ceiling 212 → 5, sord allowlist 152 → 6; what remains is `#to_hash` input (`_ToHash[untyped, untyped]`) and `Field#default`, a value or a callable.
142
+
143
+ - **`sig/` can only get more typed.** Three guards, no Steep gate:
144
+ - `rake sigs:untyped` (CI, `release:check`) counts non-comment `untyped` in `sig/` against a committed ceiling (`rakelib/sig_guards/untyped_ceiling.txt`, 215). Above it fails; below it also fails until the ceiling is lowered, so the slack can't be spent later.
145
+ - `sigs:generate` / `sigs:check` fail on a Sord `[OMIT]` / `[DUCK]` warning missing from `rakelib/sig_guards/sord_allowlist.txt` (155 entries, keyed without file:line), and on an entry Sord no longer emits, so the list only shrinks.
146
+ - `sigs:validate` runs `rbs validate` over the whole `sig/` directory, not just the generated file.
147
+ - **Sord runs without `--replace-errors-with-untyped`.** The output is byte-identical today; an unresolvable YARD type now lands in the sig as its own name (which `rbs validate` rejects) instead of a quiet `untyped`, on top of the existing generation-time guard.
148
+ - **Report-only Steep lane.** A `types` job on Ruby 3.4 runs `steep check` (Steep 2.1 / rbs 4.2 via `gemfiles/types.gemfile`, since both need Ruby >= 3.3) and prints the problem count. It never fails the build and is not part of the required `ci` check.
149
+ - **The packaged gem also leaves out `gemfiles/`** (CI-only Gemfiles).
150
+
151
+ ## [0.11.0] - 2026-10-22
152
+
153
+ Correctness and safer input. A number field holds a finite number of its own
154
+ type, read the same way regardless of how the host process is configured;
155
+ `from_json` reads decimals exactly; input that isn't a Hash, and field names
156
+ `Base` relies on, are refused up front.
157
+
158
+ ### Breaking-ish (visible behaviour changes)
159
+
160
+ - **`:integer` strings parse in base 10.** `Kernel#Integer` with no radix honours Ruby literal prefixes, so `"010"` became 8 and `"0x1A"` / `"0b11"` were accepted as hex and binary. Now `"010"` is 10, `"007"` is 7, and prefixed strings are refused. Whitespace and digit underscores still parse (`" 42 "`, `"1_000"`). Floats still truncate; Integers pass through. <!-- upgrading: integer-base-10 -->
161
+ - **`round: 0` returns a `BigDecimal` / `Float`, not an `Integer`.** `:decimal` and `:float` fields with `round: 0` used to hold an Integer; they now keep the field's type (`BigDecimal('4')`, `4.0`). <!-- upgrading: round-zero-keeps-type -->
162
+ - **`:decimal` rounding ignores the global `BigDecimal.mode`.** It followed the process-wide rounding mode, so a host calling `BigDecimal.mode(BigDecimal::ROUND_MODE, :banker)` turned `12.345` at `round: 2` into `12.34`. Rounding is now pinned half up (ties away from zero) per call, for `:float` too. <!-- upgrading: decimal-rounding-ignores-mode -->
163
+ - **NaN and ±Infinity are refused.** `:float` and `:decimal` stored them as valid values (`:decimal` accepted `"NaN"` and emitted `"NaN"` in JSON); `:integer` raised `FloatDomainError` out of `.new` and `from_json('{"n": 1e400}')`. All three now treat a non-finite value — Float, BigDecimal, String or JSON `1e400` — as an ordinary coercion failure under the field's `coercion_policy`. A `:union` refuses a non-finite Float or BigDecimal outright, before any member sees it, so `of: [:integer, :string]` no longer falls through to storing `"NaN"` / `"Infinity"` as a valid String. <!-- upgrading: non-finite-refused -->
164
+ - **`:integer`, `:float` and `:decimal` coercion messages name the input class, never the value.** That covers the new non-finite refusals and the parse failures these types re-raise from `Kernel#Integer` / `#Float` / `#BigDecimal`, whose own messages echoed the input (`invalid value for Float(): "NaN"`, a Rational's `"1/3"`). It is not yet true everywhere: the field error a `:union` records when no member matches, and the `CoercionError` raised under `coercion_policy :raise`, still include the raw value. <!-- upgrading: numeric-messages-name-class -->
165
+ - **`new` and `assign_attributes` refuse input that isn't a Hash.** Anything that is not a Hash and does not respond to `to_hash` raises `ArgumentError` naming its class (`Person.new expects a Hash (or an object responding to #to_hash), got Array`). **Breaking:** an array of pairs (`new([[:name, "Ann"]])`) used to work and now raises; pass `pairs.to_h`, as ActiveModel's `assign_attributes` also requires. `new([])` used to build an empty instance silently, and `new("x")` / `new(5)` raised `NoMethodError`. Objects responding to `to_hash` are converted first, so a permitted `ActionController::Parameters` keeps working — an *unpermitted* one now raises Rails' own `UnfilteredParameters` instead of being read key by key. `new(nil)` is now the same as `new` (it raised `NoMethodError`); `assign_attributes(nil)` raises `ArgumentError`, as ActiveModel's does. <!-- upgrading: non-hash-input-refused -->
166
+ - **Field names `Base` relies on are refused at declaration.** `field :errors` raised a `NoMethodError` from deep inside the class body, `field :class` likewise, and `field :hash` / `field :attributes` were accepted but silently broke equality, `inspect` and validation by shadowing the methods `Base` calls on itself. These names (`FieldStruct::Base::RESERVED_FIELD_NAMES`: `errors`, `attributes`, `hash`, `class`, `send`, `to_h`, `inspect`, `as_json`, `raise`, … and `Base`'s private helpers) now raise `ArgumentError` naming the field and suggesting a `serialize :json` mapping. The list is curated, not `instance_methods`: `method`, `name` and `type` stay legal. `Scaffold` renames the same names with a `_field` suffix. <!-- upgrading: reserved-field-names -->
167
+ - **`from_json` parses JSON decimals as `BigDecimal`, not Float.** It parsed with Oj's default, so `{"d": 1234567890123.4567}` reached a `:decimal` field as `1234567890123.46` — rounded through a Float before the field ever saw it. Numbers with a fraction or exponent now parse as `BigDecimal` (`decimal_class: BigDecimal`), so `:decimal` gets the exact digits. Visible elsewhere: <!-- upgrading: from-json-bigdecimal -->
168
+ - `:float` fields are unchanged (still a Float). `:integer` fields still truncate (`3.7` → `3`).
169
+ - **`:value` fields, and numbers inside a `:value` Hash or Array, now receive `BigDecimal` instead of `Float`** — and since `to_json` writes a `BigDecimal` as a string, a `:value` that passed `2.25` through now emits `"2.25"`.
170
+ - JSON `1e400` is now a finite number rather than a Float overflowing to Infinity: a `:decimal` field reads it exactly (as it already read the String `"1e400"`), an `:integer` field reads it as `10**400`, and a `:float` field still refuses it.
171
+ - An `:integer` field refuses a decimal whose integer part has more than 4300 digits (`Types::Integer::MAX_DECIMAL_DIGITS`, Python's CVE-2020-10735 limit). Without it, JSON `1e10000000` (18 bytes) built a 33-million-bit Integer in seconds of CPU.
172
+ - The `oj` dependency floor rises from `>= 3.0` to `>= 3.11.2`, the release that fixed the `decimal_class` option; an older Oj would ignore it and quietly keep the Float. <!-- upgrading: oj-floor -->
173
+ - **`base:` is now a reserved option name, like `round:`.** FieldStruct treats every option some registered type declares as its own. So a custom type that reads `base:` from its `coerce` kwargs without declaring it in `own_option_schema` used to receive it as a pass-through option (`optional :x, :my_type, base: 16` worked), and now raises at declaration: `base: option does not apply to MyType fields. It applies to: Integer.` That is how `round:` already behaves. The fix is to declare it: `def self.own_option_schema = {base: option(type: [::Integer])}`. For the same reason, a foreign `base:` on any other built-in type (e.g. `optional :code, :string, base: 16`, kept as metadata for downstream tooling) now raises instead of being stored on the Field. <!-- upgrading: base-reserved-option -->
174
+
175
+ ### Added
176
+
177
+ - **`base:` option on `:integer`** — an Integer radix (2..36, checked at class load) for string input: `field :code, :integer, base: 16` reads `"1A"` as 26. `base:` on any other type raises at declaration, naming `Integer`.
178
+
179
+ ### Fixed
180
+
181
+ - **`dup` and `clone` give the copy its own `errors`.** The copy shared the original's `Errors` object, so `b = a.dup; b.name = ''` made `a.valid?` false too. The copy now starts with a copy of the original's messages and owns them; `clone` of a `frozen!` instance stays frozen, and `clone(freeze: false)` / `dup` give an independent mutable copy. The copy is still shallow: a nested struct is shared, so mutating `b.address` changes `a.address` too. <!-- upgrading: dup-own-errors -->
182
+ - **`Scaffold.from_json` output evaluates when keys need renaming.** It emitted `serialize :json, first_name: "firstName"` *before* the field lines, and `serialize` refuses undeclared fields, so the generated class raised `ArgumentError` the moment it was loaded. The mapping now follows the fields. Keys that normalize to the same name (`fooBar` and `foo_bar`, or `errors` and `errors_field`) get distinct fields (`foo_bar2`) instead of one silently overwriting the other.
183
+ - **`from_json` reverse-maps `serialize :json` names for struct members of a `:union`.** It reverse-mapped nested structs and arrays of them, but handed a union's object over with its external keys, so a struct member saw `{"cardNumber" => ...}` instead of `card_number:` and `Payment.from_json(payment.to_json)` came back invalid — breaking the README's round-trip promise. Members are now tried in declared order, as the union's own coercion does, with each struct member reverse-mapping through its own mapping; the first that accepts the object wins.
184
+ - **The field setter treats `RangeError` as a coercion failure.** `FloatDomainError` (one of its subclasses) and Complex-to-real conversion (`Complex(1, 2)` into `:integer` or `:float`) used to raise out of `.new`; they are now handled by `coercion_policy`, including when a custom type raises them. <!-- upgrading: range-error-policy -->
185
+
186
+ ### Docs
187
+
188
+ - **README corrections** (#8): `in:` with a Range is a bounds check via `cover?`,
189
+ and `:iso8601` round-trips as RFC 3339 rather than via `strptime`/`strftime`.
190
+ - **Install from GitHub.** The README installs from a release tag; the gem is
191
+ not on RubyGems yet.
192
+
193
+ ### Tooling / CI
194
+
195
+ - **Ruby 4.0 lane and one aggregate `ci` check.** The full battery now also
196
+ runs on Ruby 4.0, and a `ci` job passes only when every job passed;
197
+ `main` and `develop` require it.
198
+ - **The packaged gem leaves out contributor-only files** (`.claude/`,
199
+ `.claude-plugin/`, `CLAUDE.md`, `docs/origin/`, build and lint config).
200
+ - **The commit guard recognizes `git commit` by its command text** (#7).
201
+
202
+ ## [0.10.0] - 2026-09-03
203
+
204
+ Temporal types: one behaviour whether or not ActiveSupport is loaded, a
205
+ declared format that actually constrains, and an `iso8601` preset that means
206
+ RFC 3339.
207
+
208
+ The through-line is a single failure mode — **a wrong answer delivered
209
+ confidently**. A `:time` field accepted `"not-a-time"` as valid-nil under Rails,
210
+ read `2026-06-31` as 1 July everywhere, and a declared `format:` refused nothing
211
+ it was given. Each of those returned a value rather than an error, which is the
212
+ one outcome a caller cannot detect.
213
+
214
+ ### Breaking-ish (behaviour changes; no consumer declares a temporal field today)
215
+
216
+ - A blank string is a coercion failure for all three temporal types.
217
+ - A value that is neither a String nor a temporal object is refused.
218
+ - A declared `format:` anchors — trailing junk is refused.
219
+ - `:time` refuses a day that never existed, as `:date` and `:datetime` already did.
220
+ - With no declared format, a string must name a whole day.
221
+ - `format: :iso8601` is RFC 3339 in and out; its output is `Z`/`+HH:MM`, not `+0000`.
222
+
223
+ ### Changed
224
+
225
+ - **`describe` now shows what a field declares, not only what its type accepts.** It rendered the *type's* option vocabulary and nothing else, so a field declaring `format: :iso8601, enum: %w[USD EUR]` printed identically to one declaring nothing at all — the schema belongs to the type, and the declaration belongs to the field. Both are now on the line, declaration first:
226
+
227
+ ```
228
+ on (Date, required) — format: :iso8601 — accepts format (String | Symbol; presets: iso8601, us, eu), in (Array | Range)
229
+ ```
230
+
231
+ Options left at `nil` are omitted, so a temporal field with no declared format does not advertise the `format: nil` that `apply_default_format` puts there internally.
232
+
233
+
234
+ - **The `:iso8601` preset is now RFC 3339, in and out.** It was a strftime string, and that had it emitting a value its own documented format would reject: `%z` renders `+0000`, while RFC 3339 — which is what JSON Schema's `date-time` means — requires `+00:00` or `Z`. It also refused fractional seconds, which most JSON APIs emit, because no strftime string can express "optional fractional seconds".
235
+
236
+ FieldStruct now parses and renders the preset itself, via `Types::Rfc3339Format`. It is deliberately **not** `Time.iso8601` / `DateTime.iso8601`: those two disagree with each other on nearly every edge, and `Time.iso8601('2026-02-30T10:30:00Z')` returns **2 March** — the same silently-wrong-day defect this release exists to remove. A `:time` field and a `:datetime` field declaring the same preset now accept exactly the same strings, which was not previously true.
237
+
238
+ Accepted: full-width fields, `T` separator, whole seconds, optional fractional seconds, and a mandatory offset (`Z` or `±hh:mm`; `±hhmm` too, since that is what v0.9.0 emitted, so its own output still parses). Refused: a missing offset, a space separator, missing seconds, week and ordinal dates, the basic unseparated form, five-digit or signed years, hour 24, second 60, an impossible UTC offset, and any date that never existed. Output is `Z` for UTC and `+HH:MM` otherwise, with fractional seconds only when the value carries them.
239
+
240
+ **This is what lets a caller stop hand-rolling the contract.** `required :on, :date, format: :iso8601` now accepts and refuses exactly what `required :on, :string, format: /\A\d{4}-\d{2}-\d{2}\z/` plus a `Date.iso8601` validator does — verified input by input. The other presets (`:us`, `:eu`, `:db`, `:rfc2822`) are display and interchange formats rather than wire contracts, so they stay strftime-based; they still get anchoring and civil-date validity.
241
+
242
+ - **A declared `format:` now anchors, and every temporal type refuses a day that never existed.** Three rules the stdlib parsers left to their caller, each one a case where the stdlib answers a question it was not asked:
243
+ - **Anchoring.** `strptime` matches a *prefix* and discards the rest, so `Date.strptime('2026-07-031', '%Y-%m-%d')` returned the 3rd and threw the trailing `1` away, and `'2026-07-03T10:30:00+0000-NONSENSE'` parsed clean under the `iso8601` preset. A string with characters left over is now refused. **"Anchor" means trailing-junk rejection and nothing more** — widths, sign and case stay strptime-lenient for a hand-written format, because `%m` accepting one or two digits is strptime's contract rather than a defect.
244
+ - **Civil validity.** `Date` and `DateTime` already refused the 30th of February. **`:time` did not** — `Time.parse`, `Time.strptime` and `Time.new` all roll it forward to 2 March silently, so `required :at, :time` read `"2026-06-31 10:30:00"` as 1 July and reported the struct valid, in *both* lanes. All three types now refuse it.
245
+ - **Completeness.** With no declared format the stdlib parsers fill in what the string omits, from *today*: `Time.parse('10:30')` is today at 10:30, `'July'` is the 1st of July this year, `'12'` is the 12th of this month. A value whose meaning depends on when it was parsed is not a value, so a string with no declared format must now name a whole day. A *declared* format that names only part of one (`format: '%H:%M'`) is a deliberate choice and still works.
246
+
247
+ This does not make `Date.parse` strict about what counts as a day — `'v1.2.3'` still reads as 2001-02-03, because `Date._parse` finds `1.2.3` and all three components are present. The no-format path delegates to the stdlib by design; a caller who needs a narrow contract declares a format.
248
+
249
+ - **A declared `format:` is kept exactly as written.** `format: :iso8601` used to be overwritten at declaration time with the strftime String it expands to, so `metadata.to_h[:at][:options][:format]` reported `"%Y-%m-%dT%H:%M:%S%z"` and the preset *name* — the one fact a documentation or schema generator actually wants — was gone. Recovering it by reverse-mapping the String back to a name worked only while no two presets shared a value: luck, not a contract. The declaration now survives (`:iso8601` stays `:iso8601`, a hand-written String stays that String) and resolution to a strftime format happens at coerce and render time instead, via the new `Types::Base.resolve_format` and `TimeFormatResolver.resolve`. Parsing, serialization and round-tripping are unchanged; an unknown preset name still raises at declaration, not at first use.
250
+
251
+ ### Fixed
252
+
253
+ - **`in:` with a Range is a bounds check again.** Validation asked `Range#include?`, which *enumerates* a non-numeric range instead of comparing endpoints. For a `Date` range that was only wasteful — 365 successor steps to answer one comparison. For a **`DateTime` range it was wrong**: the range steps by whole days, so any value not landing exactly on midnight was reported invalid inside a range that plainly contained it. `(DateTime.new(2026,1,1)..DateTime.new(2026,12,31)).include?(DateTime.new(2026,7,3,10,30))` is `false`; `cover?` is `true`. Ranges now use `cover?`; an `Array` remains a membership test. No string-range behaviour changes, because the DSL refuses `in:` on String fields outright.
254
+
255
+ - **Temporal coercion no longer depends on whether ActiveSupport is loaded.** `Date`, `Time` and `DateTime` dispatched on `respond_to?(:to_date)` / `:to_time` / `:to_datetime` — predicates ActiveSupport adds to every core class, so the types took one branch in their own suite and a different one under Rails. Three defects followed, all measured on v0.9.0 under Rails 7.2:
256
+ - **`:time` accepted garbage as valid.** `optional :t, :time` given `"not-a-time"`, `"tomorrow at noon"` or `""` was **valid with a nil value**, because AS's `String#to_time` returns `nil` rather than raising. On plain Ruby all three were invalid.
257
+ - **Blank strings diverged.** `""` and `" "` were valid-nil under Rails and a coercion failure on plain Ruby.
258
+ - **Two-digit years diverged.** `"03 Jul 26"` was year **0026** under Rails (AS passes `comp=false` to `Date.parse`) and 2026 on plain Ruby.
259
+
260
+ Coercion now dispatches on explicit stdlib classes — `String`, then `DateTime` (before `Date`, being its subclass), then `Date`, then `Time` — and parses through `Date.parse` / `Time.parse` / `DateTime.parse` / `strptime`, none of which ActiveSupport redefines. Cross-class conversion is built from components rather than `to_time` / `to_datetime`, which ActiveSupport *does* redefine: under AS 7.2 `DateTime#to_time` shifts to the system-local zone and emits a deprecation warning, while 8.x preserves the offset. Building from parts yields the same instant and the same offset with no ActiveSupport, with 7.2, and with 8.x.
261
+
262
+ Two behaviour changes fall out, both of which restore the plain-Ruby answer: **a blank string is now a coercion failure** for all three types (as it already was for `:integer`, `:float`, `:big_decimal` and `:boolean` — only `:string` treats blank as missing), and **a value that is neither a String nor a temporal object is now refused** rather than run through `parse(value.to_s)`, which invented data: `Time.parse('[2026, 7, 3]')` returned *now*, `'v1.2.3'` read as 2001-02-03, and the Integer `20260703` parsed as a valid Date. That arm was already inconsistent — with a format declared, `:date` refused the Integer while `:time` accepted it.
263
+
264
+ - **`Date` now honours `format:` when parsing a String, as documented and as `DateTime` and `Time` already did.** `Types::Date#coerce` converted anything responding to `to_date` *before* consulting the format, which made the `strptime` branch unreachable for the exact input it exists for. ActiveSupport defines `String#to_date`, so **under Rails every string was parsed by `Date.parse` and the declared format was silently ignored** — an ambiguous date such as `07/03/2026` under `format: '%Y-%m-%d'` was accepted and read as 7 March instead of being refused. Plain Ruby has no `String#to_date`, which is why the type's own specs never took that branch and the defect survived. The `String`-with-`format` check now comes first, matching `DateTime` and `Time`; a non-string still converts through `to_date`, since a format describes how a *string* is read.
265
+
266
+ ### Tooling / CI
267
+
268
+ - **New ActiveSupport lane.** The suite now also runs against a host that has loaded ActiveSupport, on **7.2 and 8.1 pinned separately** (they differ on `to_time_preserves_timezone`, which changes what `DateTime#to_time` returns). Dev dependency only — the gemspec is untouched. The lane runs under a **non-UTC `TZ`** on purpose: two of the defects it exists to catch involve the process zone and one of them does not reproduce under `TZ=UTC`, so a UTC-only lane would hide a bug it was added to find. It also **raises on ActiveSupport deprecations** rather than warning, so code reaching for a deprecated conversion fails loudly instead of quietly returning the wrong zone. Set `FIELD_STRUCT_ACTIVESUPPORT=1` with `BUNDLE_GEMFILE=gemfiles/activesupport_7_2.gemfile` (or `_8_1`) to run it locally. This lane is the reason the defects above are now visible: without it, the gem's suite could only ever test the half of the world that does not load Rails.
269
+
270
+ - **Static guardrail against framework-redefinable dispatch.** `spec/guardrails_spec.rb` now fails if anything in `lib/` calls `respond_to?` with a predicate ActiveSupport defines or redefines on core classes (`to_date`, `to_time`, `to_datetime`, `as_json`, `blank?`, `try`, …), naming the offending file and line. One documented exception is allowlisted: `Base#json_value`'s terminal `as_json` arm, which is reached only for `:value`-typed fields and where the ActiveSupport outcome is the better one (a Struct serializes as `{"x":1,"y":2}` rather than `"#<struct ...>"`). The guard runs in milliseconds and catches a new probe at PR time; it does **not** replace the ActiveSupport lane, because AS changes semantics and not merely which predicates answer true.
271
+
272
+ ## [0.9.0] - 2026-06-03
273
+
274
+ Discoverability and toolchain hardening on top of v0.8.0's legibility work.
275
+
276
+ ### Added
277
+
278
+ - **`FieldStruct::Base.describe` / `Metadata#describe`** — a human/agent-readable schema summary: one line per field with its type, required-ness, and the native options that field's type accepts (value shapes, required options starred, presets listed). The readable companion to the structured `metadata.to_h` — answers "what does this model look like, and what can I put where" in one call.
279
+ - **`Types::Base.own_option_schema`** — the per-type hook for declaring a type's own options; `option_schema` merges it down the class chain.
280
+
281
+ ### Changed
282
+
283
+ - **`Types#option_schema` is frozen and memoized per type class.** A type's option vocabulary is fixed at load, so the inheritance merge is computed once instead of on every field declaration. Types now declare `own_option_schema` instead of `super.merge(...)`; the public `option_schema` return value is unchanged.
284
+
285
+ ### Documentation
286
+
287
+ - **YARD coverage to 100%** — documented the remaining constants, the `FieldStruct` / `FieldStruct::Types` modules, the per-type `resolve_options` overrides, `PresetResolver.call`, and the internal `inherited` / `canonicalize_serialized_value` hooks.
288
+ - README now surfaces `errors.full_messages`, the invalid-instance `inspect` output, and runtime option discovery (`Type.option_schema`) — the v0.8.0 surfaces that only `USAGE.md` / `AGENTS.md` had documented.
289
+
290
+ ### Tooling / CI
291
+
292
+ - **Sord-warning guard.** `sigs:generate` now fails when Sord can't resolve a YARD type and silently falls back to `untyped` — a degradation that was invisible to `sigs:check` (the committed file already carried the `untyped`). The usual cause is a bare stdlib name (`Symbol` / `Array`) colliding with `Types::Symbol` / `Types::Array`; the fix is to fully-qualify it. The affected `option_schema` annotations were qualified accordingly.
293
+ - **CI matrix widened** from a single Ruby (3.3.6) to **3.2 / 3.3 / 3.4** (full battery), plus a new **Ruby-floor job** that runs the library specs on **3.0 / 3.1** via a minimal `gemfiles/floor.gemfile` (no dev toolchain — `rbs`/`sord` need Ruby ≥3.2), proving the gem's advertised `>= 3.0` runtime floor. The one rbs-grammar spec self-skips where the gem is absent.
294
+
295
+ ## [0.8.0] - 2026-06-03
296
+
297
+ Legibility for humans and agents, made a first-class design invariant and
298
+ delivered across the error, inspection, and option surfaces.
299
+
300
+ ### Added
301
+
302
+ - **`FieldStruct::Errors#full_messages`** — renders each validation message as a complete sentence by prepending the humanized field name (`first_name` + `is required` → `"First name is required"`). Humanization is deliberately minimal (underscores to spaces, first character upcased — no inflector, no `_id` stripping, no i18n), so the transform stays predictable. Messages on `:base` (the cross-field convention) pass through unprefixed.
303
+ - **`Types::Base.option_schema`** — every type now declares its native field options in one place, as a frozen `{name => {type:, required:, presets:}}` hash, inherited via `super.merge` so subtypes pick up their parent's options (`:uuid` inherits `:string`'s `format:` / `enum:`). The type system can now answer "what options do I accept, of what shape, with what presets" at runtime, for both humans and agents, without reading source.
304
+ - **`Registry#type_classes`** — enumerates every distinct type class resolvable through the registry (local plus parent chain), backing the reserved-option-name computation and general discoverability.
305
+
306
+ ### Changed
307
+
308
+ - **Field-option validation is now schema-driven and three-way** (validate-native, pass-through-foreign), replacing the scattered `format:`/`enum:`/`in:` applicability checks. A native option with a wrong-shaped value (`round: '2'`) raises at class load naming the expected shape; a known option used on the wrong type (`enum:` on `:integer`, a misplaced `of:`) raises naming the types it *does* apply to; an unknown option is left untouched on the `Field` so downstream tooling (e.g. an Avro schema exporter) can carry its own options. The wrong-family error *messages* changed (e.g. "enum: option does not apply to Integer fields. It applies to: String, Symbol.").
309
+ - **`Base#inspect` surfaces validity.** An invalid instance now appends its per-field errors — `#<User name: "" age: 30 errors: {name: ["is required"]}>` — so the most-read debug output no longer hides invalidity. Valid instances are unchanged, byte for byte. `inspect` reads the current errors and never re-runs validation, so printing an instance can't mutate it.
310
+
311
+ ### Documentation
312
+
313
+ - Named **legibility for humans and agents** as design invariant 7 in `.claude/project_intent.md`, phrased operationally (the public surface must explain itself without source). Reconciled the source-of-truth docs with reality: `docs/origin/plan.md` is now framed as the frozen design-rationale record, `project_intent.md` carries a current Capabilities section, and `CLAUDE.md` re-points "source of truth" (current behavior → `USAGE.md`/`README.md`; rationale → `plan.md`). `USAGE.md` / `AGENTS.md` document the new runtime option discoverability and the inspect/error surfaces.
314
+
315
+ ## [0.7.1] - 2026-05-28
316
+
317
+ Documentation: an adoption guide, an agent router, and clearer inheritance semantics.
318
+
319
+ ### Added
320
+
321
+ - **`docs/getting_started.md`** — a getting-started / integration guide (general Ruby plus a Rails section) with a task→API map, an error→meaning→fix table, a worked end-to-end example, common mistakes, and an "agent toolbox". Its general-Ruby examples are executed by the doctest harness.
322
+ - **`AGENTS.md`** — a tool-agnostic router that points AI coding agents at the usage docs and the top gotchas (complements the Claude-specific skill; distinct from the contributor-facing `CLAUDE.md`).
323
+
324
+ ### Documentation
325
+
326
+ - Documented class-macro inheritance: `coercion_policy` / `unknown_attributes` / `immutable!` / `frozen!` inherit to subclasses and a child overrides them in isolation from the parent — with the asymmetry that `immutable!` / `frozen!` are one-way (a child can add but not un-set them). Covered in the README (a doctested example + table), `USAGE.md`, and the guide's common-mistakes list.
327
+
328
+ ## [0.7.0] - 2026-05-28
329
+
330
+ Two adoption-focused additions: a JSON-to-FieldStruct scaffolder for modeling existing APIs and webhooks, and a `FieldStruct.root` path helper.
331
+
332
+ ### Added
333
+
334
+ - **`FieldStruct::Scaffold.from_json`** — generates starter FieldStruct class source from a JSON example, or an array of same-shape examples. A *prototype you refine*: it reconstructs nesting (nested objects → nested classes emitted before their parent), maps arrays to `:array, of: …`, and types real JSON booleans/numbers, while keeping all strings as `:string` (a numeric-looking `authorization_code` must not become an Integer) and surfacing guesses as trailing comments. With multiple samples it resolves empty fields from other samples, drives `required`/`optional` from presence, and flags small repeated vocabularies as enum candidates. Non-identifier keys are snake-cased with a `serialize :json` mapping emitted. The developer adds enums, descriptions, and final scalar types — the comments point at where.
335
+ - **`FieldStruct.root`** — returns a `Pathname` to the library's root directory (the parent of `lib/`), computed from the entry file's location so it resolves to the repo root in development and to the installed gem directory when packaged.
336
+
337
+ ## [0.6.0] - 2026-05-28
338
+
339
+ Type accuracy at the call site and ergonomics for editors and AI assistants: accurate stdlib signatures (backed by an RBS collection), an RBS generator for *your* FieldStruct subclasses, a dense `USAGE.md`, a bundled Claude Code skill, a schema view on `Metadata`, and README/USAGE examples that are now executed as doctests.
340
+
341
+ ### Added
342
+
343
+ - **`FieldStruct::RBS.generate(klass)`** — emits RBS for the per-field accessors the `field` DSL defines on a subclass: a typed reader (nullability follows `required?`) and a permissive `(untyped) -> untyped` writer (the type coerces loose input). Handles nested structs, `:array` (`::Array[elem]`), `:union` (`(::A | ::B)`), `:boolean` (`bool`), `:value` (`untyped`), namespaced classes (module nesting), and inheritance (emits only own fields with `< ::Parent`). This is the deferred **D13 track 2** — Sord can't see these methods because they don't exist until you declare the fields. See the README "Type signatures" section for a Rake-task wiring example.
344
+ - **`Metadata#to_h`** — a copy-pasteable schema view keyed by field name (`type`, `ruby_type`, `required`, `default`, `options`, `description`), rendered as primitives/short names so you can `pp Klass.metadata.to_h` to see a model's shape without reading its source. The block form still delegates to `Enumerable#to_h` (so `Base#attributes` is unchanged).
345
+ - **`USAGE.md`** — a dense, example-first reference covering every type, field option, and class macro, plus validation/coercion semantics, JSON, nesting, registries, and gotchas. Ships in the gem package (`bundle show field_struct`) and is included in the YARD docs.
346
+ - **Claude Code skill** — `skills/field-struct/SKILL.md` gives an assistant the DSL, the common mistakes, and how to debug an invalid instance. The repo doubles as a single-plugin marketplace via `.claude-plugin/{plugin.json,marketplace.json}` (`/plugin marketplace add Paymentbox-com/field_struct`); the skill also ships in the gem for a no-marketplace copy into `.claude/skills/`.
347
+ - **RBS collection** — `rbs_collection.yaml` vendors the stdlib `date` / `time` / `bigdecimal` signatures so `sigs:validate` can resolve fully-qualified stdlib types. CI installs it and now runs the sig guards (`sigs:check` + `sigs:validate`), which the default `rake` task never exercised.
348
+ - **Doctested examples** — `spec/docs_examples_spec.rb` executes every ` ```ruby ` block in README.md / USAGE.md that is preceded by an invisible `<!-- doctest -->` marker, asserting `# =>` expectations by value comparison.
349
+
350
+ ### Changed
351
+
352
+ - **The generated sig now reports accurate stdlib return types.** `coerce` on the Date/Time/DateTime/BigDecimal types is typed `-> ::Date?` / `::Time?` / `::DateTime?` / `::BigDecimal?` instead of resolving to the FieldStruct *wrapper* classes. Core type names in the YARD docs are fully-qualified (`::String`, `::Symbol`, `::Integer`) so Sord resolves them cleanly — this clears 38 "wasn't able to be resolved to a constant" warnings.
353
+ - **`rake sigs:validate`** loads the RBS collection (and installs it on first run) instead of running `--no-collection`.
354
+
355
+ ### Notes
356
+
357
+ - The bundled plugin/marketplace manifests intentionally omit a pinned `version` (git-SHA tracked) to avoid drift from `version.rb`.
358
+
359
+ ## [0.5.5] - 2026-05-27
360
+
361
+ Documentation tooling: a `rake docs:generate` task that builds the YARD HTML, plus a `release:check` umbrella that runs the full pre-flight battery before every release.
362
+
363
+ ### Added
364
+
365
+ - **`rake docs:generate`** — builds YARD HTML to `doc/` with `--fail-on-warning`, so broken `{#method}` links and malformed tags fail the build instead of slipping into a release.
366
+ - **`rake docs:stats`** — `yard stats --list-undoc`; quick view of remaining coverage gaps.
367
+ - **`rake release:check`** — umbrella task that runs `spec`, `rubocop`, `sigs:check`, `sigs:validate`, and `docs:generate`. The pre-flight ritual before each release commit.
368
+ - **`.yardopts`** pinning the source list (`lib/**/*.rb`) and including `CHANGELOG.md` as an extra doc.
369
+ - `yard` added to the Gemfile as a direct dev dependency (it was already present transitively via `sord`).
370
+
371
+ ### Fixed
372
+
373
+ - A few YARD `{#field}` / `{#immutable!}` links inside `class << self` were unresolvable because the targets are class methods, not instance methods. Switched to the `{.field}` / `{.immutable!}` (class-method) link syntax so the strict YARD build is warning-free.
374
+
375
+ ## [0.5.4] - 2026-05-27
376
+
377
+ IRB-friendly inspect across every public object, plus an opt-in `FS` alias.
378
+
379
+ ### Changed
380
+
381
+ - **Custom `#inspect` / `#pretty_print` across every surface object.** The default reflection-based output dumped every ivar, making FieldStruct objects in IRB look like a wall of text. Each library object now renders concisely:
382
+
383
+ ```
384
+ # Field
385
+ #<FieldStruct::Field :level String enum=["beginner", "pro"]>
386
+ #<FieldStruct::Field :address Nested(Address) required>
387
+ #<FieldStruct::Field :payload Union(String | Integer)>
388
+
389
+ # Metadata — one-line inspect lists field names; pp fans out:
390
+ #<FieldStruct::Metadata fields=[:name, :age, :email]>
391
+ #<FieldStruct::Metadata
392
+ #<FieldStruct::Field :name String required>
393
+ #<FieldStruct::Field :age Integer default=0>
394
+ #<FieldStruct::Field :email String required format=/@/>>
395
+
396
+ # Type instances
397
+ #<FieldStruct::Types::String>
398
+ #<FieldStruct::Types::Nested struct_class=Address>
399
+ #<FieldStruct::Types::Union of=String | Integer>
400
+
401
+ # Errors / Registry
402
+ #<FieldStruct::Errors empty>
403
+ #<FieldStruct::Errors name=["can't be blank"]>
404
+ #<FieldStruct::Registry types=[:string, :integer, ...]>
405
+ ```
406
+
407
+ - **`field` / `required` / `optional` now return the class `Metadata`** instead of the just-added `Field`. The Field is still available as `metadata[name]`. This makes IRB DSL output show the running set of declared fields rather than just the last one, and feeds the new Metadata pretty-print directly. No FieldStruct internals depended on the previous return value.
408
+
409
+ ### Added
410
+
411
+ - **`FieldStruct.use_alias!`** — opt-in shortcut that defines a top-level constant (default `FS`) pointing back at `FieldStruct`, *and* swaps the prefix used by every `#inspect` method in the library so output reads with the short name too. Off by default. Raises `NameError` if the chosen constant is already taken by something else.
412
+
413
+ ```ruby
414
+ FieldStruct.use_alias!
415
+ FS::Base # => FieldStruct::Base
416
+ User.metadata
417
+ # => #<FS::Metadata
418
+ # #<FS::Field :name String required>
419
+ # #<FS::Field :age Integer>
420
+ # >
421
+ ```
422
+
423
+ ### Changed (continued)
424
+
425
+ - **`Metadata#pretty_print` closes `>` on its own line** so multi-field metadata reads as a tidy block rather than trailing the last field.
426
+
427
+ ## [0.5.3] - 2026-05-27
428
+
429
+ Sord-generated RBS signatures.
430
+
431
+ ### Added
432
+
433
+ - **RBS signatures (`sig/field_struct.rbs`).** Generated from YARD comments via Sord and shipped in the gem. Downstream tools (Solargraph, Steep, RBS-aware editors) get type info for the public surface without configuring anything. Three new rake tasks:
434
+ - `rake sigs:generate` — regenerate from current YARD
435
+ - `rake sigs:validate` — parse-check the committed sig file
436
+ - `rake sigs:check` — fail if the committed sigs are stale (CI guard)
437
+ - **`sord` and `rbs` as Gemfile development dependencies.** Both `require: false`.
438
+
439
+ The custom Metadata→RBS generator for *user-defined* FieldStruct subclasses (per D13 in the plan) remains deferred — Sord covers the library's hand-written classes; the user-class generator is its own future effort.
440
+
441
+ ## [0.5.2] - 2026-05-27
442
+
443
+ Field-level documentation metadata.
444
+
445
+ ### Added
446
+
447
+ - **Field-level `description:` (aliased as `desc:`).** Attach a human-readable description to any field for downstream documentation generators.
448
+
449
+ ```ruby
450
+ required :email, :string, description: 'Primary contact email'
451
+ required :age, :integer, desc: 'Age in years'
452
+ ```
453
+
454
+ Stored as a first-class attribute on `Field` (`field.description`, with `field.desc` as a reader alias) — not in `Field#options`. Documentation metadata only: does not appear in `attributes` / `as_json` / pattern matches. Passing both `description:` and `desc:` raises `ArgumentError`. Subclass re-declarations replace the inherited description (including back to `nil` if not provided).
455
+
456
+ ## [0.5.1] - 2026-05-27
457
+
458
+ Two internal refactors that set the gem up for an eventual RBS generator
459
+ and improve IDE / Sord / Solargraph readings of the public surface. No
460
+ behavior change.
461
+
462
+ ### Changed
463
+
464
+ - **`Type#coerce` signatures are now typed kwargs.** Each type's `coerce` method declares the options it consumes as named keyword arguments with a trailing `**` catch-all. The Hash-as-positional pattern is gone from the type contract; the loose base contract is `def coerce(value, **) = raise NotImplementedError` and each subclass narrows. Sets the gem up for an eventual RBS generator that emits useful per-type signatures.
465
+
466
+ ```ruby
467
+ # Before
468
+ def coerce(value, options = {})
469
+ fmt = options[:format] || self.class.default_format
470
+ end
471
+
472
+ # After
473
+ def coerce(value, format: self.class.default_format, **)
474
+ # fmt is bound directly
475
+ end
476
+ ```
477
+
478
+ The setter pipeline splats `field.options` at the call site (`coerce(value, **field.options)`), so the Hash storage on `Field` is unchanged. Existing call sites that pass options as kwargs are unaffected; the rare positional Hash needs explicit braces (`type.coerce({key: 'val'})`) to be the value, not options.
479
+
480
+ - **Internal DSL helpers are now functional**, returning new Hashes instead of mutating in place. `apply_default_format!`, `resolve_array_options!`, `build_union_instance!` → `apply_default_format`, `resolve_array_options`, `build_union_instance`. Callers update via reassignment in `Base.field`.
481
+
482
+ - **YARD `@param` types tightened library-wide.** Type coerce methods, value-object attrs (Field/Metadata), DSL macros (`field`, `coercion_policy`, `unknown_attributes`, `validate`), and the Errors family all now declare the actual accepted union types instead of generic `[Object]` / `[Hash]`. Duck-typed inputs use `#to_x` interface notation (`[Date, #to_date, String, nil]`). Strictly-bounded types name the exact set (`Symbol#coerce` is `[Symbol, String, nil]`). Documentation-only — zero behavior change.
483
+
484
+ ## [0.5.0] - 2026-05-27
485
+
486
+ Per-type field options + presets.
487
+
488
+ ### Added
489
+
490
+ - **`round:` option on `:big_decimal` / `:decimal` / `:float`** — Integer that controls `.round(n)` after coercion.
491
+ - **`values:` option on `:boolean`** — Hash `{truthy: [...], falsy: [...]}` for explicit vocabularies, or a Symbol preset. Built-in presets: `:english_yes_no`, `:english`, `:numeric`.
492
+ - **`format:` option on `:date` / `:datetime` / `:time`** — strftime/strptime String (or Symbol preset), applied in both directions: input strings parse via `strptime`, output via `strftime`. Built-in presets: `:iso8601`, `:rfc2822`, `:db`, plus `:us` / `:eu` for `:date`.
493
+ - **`format:` Symbol-preset support on `:email` / `:uuid` / `:url`** — the existing Regexp option now also accepts named presets. `:email` → `:permissive`/`:default`/`:strict`; `:uuid` → `:any_version`/`:v4`/`:v7`; `:url` → `:http`/`:https_only`/`:any_scheme`.
494
+ - **`Types::Base.resolve_options(options)` hook** — a per-type seam for resolving Symbol presets at field-declaration time. Pass-through by default; subclasses override to interpret their own options.
495
+ - **`Types::PresetResolver`** and **`Types::TimeFormatResolver`** — small helpers used by the built-in types; downstream type subclasses can reuse them.
496
+
497
+ ### Changed
498
+
499
+ - **`DEFAULT_FORMAT` / `TRUTHY_STRINGS` / `FALSEY_STRINGS` constants removed.** The defaults live on class methods (`default_format`, `default_truthy`, `default_falsy`) so subclasses can override with a one-line method instead of redeclaring a constant. Existing callers that reach for `Types::UUID::DEFAULT_FORMAT` etc. must call `Types::UUID.default_format` instead.
500
+ - **`format:` validator loosened** — previously restricted to string-shaped types. Time-shaped types (`:date`, `:time`, `:datetime`) are now also format-aware (with strftime/strptime semantics). Non-format-aware types still raise.
501
+
502
+ ## [0.4.0] - 2026-05-27
503
+
504
+ Serialization-formats redesign. Aliases are no longer a per-field
505
+ attribute; they're a class-level mapping declared via the new
506
+ `serialize` macro and consulted only by JSON I/O.
507
+
508
+ ### Added
509
+
510
+ - **`serialize` class macro.** Declares an import/export mapping for a named
511
+ format, stored on `Metadata#serializations`:
512
+
513
+ ```ruby
514
+ class User < FieldStruct::Base
515
+ required :first_name, :string
516
+ serialize :json, first_name: 'firstName'
517
+ end
518
+ ```
519
+
520
+ Internal-symbol on the left, external-string on the right; the same
521
+ mapping applies to both directions. Fields not listed use their
522
+ canonical name. Multi-format coexistence is allowed (`:json`, `:csv`,
523
+ …); only `:json` is wired in-gem (downstream gems can read
524
+ `Klass.metadata.serializations[:their_name]` for their own I/O).
525
+ Mapping keys must be declared fields — undeclared keys raise
526
+ `ArgumentError` at class load.
527
+
528
+ ### Changed
529
+
530
+ - **`as_json` / `to_json`** apply the `:json` mapping when one is declared
531
+ (identity otherwise). Round-trip via `Klass.from_json(instance.to_json)`
532
+ preserves structural equality.
533
+ - **`from_json`** reverse-maps the parsed hash recursively (through nested
534
+ FieldStructs and arrays of nested) before calling `.new`.
535
+
536
+ ### Removed (breaking)
537
+
538
+ - **`aliases:` field option** — per-field alias declarations are gone.
539
+ Lift them up to a `serialize :json, ...` block on the class.
540
+
541
+ ```ruby
542
+ # Before:
543
+ required :first_name, :string, aliases: ['firstName']
544
+
545
+ # After:
546
+ required :first_name, :string
547
+ serialize :json, first_name: 'firstName'
548
+ ```
549
+
550
+ - **`aliased: true` kwarg** on `attributes` / `to_h` / `as_json` /
551
+ `to_json` — no longer accepted. `to_json` already applies the
552
+ declared mapping; `to_h` / `attributes` stay canonical.
553
+ - **`Field#aliases`**, **`Field#export_name`**, and **`Metadata#field_for`**
554
+ removed from the public surface. `Metadata#[name]` is the canonical
555
+ lookup; lookup by external name is the serializer's concern.
556
+
557
+ ## [0.3.2] - 2026-05-27
558
+
559
+ Ruby pattern matching support.
560
+
561
+ ### Added
562
+
563
+ - **Pattern matching** — every FieldStruct instance now works as a Ruby 3.0+ pattern target. `deconstruct_keys(keys)` returns the canonical-name attribute hash (sliced down when keys is given, full when keys is `nil`); `deconstruct` returns field values in declaration order. Nested FieldStructs recurse naturally because every subclass inherits the same protocol. Aliases and `errors` do not participate (patterns match data, not validity, and use canonical names — the Ruby-side convention). Frozen and immutable instances work identically.
564
+
565
+ ## [0.3.1] - 2026-05-27
566
+
567
+ Two small ergonomics improvements on top of v0.3.0.
568
+
569
+ ### Added
570
+
571
+ - **Callable defaults** — `default:` now accepts a literal OR a parameterless callable (Proc, Lambda, Method, anything that responds to `#call`). Callables are invoked once per instance during apply_defaults; the return value flows through the setter pipeline like any other default. Safe for per-instance values like timestamps or generated IDs.
572
+ - **`FieldStruct.new_registry`** — shorthand for building a namespace registry. Defaults the parent to `FieldStruct.types`; an explicit `nil` parent gives an unparented registry. The block (optional) is evaluated in the new registry's instance scope, so `register :money, Acme::Money` works without a receiver.
573
+
574
+ ## [0.3.0] - 2026-05-27
575
+
576
+ Phase 2 wave two: union types, the `:binary` extended type, and the
577
+ `frozen!` class macro. All five Phase 2+ backlog items that belong
578
+ in-gem are now landed; RBS gen / format converters / docs generator
579
+ remain deferred to separate downstream gems.
580
+
581
+ ### Added
582
+
583
+ - **`:binary` extended type** — Types::Binary subclasses Types::String and forces ASCII-8BIT encoding on the coerced value. `missing?` is nil-or-empty only (whitespace bytes are meaningful). Designed for raw bytes / BLOBs.
584
+ - **`frozen!` class macro** — instances are Ruby-frozen at the end of `initialize`; subsequent ivar mutations raise `FrozenError`. Inherited by descendants. Independent of `immutable!` (which uses our custom `ImmutableError`). The two can stack.
585
+ - **Union types** — `optional :payload, :union, of: [Payload, :boolean]`. Each member type is tried in declared order; the first that doesn't raise wins. Member-coercion errors are caught broadly (ArgumentError, TypeError, FieldStruct::Error); unrelated bugs still propagate. If every member rejects, the union raises `TypeError` and the parent's `coercion_policy` engages. `ruby_type` returns a flat, deduplicated `Array<Class>` across all members. Declaration-time guards require `of:` to be an Array with at least two members.
586
+
587
+ ## [0.2.0] - 2026-05-27
588
+
589
+ Phase 2 first wave. Nested struct support, JSON import, field-name
590
+ aliases, cross-field validation, field-level coercion policy override,
591
+ extended scalar types (`:symbol`, `:uuid`, `:url`, `:email`), and
592
+ `enum:` / `in:` value-restriction field options.
593
+
594
+ ### Added
595
+
596
+ - **Nested FieldStructs** — declare `field :address, Address` where `Address < FieldStruct::Base`. Coerce nil / instance / Hash; eager `'is invalid'` stamp on the parent at assignment time; inner construction errors (`UnknownAttributeError`, `CoercionError`) propagate to the caller rather than being caught by the parent's `coercion_policy`. Arrays of nested work via `of:` (class or symbol form). `as_json` deep-walks. `Types::Nested` is the wrapping type; `Field` accepts a pre-built `type_instance:` so parameterized types flow through the same plumbing as stock types.
597
+ - **`Klass.from_json(string)`** — JSON import driven off Metadata. Parses with Oj, feeds through `initialize`, so coercion / nested construction / `unknown_attributes` / `coercion_policy` engage as normal. Non-object JSON roots raise `ArgumentError`; malformed JSON propagates the underlying parse error. Round-trips structurally for every base type except `:value`.
598
+ - **Extended scalar types** — `:symbol`, `:uuid`, `:url`, `:email`. The string-shaped trio (`:uuid` / `:url` / `:email`) subclasses `Types::String` and pre-fills the field's `format:` option from a class-level `default_format`. Users can override with `format: /.../`.
599
+ - **`enum:` and `in:` field options** — two parallel "value must be one of these" options. `enum: [...]` works on string-like types (`:string`, `:symbol`, and subclasses including `:uuid` / `:url` / `:email`). `in: [...]` or `in: range` works on rangy types (`:integer`, `:float`, `:decimal`, `:date`, `:time`, `:datetime`); accepts an Array or any Range. Both run post-coercion. Mismatch is a `'is invalid'` validation error, mirroring `format:`. Declaration-time guards reject the wrong combination.
600
+ - **Field-level `coercion_policy:` override** — `field :x, :integer, coercion_policy: :raise` overrides the class default for a single field. Falls back to the class macro when not specified. Unknown values raise `ArgumentError` at class load.
601
+ - **Cross-field validation** — `validate { |record| ... }` blocks and `validate :method_name` symbol form (interchangeable, can be mixed and stacked). Validators run at the end of `initialize` and on every `valid?` call. Convention is `errors.add(:base, '...')`; `errors[:base]` is cleared between cross-field runs, while field-level setter errors are preserved. Validators are inherited by subclasses (the subclass receives a `dup` of the parent's list and appends its own). Classes with no `validate` declarations keep Phase 1's cheap `valid? = errors.empty?` behavior.
602
+ - **Field-name aliases** — declare `field :email, :string, aliases: ['EmailAddress', 'email_address']` to bridge external naming conventions. Import accepts any declared alias (canonical wins on conflict); export opts in via `as_json(aliased: true)` / `to_json(aliased: true)` / `to_h(aliased: true)` / `attributes(aliased: true)` using each field's first alias. `unknown_attributes :raise` treats aliases as known. Aliases propagate through nested FieldStructs. No Ruby-side getter/setter methods are auto-defined for alias names — Ruby code always uses the canonical name. `Field#aliases` is a first-class attribute and `Metadata#field_for(name)` looks up by canonical-or-alias.
603
+
604
+ ## [0.1.0] - 2026-05-27
605
+
606
+ Initial release. Typed-PORO foundation: declared fields with enforced
607
+ types, presence checks, per-field validation, and an ActiveModel-shaped
608
+ public surface that reuses no ActiveModel code.
609
+
610
+ ### Added
611
+
612
+ - **DSL macros** — `field`, `required`, `optional` for declaring
613
+ attributes. Type is the second positional argument.
614
+ - **Class macros** — all inherited by descendants, all overridable:
615
+ - `coercion_policy :keep_raw | :replace | :raise` (default `:keep_raw`)
616
+ - `immutable!` — one-way switch; setters post-`initialize` raise
617
+ `FieldStruct::ImmutableError`
618
+ - `unknown_attributes :ignore | :raise` (default `:ignore`)
619
+ - **Base types** — `:string`, `:immutable_string`, `:integer`, `:float`,
620
+ `:big_decimal` (with `:decimal` alias), `:boolean`, `:date`, `:time`,
621
+ `:datetime`, `:value`, `:array` (with required `of:` element type).
622
+ - **Per-field validation on assignment** — every setter runs
623
+ `coerce → store → presence-check → format-check`, and the setter owns
624
+ its field's errors.
625
+ - **`format:` field option** for string-shaped fields; declaration-time
626
+ guard against use on non-string types.
627
+ - **Registry** — `FieldStruct::Registry` with parent-chain lookup;
628
+ `FieldStruct.types` is the seeded base. Namespaces extend by defining
629
+ a module-level `field_types` method.
630
+ - **Inheritance** — subclasses inherit parent metadata, parent
631
+ declarations are not mutated when a child adds or shadows a field.
632
+ - **AM-shaped public surface** (interface only, no AM code reuse):
633
+ `valid?`, `invalid?`, `errors`, `attributes`, `attribute_names`,
634
+ `assign_attributes`, `to_h`, `as_json`, `to_json` (via Oj), `inspect`,
635
+ `model_name`, `to_model`.
636
+ - **Structural equality** — `==` / `eql?` / `hash` over
637
+ `[class, attributes]`; errors ignored.
638
+ - **`FieldStruct::Errors`** value object — `[]`, `add`, `clear`,
639
+ `empty?`, `to_h`, `messages`.
640
+ - **Error classes** — `FieldStruct::Error` (base),
641
+ `FieldStruct::CoercionError`, `FieldStruct::ImmutableError`,
642
+ `FieldStruct::UnknownAttributeError`.
643
+ - **YARD comments** on the public surface.
644
+
645
+ ### Notes
646
+
647
+ - Boolean's `ruby_type` returns `[TrueClass, FalseClass]` because Ruby
648
+ has no Boolean class — the contract is "Class or Array<Class>"
649
+ (`Value#ruby_type` is `Object`).
650
+ - Array coercion is whole-value under `coercion_policy`: a single bad
651
+ element causes the array to be treated as one uncoercible value, and
652
+ the class's policy applies to that whole array. Per-element policy
653
+ is a Phase 2 candidate.