@orkestrel/scaffold 0.0.67 → 0.0.68

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 (74) hide show
  1. package/dist/bin/main.js +67 -44
  2. package/dist/bin/main.js.map +1 -1
  3. package/dist/host/agents/templates/brief.md +9 -0
  4. package/dist/host/claude/agents/orkestrel.md +4 -4
  5. package/dist/host/claude/rules/names.md +15 -0
  6. package/dist/host/claude/rules/tests.md +33 -4
  7. package/dist/host/claude/rules/workspace.md +14 -2
  8. package/dist/host/dotfiles/prettierignore +3 -0
  9. package/dist/host/guides/README.md +65 -0
  10. package/dist/host/guides/abort.md +169 -0
  11. package/dist/host/guides/agent.md +1509 -0
  12. package/dist/host/guides/brief.md +1266 -0
  13. package/dist/host/guides/browser.md +2200 -0
  14. package/dist/host/guides/budget.md +196 -0
  15. package/dist/host/guides/codec.md +519 -0
  16. package/dist/host/guides/console.md +785 -0
  17. package/dist/host/guides/contract.md +1193 -0
  18. package/dist/host/guides/csv.md +541 -0
  19. package/dist/host/guides/database.md +2518 -0
  20. package/dist/host/guides/emitter.md +233 -0
  21. package/dist/host/guides/form.md +1791 -0
  22. package/dist/host/guides/html.md +717 -0
  23. package/dist/host/guides/indexeddb.md +505 -0
  24. package/dist/host/guides/interpret.md +1029 -0
  25. package/dist/host/guides/lsp.md +515 -0
  26. package/dist/host/guides/markdown.md +964 -0
  27. package/dist/host/guides/mcp.md +5554 -0
  28. package/dist/host/guides/middleware.md +927 -0
  29. package/dist/host/guides/msg.md +440 -0
  30. package/dist/host/guides/ndjson.md +120 -0
  31. package/dist/host/guides/ollama.md +380 -0
  32. package/dist/host/guides/pool.md +280 -0
  33. package/dist/host/guides/probe.md +1210 -0
  34. package/dist/host/guides/process.md +1620 -0
  35. package/dist/host/guides/program.md +1110 -0
  36. package/dist/host/guides/qualifier.md +854 -0
  37. package/dist/host/guides/queue.md +370 -0
  38. package/dist/host/guides/rater.md +330 -0
  39. package/dist/host/guides/reason.md +1122 -0
  40. package/dist/host/guides/relation.md +373 -0
  41. package/dist/host/guides/router.md +753 -0
  42. package/dist/host/guides/scaffold.md +192 -31
  43. package/dist/host/guides/sea.md +383 -0
  44. package/dist/host/guides/server.md +752 -0
  45. package/dist/host/guides/sqlite.md +330 -0
  46. package/dist/host/guides/sse.md +187 -0
  47. package/dist/host/guides/supervisor.md +4890 -0
  48. package/dist/host/guides/table.md +1556 -0
  49. package/dist/host/guides/template.md +280 -0
  50. package/dist/host/guides/terminal.md +1145 -0
  51. package/dist/host/guides/test.md +2969 -0
  52. package/dist/host/guides/timeout.md +252 -0
  53. package/dist/host/guides/tool.md +311 -0
  54. package/dist/host/guides/toolbox.md +1038 -0
  55. package/dist/host/guides/websocket.md +282 -0
  56. package/dist/host/guides/worker.md +615 -0
  57. package/dist/host/guides/workflow.md +1507 -0
  58. package/dist/host/guides/workspace.md +595 -0
  59. package/dist/host/manifest.json +1218 -10
  60. package/dist/host/tests/policy.test.ts +279 -2
  61. package/dist/host/tests/setupPolicy.ts +437 -6
  62. package/dist/src/core/index.cjs +38 -16
  63. package/dist/src/core/index.cjs.map +1 -1
  64. package/dist/src/core/index.d.cts +33 -9
  65. package/dist/src/core/index.d.ts +33 -9
  66. package/dist/src/core/index.js +37 -17
  67. package/dist/src/core/index.js.map +1 -1
  68. package/dist/src/server/index.cjs +1750 -1567
  69. package/dist/src/server/index.cjs.map +1 -1
  70. package/dist/src/server/index.d.cts +106 -24
  71. package/dist/src/server/index.d.ts +106 -24
  72. package/dist/src/server/index.js +1751 -1570
  73. package/dist/src/server/index.js.map +1 -1
  74. package/package.json +3 -3
@@ -0,0 +1,1791 @@
1
+ # Form
2
+
3
+ > The environment-agnostic form document: a `FormSchema` stating what is asked, a `Form` holding
4
+ > the answers given against it, declarative `FieldRule` data stating what those answers must
5
+ > satisfy, and one submit that settles the form exactly once.
6
+
7
+ **A terminal prompt and a browser form are the same abstraction.** Both ask a person a set of
8
+ questions, hold partial answers, check them against rules, and finish once. What differs is the
9
+ host, and each host contributes the one part it owns. Parking is the server environment's
10
+ contribution: `answer` is a form whose result nobody has resolved yet, so a server can hand the
11
+ document out, wait, and receive the answers back through the same promise a local caller awaits.
12
+ Rendering is the browser's contribution, and it lives in the browser, not here. Nothing here
13
+ renders, reads a keyboard, or opens a socket. This package ships the document both hosts share.
14
+
15
+ The core is pure and total. Every guard returns `false` off-shape rather than throwing, every parser
16
+ returns `undefined` on refusal, and every value the form hands back is a frozen owned copy.
17
+ Form-owned refusals raise `FormError`, and each one names a caller mistake. A custom validator's own
18
+ throw escapes the mutation call unchanged.
19
+
20
+ ## Surface
21
+
22
+ Everything in this guide is exported from `@orkestrel/form` ([`src/core`](../src/core)). Nothing is
23
+ internal: every declaration in the module is reachable from the barrel, so a consumer holds exactly
24
+ the mechanisms the package uses on itself.
25
+
26
+ ### Open a form, answer it, and settle it
27
+
28
+ Builds a two-field sign-up form, fills both answers, submits, and awaits the settled result.
29
+
30
+ ```ts
31
+ import { createForm } from '@orkestrel/form'
32
+
33
+ const form = createForm({
34
+ label: 'Sign up',
35
+ fields: [
36
+ { control: 'text', name: 'email', label: 'Email', rule: { required: true, email: true } },
37
+ { control: 'confirm', name: 'terms', label: 'I accept the terms', rule: { required: true } },
38
+ ],
39
+ })
40
+
41
+ form.fill({ email: 'ada@example.com', terms: true })
42
+ const result = form.submit() // { success: true, value: { email: 'ada@example.com', terms: true } }
43
+ const answers = await form.answer // { email: 'ada@example.com', terms: true }
44
+ ```
45
+
46
+ ### Schema and fields
47
+
48
+ The document itself — what a form asks, in the order it asks it. All data, no behavior. Each
49
+ control's own interface adds its members to `FieldBase`, and the control values themselves are
50
+ worked through in [Controls](#controls).
51
+
52
+ A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an
53
+ optional member and `plus` introducing its call-signature members, and a type alias's own type
54
+ literal with a union's arms escaped as `\|`. An extended interface's name comes before `plus`,
55
+ with the members it adds after.
56
+
57
+ | API | Kind | Shape | Summary |
58
+ | --------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
59
+ | `FormSchema` | interface | `{ name?, label?, help?, groups?, fields }` | Describes everything a form asks. |
60
+ | `FormGroup` | interface | `{ name, label, help? }` | Represents a named section of a form. |
61
+ | `FormField` | type | `TextField \| EditorField \| PasswordField \| NumberField \| DateField \| TimeField \| DatetimeField \| ColorField \| ConfirmField \| SelectField \| CheckboxField \| FileField` | Represents any field a schema can declare. |
62
+ | `FieldBase` | interface | `{ name, label?, help?, group?, hidden?, disabled?, locked?, rule?, meta? }` | Declares what every field carries, whatever its control. |
63
+ | `FieldControl` | type | `'text' \| 'editor' \| 'password' \| 'number' \| 'date' \| 'time' \| 'datetime' \| 'color' \| 'confirm' \| 'select' \| 'checkbox' \| 'file'` | Names the control a field presents to the person answering it. |
64
+ | `FieldChoice` | interface | `{ value, label, help?, disabled? }` | Represents one option a `select` or `checkbox` field offers. |
65
+ | `TextField` | interface | `FieldBase plus { control, default?, placeholder? }` | Represents a single line of text. |
66
+ | `EditorField` | interface | `FieldBase plus { control, default?, placeholder? }` | Represents text over many lines. |
67
+ | `PasswordField` | interface | `FieldBase plus { control, mask? }` | Represents a secret, obscured as it is typed. |
68
+ | `NumberField` | interface | `FieldBase plus { control, default?, placeholder? }` | Represents a number. |
69
+ | `DateField` | interface | `FieldBase plus { control, default? }` | Represents a calendar date, held as the control's own `YYYY-MM-DD` string. |
70
+ | `TimeField` | interface | `FieldBase plus { control, default? }` | Represents a time of day, held as the control's own `HH:MM` string, with seconds optional. |
71
+ | `DatetimeField` | interface | `FieldBase plus { control, default? }` | Represents a date and a time of day together, with no zone, held as the control's own string. |
72
+ | `ColorField` | interface | `FieldBase plus { control, default? }` | Represents a color, held as the control's own six-digit `#rrggbb` string. |
73
+ | `ConfirmField` | interface | `FieldBase plus { control, default? }` | Represents a single on/off box, holding a boolean. |
74
+ | `SelectField` | interface | `FieldBase plus { control, choices, default?, open? }` | Represents one choice out of a list. |
75
+ | `CheckboxField` | interface | `FieldBase plus { control, choices, default? }` | Represents any number of choices out of a list, holding the checked values. |
76
+ | `FileField` | interface | `FieldBase plus { control, accept?, multiple? }` | Represents one or more files, by name. |
77
+
78
+ ### Answers and rules
79
+
80
+ What a form holds, what its answers must satisfy, and how a failure reports itself.
81
+
82
+ A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an
83
+ optional member and `plus` introducing its call-signature members, and a type alias's own type
84
+ literal with a union's arms escaped as `\|`.
85
+
86
+ | API | Kind | Shape | Summary |
87
+ | ------------------- | --------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
88
+ | `FieldValue` | type | `string \| number \| boolean \| readonly string[]` | Represents every value a field can hold. |
89
+ | `FormValues` | type | `Readonly<Record<string, FieldValue>>` | Represents a form's answers, keyed by field name. |
90
+ | `FieldRule` | interface | `{ required?, minimum?, maximum?, step?, pattern?, email?, url?, integer?, alphanumeric?, custom? }` | Represents the constraints one field's value must satisfy. |
91
+ | `FieldRuleName` | type | `Exclude<keyof FieldRule, 'custom'>` | Lists every rule that reports its failure by name. |
92
+ | `FieldValidator` | type | `(value: FieldValue \| undefined, values: FormValues) => true \| string` | Checks one value against the whole form. |
93
+ | `FieldError` | interface | `{ field, message, rule? }` | Represents one failed check against one field. |
94
+ | `EvaluationOptions` | interface | `{ messages?, disabled? }` | Describes how to check a schema against a set of answers. |
95
+
96
+ ### The form
97
+
98
+ The entity, its factory, its contract, and the error it raises.
99
+
100
+ A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an
101
+ optional member and `plus` introducing its call-signature members, and a type alias's own type
102
+ literal with a union's arms escaped as `\|`. A function row's `Shape` cell holds its signature,
103
+ and a guard row's the type it narrows to. A class row's `Shape` cell holds the interface it
104
+ implements, or its constructor signature where it implements none.
105
+
106
+ | API | Kind | Shape | Summary |
107
+ | --------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
108
+ | `Form` | class | `FormInterface` | Implements `FormInterface` exactly, over an owned schema, the answers given against it, and the errors they carry. |
109
+ | `FormInterface` | interface | `{ emitter, schema, values, baseline, errors, touched, disabled, status, valid, dirty, answer } plus field, fill, touch, invalidate, disable, enable, submit, clear, destroy` | Declares the contract a form exposes: the state it holds and the calls that move it. |
110
+ | `createForm` | function | `(schema: FormSchema, options?: FormOptions) => FormInterface` | Opens a form against a schema. |
111
+ | `FormOptions` | interface | `{ on?, error?, values?, messages? }` | Describes how to open a form. |
112
+ | `FormStatus` | type | `'editing' \| 'settled' \| 'abandoned'` | Represents where a form sits in its life. |
113
+ | `FormResult` | type | `Result<FormValues, readonly FieldError[]>` | Reports what a submit answers with: the values, or every error that stopped them. |
114
+ | `FormEventMap` | type | `{ fill, validate, disable, enable, submit, clear, abandon }` | Lists everything a form announces. |
115
+ | `FormError` | class | `new (code: FormErrorCode, message: string, context?: JSONRecord) => FormError` | Represents an error raised by the form domain. |
116
+ | `FormErrorCode` | type | `'SCHEMA' \| 'FIELD' \| 'CONTROL' \| 'SETTLED' \| 'ABANDONED'` | Names the machine-readable code a form error carries. |
117
+ | `isFormError` | function | `FormError` | Determines whether an unknown value is a form error. |
118
+
119
+ `FormInterface`'s readonly data members are the names in its `Shape` cell before `plus`, and they
120
+ stay here rather than in `## Methods`; the call-signature members after `plus` are documented under
121
+ [Methods](#methods).
122
+
123
+ ### Constants
124
+
125
+ The control and status registries, the permitted-member table each control is checked against, the
126
+ default rule copy, and the shipped patterns — every one of them frozen, so a shared `RegExp` cannot
127
+ be recompiled under a consumer. `EMAIL_PATTERN`, `URL_PATTERN`, `ALPHANUMERIC_PATTERN`, and
128
+ `INTEGER_PATTERN` are the tests behind the rules they are named for, `INTEGER_PATTERN` on a text
129
+ control; `COLOR_PATTERN`, `DATE_PATTERN`, `TIME_PATTERN`, and `DATETIME_PATTERN` are the shapes a
130
+ `color`, `date`, `time`, and `datetime` value must have. Each budget's row names its ceiling.
131
+ [Budgets](#budgets) then works each ceiling through beside the unit it counts, and
132
+ [Patterns and where trust lives](#patterns-and-where-trust-lives) does the same for `PATTERN_LIMIT`.
133
+
134
+ A `Shape` cell holds the constant's declared type.
135
+
136
+ | API | Kind | Shape | Summary |
137
+ | ---------------------- | ----- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
138
+ | `FIELD_CONTROLS` | const | `readonly FieldControl[]` | Lists every field control, in the order declared by the public contract. |
139
+ | `FIELD_BASE_KEYS` | const | `readonly string[]` | Lists the members every field declares, whatever its control. |
140
+ | `FIELD_KEYS` | const | `Readonly<Record<FieldControl, readonly string[]>>` | Lists every member one field control permits, composed from `FIELD_BASE_KEYS` and the members the control's own interface adds. |
141
+ | `FORM_STATUSES` | const | `readonly FormStatus[]` | Lists every form lifecycle status. |
142
+ | `RULE_MESSAGES` | const | `Readonly<Record<FieldRuleName, string>>` | Holds the default failure copy for every named field rule. |
143
+ | `EMAIL_PATTERN` | const | `Readonly<RegExp>` | Matches a practical whole-address email shape. |
144
+ | `URL_PATTERN` | const | `Readonly<RegExp>` | Matches an absolute HTTP or HTTPS URL shape. |
145
+ | `ALPHANUMERIC_PATTERN` | const | `Readonly<RegExp>` | Matches one or more ASCII letters or digits. |
146
+ | `INTEGER_PATTERN` | const | `Readonly<RegExp>` | Matches a signed or unsigned base-ten integer string. |
147
+ | `COLOR_PATTERN` | const | `Readonly<RegExp>` | Matches a six-digit hexadecimal color string. |
148
+ | `DATE_PATTERN` | const | `Readonly<RegExp>` | Matches an ISO calendar date string in `YYYY-MM-DD` form. |
149
+ | `TIME_PATTERN` | const | `Readonly<RegExp>` | Matches a 24-hour time string with optional seconds. |
150
+ | `DATETIME_PATTERN` | const | `Readonly<RegExp>` | Matches an ISO local date and time string with optional seconds. |
151
+ | `PATTERN_LIMIT` | const | `number` | Caps the accepted source length for an authored regular expression, at 256. |
152
+ | `FIELD_LIMIT` | const | `number` | Caps the number of fields one schema may declare, at 512. |
153
+ | `GROUP_LIMIT` | const | `number` | Caps the number of groups one schema may declare, at 64. |
154
+ | `CHOICE_LIMIT` | const | `number` | Caps the number of choices one `select` or `checkbox` field may offer, at 1024. |
155
+ | `LIST_LIMIT` | const | `number` | Caps the number of entries one list-valued answer may hold, at 1024. |
156
+ | `NAME_LIMIT` | const | `number` | Caps the length, in UTF-16 code units, of a schema, group, or field name, at 128. |
157
+ | `STRING_LIMIT` | const | `number` | Caps the length, in UTF-16 code units, of any single retained string, at 65536. |
158
+ | `TEXT_LIMIT` | const | `number` | Caps the total length, in UTF-16 code units, of every string one schema retains, at 1048576. |
159
+ | `NODE_LIMIT` | const | `number` | Caps the total number of records, arrays, and leaves one schema retains, at 16384. |
160
+
161
+ ### Guards
162
+
163
+ Total `is*` guards over unknown input. None throws, none coerces, and each returns `false` for
164
+ anything off-shape — including a hostile prototype, a symbol key, or a cyclic value. `isFieldValue`
165
+ also refuses a number that is not finite, so `NaN` and `Infinity` are not field values, and
166
+ `isFormSchema` reads structure alone: domain soundness is `auditSchema`'s question.
167
+
168
+ In a guard table a `Shape` cell holds the type the guard narrows to.
169
+
170
+ | API | Kind | Shape | Summary |
171
+ | ---------------- | -------- | -------------- | -------------------------------------------------------------------------- |
172
+ | `isFieldControl` | function | `FieldControl` | Determines whether an unknown value is a declared field control. |
173
+ | `isFormStatus` | function | `FormStatus` | Determines whether an unknown value is a form lifecycle status. |
174
+ | `isFieldValue` | function | `FieldValue` | Determines whether an unknown value has a form field value shape. |
175
+ | `isFieldChoice` | function | `FieldChoice` | Determines whether an unknown value is one exact field choice record. |
176
+ | `isFieldRule` | function | `FieldRule` | Determines whether an unknown value is one exact field rule record. |
177
+ | `isFormField` | function | `FormField` | Determines whether an unknown value is one exact discriminated form field. |
178
+ | `isFormGroup` | function | `FormGroup` | Determines whether an unknown value is one exact form group record. |
179
+ | `isFormSchema` | function | `FormSchema` | Determines whether an unknown value is one exact structural form schema. |
180
+ | `isFormValues` | function | `FormValues` | Determines whether an unknown value is a record of field values. |
181
+ | `isFieldError` | function | `FieldError` | Determines whether an unknown value is one exact field error record. |
182
+
183
+ ### Helpers
184
+
185
+ The pure leaves the form composes: the prototype-safe record write, `createFieldError`'s rule
186
+ failure builder, the control shape test, the evaluation engine, the derivations, and the wire
187
+ projection.
188
+
189
+ | API | Kind | Summary |
190
+ | ------------------ | -------- | ------------------------------------------------------------------------------- |
191
+ | `defineEntry` | function | Writes one own enumerable data property onto a record. |
192
+ | `freezeEntry` | function | Writes one own enumerable data property that cannot be rewritten or removed. |
193
+ | `matchesField` | function | Checks whether a value has the shape required by one field control. |
194
+ | `matchesAnswer` | function | Decides whether a raw binding value projects to an answered field. |
195
+ | `appliesRule` | function | Checks whether a named rule applies to one field control. |
196
+ | `evaluateField` | function | Evaluates one field rule against its current value. |
197
+ | `evaluateForm` | function | Evaluates every active field in schema order. |
198
+ | `computeDefaults` | function | Computes the values explicitly seeded by a schema. |
199
+ | `matchesValue` | function | Compares two field values by scalar identity or ordered list content. |
200
+ | `extractChanges` | function | Extracts the names whose answers differ between two form value records. |
201
+ | `matchesValues` | function | Compares two form value records by keys and value content. |
202
+ | `formatMessage` | function | Resolves and interpolates one rule message. |
203
+ | `createFieldError` | function | Creates one named-rule failure against a field. |
204
+ | `serializeForm` | function | Projects a schema into JSON while removing custom validators and absent values. |
205
+ | `extractGroups` | function | Selects referenced groups in first-reference field order. |
206
+ | `auditSchema` | function | Audits a structurally valid schema for domain invariants. |
207
+
208
+ ### Cloners
209
+
210
+ Owned frozen snapshots. The form takes one of the schema at construction, so a later edit to the
211
+ schema the caller passed changes nothing inside the form, and no list the form hands back is a live
212
+ internal reference.
213
+
214
+ | API | Kind | Summary |
215
+ | ----------------- | -------- | ------------------------------------------------------- |
216
+ | `cloneValue` | function | Clones one form value into an owned frozen snapshot. |
217
+ | `cloneChoices` | function | Clones a field's choices into an owned frozen snapshot. |
218
+ | `cloneFormField` | function | Clones one form field into an owned frozen snapshot. |
219
+ | `cloneFormSchema` | function | Clones a form schema into an owned frozen snapshot. |
220
+
221
+ ### Parsers
222
+
223
+ The wire boundary. Each returns `undefined` on refusal rather than throwing, and each returns an
224
+ owned value rather than the caller's.
225
+
226
+ | API | Kind | Summary |
227
+ | ------------- | -------- | ----------------------------------------------------------------------- |
228
+ | `parseForm` | function | Parses unknown wire data into an owned, semantically sound form schema. |
229
+ | `parseValue` | function | Parses one answer against its field control. |
230
+ | `parseValues` | function | Parses a strict answer record against the fields declared by a schema. |
231
+
232
+ ## Controls
233
+
234
+ Each control fixes both the options its field accepts and the `FieldValue` it holds. The `confirm`,
235
+ `checkbox`, and `datetime` mappings need saying out loud, because a host's vocabulary is wider than
236
+ this one and the collapses are deliberate:
237
+
238
+ - A lone browser checkbox is a `confirm`. It means yes or no and it holds a boolean.
239
+ - `checkbox` is the multi-choice group — the terminal's checkbox — and it holds the checked values
240
+ as a list. It is never one box.
241
+ - `datetime` is the browser's `datetime-local`: a wall-clock date and time carrying no zone.
242
+
243
+ Email and url are this package's `text` plus a rule, because they differ from text only in what they
244
+ accept: email is `text` with `{ email: true }`, and url is `text` with `{ url: true }`. Tel and
245
+ search are `text` with no rule of their own. A telephone number has no one shape this package could
246
+ assert across dialling plans, and search names an affordance rather than a constraint, so a schema
247
+ that wants a shape for either declares its own `pattern`.
248
+
249
+ A browser range is a `number` with `minimum`, `maximum`, and `step`. A radio group is a `select` and
250
+ a switch is a `confirm` — both are the same question wearing a different affordance, and which
251
+ affordance to draw is the renderer's decision. A datalist is a `select` with `open`, which is
252
+ exactly what "suggest these, accept anything" means.
253
+
254
+ | Control | Value | Notes |
255
+ | ---------- | ------------------- | -------------------------------------------------------------- |
256
+ | `text` | `string` | Carries email and url as rules, and tel and search as neither. |
257
+ | `editor` | `string` | Text over many lines. |
258
+ | `password` | `string` | No `default`: a seeded secret is a secret written down. |
259
+ | `number` | `number` | Also carries a range, as `minimum` plus `maximum` plus `step`. |
260
+ | `date` | `string` | `YYYY-MM-DD`. |
261
+ | `time` | `string` | `HH:MM`, seconds optional. |
262
+ | `datetime` | `string` | The browser's datetime-local, no zone. |
263
+ | `color` | `string` | `#rrggbb`, six digits. |
264
+ | `confirm` | `boolean` | A lone browser checkbox, and a switch. |
265
+ | `select` | `string` | A radio group, and a datalist when `open` is true. |
266
+ | `checkbox` | `readonly string[]` | The multi-choice group. |
267
+ | `file` | `readonly string[]` | Names only. Bytes never enter the document. |
268
+
269
+ ### text
270
+
271
+ Declares a `TextField` carrying a placeholder and a required-and-email rule.
272
+
273
+ ```ts
274
+ import type { TextField } from '@orkestrel/form'
275
+
276
+ const email: TextField = {
277
+ control: 'text',
278
+ name: 'email',
279
+ label: 'Email',
280
+ placeholder: 'you@example.com',
281
+ rule: { required: true, email: true },
282
+ }
283
+ ```
284
+
285
+ ### editor
286
+
287
+ Declares an `EditorField` bounded by a maximum-length rule.
288
+
289
+ ```ts
290
+ import type { EditorField } from '@orkestrel/form'
291
+
292
+ const bio: EditorField = {
293
+ control: 'editor',
294
+ name: 'bio',
295
+ label: 'About you',
296
+ rule: { maximum: 500 },
297
+ }
298
+ ```
299
+
300
+ ### password
301
+
302
+ `password` carries no `default`, so `computeDefaults` never seeds one. `mask` is the character the
303
+ control repeats in place of the text, and the form stores the real value untouched.
304
+
305
+ ```ts
306
+ import type { PasswordField } from '@orkestrel/form'
307
+
308
+ const secret: PasswordField = {
309
+ control: 'password',
310
+ name: 'secret',
311
+ label: 'Password',
312
+ mask: '*',
313
+ rule: { required: true, minimum: 12 },
314
+ }
315
+ ```
316
+
317
+ ### number
318
+
319
+ A browser range is this field with `minimum`, `maximum`, and `step` all set.
320
+
321
+ ```ts
322
+ import type { NumberField } from '@orkestrel/form'
323
+
324
+ const volume: NumberField = {
325
+ control: 'number',
326
+ name: 'volume',
327
+ label: 'Volume',
328
+ default: 5,
329
+ rule: { minimum: 0, maximum: 11, step: 1 },
330
+ }
331
+ ```
332
+
333
+ ### date
334
+
335
+ Declares a `DateField` bounded by a minimum and a maximum calendar date.
336
+
337
+ ```ts
338
+ import type { DateField } from '@orkestrel/form'
339
+
340
+ const start: DateField = {
341
+ control: 'date',
342
+ name: 'start',
343
+ label: 'Start date',
344
+ rule: { minimum: '2026-01-01', maximum: '2026-12-31' },
345
+ }
346
+ ```
347
+
348
+ ### time
349
+
350
+ Declares a `TimeField` with a default and a minimum-and-maximum time-of-day rule.
351
+
352
+ ```ts
353
+ import type { TimeField } from '@orkestrel/form'
354
+
355
+ const opens: TimeField = {
356
+ control: 'time',
357
+ name: 'opens',
358
+ label: 'Opening time',
359
+ default: '09:00',
360
+ rule: { minimum: '06:00', maximum: '22:00' },
361
+ }
362
+ ```
363
+
364
+ ### datetime
365
+
366
+ Declares a `DatetimeField` bounded by a minimum date and time.
367
+
368
+ ```ts
369
+ import type { DatetimeField } from '@orkestrel/form'
370
+
371
+ const slot: DatetimeField = {
372
+ control: 'datetime',
373
+ name: 'slot',
374
+ label: 'Appointment',
375
+ rule: { minimum: '2026-01-01T09:00' },
376
+ }
377
+ ```
378
+
379
+ ### color
380
+
381
+ Declares a `ColorField` seeded with a default six-digit color.
382
+
383
+ ```ts
384
+ import type { ColorField } from '@orkestrel/form'
385
+
386
+ const brand: ColorField = {
387
+ control: 'color',
388
+ name: 'brand',
389
+ label: 'Brand color',
390
+ default: '#3366ff',
391
+ }
392
+ ```
393
+
394
+ ### confirm
395
+
396
+ Declares a `ConfirmField` a submit refuses to pass until it is required and checked.
397
+
398
+ ```ts
399
+ import type { ConfirmField } from '@orkestrel/form'
400
+
401
+ const terms: ConfirmField = {
402
+ control: 'confirm',
403
+ name: 'terms',
404
+ label: 'I accept the terms',
405
+ rule: { required: true },
406
+ }
407
+ ```
408
+
409
+ ### select
410
+
411
+ `open` admits a value the list does not offer, which is what turns a closed menu into a suggestion
412
+ list. A choice marked `disabled` is shown and refused at every door, including seeded values.
413
+ Filter stored answers through `parseValues` or `parseValue` before seeding them; an `undefined`
414
+ result means the value is no longer legal. A closed, all-disabled select is unanswerable and faults
415
+ when required, whether or not the field itself is declared disabled; an open select and an optional
416
+ select are both legal.
417
+
418
+ ```ts
419
+ import type { SelectField } from '@orkestrel/form'
420
+
421
+ const plan: SelectField = {
422
+ control: 'select',
423
+ name: 'plan',
424
+ label: 'Plan',
425
+ choices: [
426
+ { value: 'free', label: 'Free' },
427
+ { value: 'pro', label: 'Pro', help: 'Everything in Free, plus support' },
428
+ { value: 'legacy', label: 'Legacy', disabled: true },
429
+ ],
430
+ default: 'free',
431
+ }
432
+ ```
433
+
434
+ ### checkbox
435
+
436
+ A checkbox value is the checked values as a list. Duplicates are refused, and `minimum` and
437
+ `maximum` count selections rather than characters. `required` is satisfied by any present answer,
438
+ including the empty list; an empty submission is a valid "none selected".
439
+
440
+ ```ts
441
+ import type { CheckboxField } from '@orkestrel/form'
442
+
443
+ const topics: CheckboxField = {
444
+ control: 'checkbox',
445
+ name: 'topics',
446
+ label: 'Interests',
447
+ choices: [
448
+ { value: 'releases', label: 'Releases' },
449
+ { value: 'security', label: 'Security' },
450
+ ],
451
+ default: ['releases'],
452
+ rule: { minimum: 1 },
453
+ }
454
+ ```
455
+
456
+ ### file
457
+
458
+ A file value is a list of names. `multiple` admits more than one, and without it a second name is
459
+ refused.
460
+
461
+ ```ts
462
+ import type { FileField } from '@orkestrel/form'
463
+
464
+ const documents: FileField = {
465
+ control: 'file',
466
+ name: 'documents',
467
+ label: 'Supporting documents',
468
+ accept: ['application/pdf', '.png'],
469
+ multiple: true,
470
+ rule: { maximum: 3 },
471
+ }
472
+ ```
473
+
474
+ ### meta
475
+
476
+ `meta` is not a control. It is the bounded JSON carrier every field has, on `FieldBase`, for
477
+ whatever the schema declines to model — an icon, a column width, an analytics key, a renderer hint.
478
+ The properties that follow define it, and together they are why it can be there at all.
479
+
480
+ **Evaluation never reads it.** No rule sees it, no error can come from it, and `evaluateForm` gives
481
+ the same answers whether it is present or absent. It is inert by construction, not by convention.
482
+
483
+ **It round-trips verbatim.** `serializeForm` writes it out and `parseForm` reads it back, key for
484
+ key and value for value, because it is already JSON. Nothing in this package rewrites, prunes, or
485
+ namespaces what a host put there.
486
+
487
+ **It is bounded JSON.** `isFormField` admits it only through `isBoundedJSONRecord`, so a cyclic
488
+ value, a value nested past that guard's depth bound, and anything that is not JSON — a function, a
489
+ symbol — each refuse the whole field. Depth is the guard's job; size is the audit's, and the
490
+ following budgets count `meta`'s strings and nodes. They count it a little more strictly than the
491
+ schema's own: a key inside `meta` counts against the text budget, and the schema's own keys —
492
+ `control`, `name`, `rule` — do not. The stricter side is the one the host controls, which is the
493
+ right way round.
494
+
495
+ The guard reads structure alone, so it admits one record that ownership then refuses: a `meta` whose
496
+ keys are accessors rather than data. That record is bounded JSON by shape, and `cloneFormField`
497
+ copies enumerable data properties only, so taking ownership of it throws `FormError` coded `SCHEMA`
498
+ naming the field. The constructor reaches that refusal through the same clone, which is why a field
499
+ `isFormField` accepted can still be refused when a form opens against it. `serializeForm` refuses
500
+ the same record the same way — `SCHEMA` naming the field — and `parseForm` answers it as every
501
+ refusal: `undefined`.
502
+
503
+ **This package defines no key in it.** Every key belongs to the host, so two hosts can carry
504
+ different vocabularies through the same document and neither collides with the package. A key this
505
+ package started reading would stop being the host's.
506
+
507
+ It is declared on fields only. The first consumer asked for a field carrier, and an exact guard
508
+ refuses `meta` on `FormSchema`, `FormGroup`, and `FieldChoice` rather than admitting a member
509
+ nothing reads. The form owns what it stores, so `form.field(name)` hands back a frozen
510
+ null-prototype copy rather than the caller's object.
511
+
512
+ ```ts
513
+ import {
514
+ createForm,
515
+ evaluateForm,
516
+ isFieldChoice,
517
+ isFormGroup,
518
+ parseForm,
519
+ serializeForm,
520
+ } from '@orkestrel/form'
521
+ import type { FormSchema } from '@orkestrel/form'
522
+
523
+ const schema: FormSchema = {
524
+ fields: [{ control: 'text', name: 'email', meta: { icon: 'mail', order: 2 } }],
525
+ }
526
+
527
+ evaluateForm(schema, {}) // [] — meta is never evaluated
528
+
529
+ const wire = JSON.stringify(serializeForm(schema))
530
+ wire // '{"fields":[{"control":"text","name":"email","meta":{"icon":"mail","order":2}}]}'
531
+ JSON.stringify(parseForm(JSON.parse(wire))) === wire // true — verbatim, both directions
532
+
533
+ isFormGroup({ name: 'account', label: 'Account', meta: {} }) // false — groups carry no meta
534
+ isFieldChoice({ value: 'a', label: 'A', meta: {} }) // false — nor do choices
535
+
536
+ const form = createForm(schema)
537
+ form.field('email')?.meta // { icon: 'mail', order: 2 } — an owned frozen copy
538
+ Object.getPrototypeOf(form.field('email')?.meta ?? {}) // null
539
+ ```
540
+
541
+ ### Rendering
542
+
543
+ A renderer picks an affordance from what a field asks a person for, not from the name its control
544
+ carries. The categories that decision is made in are the portable ones the `enterprise-bootstrap`
545
+ skill catalogs in its `references/inputs.md` file, which every scaffold target carries at
546
+ `.agents/skills/enterprise-bootstrap/references/inputs.md`. The category names in the
547
+ table are that catalog's own, and a category name is the join key: read the row here, then open the
548
+ section carrying that name there.
549
+
550
+ Every field owes the same things whatever its category: its `label`, its `help`, a message for each
551
+ `FieldError` it carries keyed by that error's `rule`, and the display policy over `touched` that
552
+ holds a message back until somebody has visited the field. `hidden`, `locked`, and `disabled`
553
+ change what is drawn in every category alike; see [The visibility switches](#the-visibility-switches)
554
+ for the obligation each one carries. The table states what a control owes on top of those.
555
+
556
+ | Control | Category | What moves it | What the renderer owes |
557
+ | ---------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
558
+ | `text` | One line of text | Nothing. `email`, `url`, and `pattern` narrow what the line accepts and leave the category where it is. | One line whatever the rule says: email, url, tel, and search are all this control. |
559
+ | `editor` | Text over many lines | Nothing. | The height of the box. The document states no row count. |
560
+ | `password` | A secret | Nothing. | Obscure the text as it is typed, repeating `mask` where the schema names one, and seed nothing: the control carries no `default`. |
561
+ | `number` | A number | `minimum`, `maximum`, and `step` all set — that is a number in a bounded range. | The bounded affordance only when `minimum`, `maximum`, and `step` are all set, and a plain number otherwise. |
562
+ | `date` | A date | Nothing. | The value is the control's own `YYYY-MM-DD` string. Reading a person's localized entry back into it is the binding's parse. |
563
+ | `time` | A time | Nothing. | The value is the control's own `HH:MM` string, seconds optional. |
564
+ | `datetime` | A date and time | Nothing. | The value carries no zone, so a renderer that shows one has invented it. |
565
+ | `color` | A color | Nothing. | The value is `#rrggbb`, six digits, whatever picker produced it. |
566
+ | `confirm` | One on/off answer | Nothing. | One box or a switch, and which of them to draw is the renderer's decision. |
567
+ | `select` | One of a few | `open` — that is one of many with an unlisted value admitted. A list longer than the renderer draws at once — that is one of many. | Show a choice marked `disabled` and refuse it, seeded values included. |
568
+ | `checkbox` | Any of a few | A list longer than the renderer draws at once — that is any of many. | Refuse a duplicate, and count selections rather than characters where `minimum` or `maximum` is set. |
569
+ | `file` | Files | Nothing. `multiple` changes how many names the value holds, not the category. | Names only, filtered by `accept` and bounded by `multiple`. Bytes never enter the document. |
570
+
571
+ The catalog's remaining categories have no control of their own here. A renderer draws each over a
572
+ control the document already carries, or leaves it outside the document, so a renderer never
573
+ invents a control:
574
+
575
+ - **A value picked from a searched list** — `select`, and `select` with `open` where the search must
576
+ admit a value the list does not offer. `choices` is data in the schema, so the search field, the
577
+ ranking, and the menu belong to the renderer, and a list fetched while somebody types is the host
578
+ building a new schema.
579
+ - **An ordered set of tags** — `checkbox` over a fixed vocabulary, whose value keeps the order it
580
+ was filled in. Tags a person types freely are outside the document: `checkbox` admits a listed
581
+ value only, and no control holds a list of unlisted ones.
582
+ - **A rating** — `select` over the fixed values, which is the radio group the catalog draws, or
583
+ `number` with `minimum`, `maximum`, and `step` where the scale is continuous.
584
+ - **A step in a sequence** — outside the document. A step indicator reports position rather than
585
+ holding an answer, and sequencing several forms is the host's work.
586
+
587
+ ## Rules
588
+
589
+ A rule is data, not a closure. That is what lets a schema cross a wire and validate on the other
590
+ side exactly as it validated here — with the single exception of `custom`, which is a function and
591
+ therefore does not travel.
592
+
593
+ | Rule | Operand | What it measures |
594
+ | -------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
595
+ | `required` | `true` | That an answer exists at all. Presence only: `''`, `[]`, `false`, and `0` are answers and satisfy it. |
596
+ | `minimum` | number or string | Characters for text, editor, and password; magnitude for number; chronology for `date`, `time`, and `datetime`; selections for checkbox and file. |
597
+ | `maximum` | number or string | The same measure as `minimum`, at the other end. |
598
+ | `step` | number | The interval a numeric value must land on, counted from `minimum` or from zero. Number only. |
599
+ | `pattern` | string | Regular-expression source the whole value must match. String-valued controls only. |
600
+ | `email` | `true` | That the whole value is an email address, per `EMAIL_PATTERN`. |
601
+ | `url` | `true` | That the whole value is an absolute HTTP or HTTPS URL, per `URL_PATTERN`. |
602
+ | `integer` | `true` | That a number has no fractional part, or that a string is a base-ten integer. |
603
+ | `alphanumeric` | `true` | That the whole value is ASCII letters and digits, per `ALPHANUMERIC_PATTERN`. |
604
+ | `custom` | `FieldValidator` | Anything the rest cannot say. It runs last, on an absent value too, and it is the only rule that sees the rest of the form. |
605
+
606
+ The operand's type follows the control family. `minimum` and `maximum` take a number wherever the
607
+ measure is a count or a magnitude, and take a string written in the control's own format wherever
608
+ the measure is chronology — `'2026-01-01'` for a date, `'09:00'` for a time. `auditSchema` refuses
609
+ the mismatch rather than letting it fail silently at evaluation time.
610
+
611
+ Bounds compare temporal strings lexically. Spell each operand and value at the same precision:
612
+ because seconds are optional, `'09:00'` sorts before `'09:00:00'`.
613
+
614
+ `step` is number-only. A temporal step is not in this package; see the concept inventory.
615
+
616
+ ```ts
617
+ import { evaluateField, formatMessage } from '@orkestrel/form'
618
+ import type { NumberField } from '@orkestrel/form'
619
+
620
+ const volume: NumberField = {
621
+ control: 'number',
622
+ name: 'volume',
623
+ rule: { minimum: 0, maximum: 11, step: 1 },
624
+ }
625
+
626
+ evaluateField(volume, 12, {})
627
+ // [{ field: 'volume', message: 'Must be at most 11', rule: 'maximum' }]
628
+ evaluateField(volume, 0.5, {})
629
+ // [{ field: 'volume', message: 'Must be a multiple of 1', rule: 'step' }]
630
+
631
+ formatMessage('minimum', 8) // 'Must be at least 8'
632
+ formatMessage('required', undefined, { required: 'We need this one' }) // 'We need this one'
633
+ ```
634
+
635
+ ### How an answer is counted
636
+
637
+ **`undefined` is the only absence.** A field is unanswered when `values` has no key for it, and
638
+ answered otherwise. `''`, a string of spaces, `[]`, `false`, and `0` are all answers, so `required`
639
+ is satisfied by every one of them. `required` asks whether an answer exists, never whether it says
640
+ anything: a length rule is `minimum`, and a "must be ticked" rule is `custom`.
641
+
642
+ **This is not HTML's model, and the difference is deliberate.** A browser fails `required` on the
643
+ exact empty string, so an empty text input is unanswered there and answered here. Neither model is
644
+ wrong — HTML has one input surface and this document has many — but a binding that wants HTML's
645
+ answer has to say so, and `matchesAnswer` is where it says it.
646
+
647
+ **Project at the binding, not in the rules.** `matchesAnswer` is the documented projection: it is
648
+ false for absence and for a string that is only whitespace, and true for every other value including
649
+ `[]`, `false`, and `0`. Fill through it, and the empty box arrives as absence:
650
+
651
+ ```ts
652
+ import { createForm, matchesAnswer } from '@orkestrel/form'
653
+
654
+ matchesAnswer(undefined) // false
655
+ matchesAnswer('') // false
656
+ matchesAnswer(' ') // false — whitespace alone is not an answer
657
+ matchesAnswer('ada') // true
658
+ matchesAnswer([]) // true — an empty list is an answered "none of them"
659
+ matchesAnswer(false) // true
660
+ matchesAnswer(0) // true
661
+
662
+ const form = createForm({
663
+ fields: [{ control: 'text', name: 'email', rule: { required: true } }],
664
+ })
665
+
666
+ // The binding's own line, once, wherever a raw control value arrives.
667
+ const raw = ' '
668
+ form.fill('email', matchesAnswer(raw) ? raw : undefined)
669
+
670
+ form.values.email // undefined — the projection cleared it
671
+ form.errors.length // 1 — and `required` then reports it
672
+ ```
673
+
674
+ Core evaluation does not use this projection. Keeping it at the binding is what lets one schema
675
+ serve a browser input that treats blank as empty and a terminal prompt that treats a bare return as
676
+ skipped, without the schema knowing which host it is on.
677
+
678
+ ### The custom seam
679
+
680
+ `custom` receives two arguments: the value the field holds — or `undefined` when nobody has answered
681
+ it — and every answer the form holds. The second is what makes a cross-field rule possible without a
682
+ second mechanism, because a confirmation field
683
+ reads its sibling directly. It returns `true` to pass, or the message explaining the failure, and
684
+ that message travels as a `FieldError` with no `rule`, because the failure belongs to no named rule.
685
+ A validator's own throw escapes the mutation call unchanged; only form-owned refusals are
686
+ `FormError`.
687
+
688
+ ```ts
689
+ import { evaluateField } from '@orkestrel/form'
690
+ import type { FieldValidator, PasswordField } from '@orkestrel/form'
691
+
692
+ const matches: FieldValidator = (value, values) =>
693
+ value === values.password ? true : 'Both passwords must match'
694
+
695
+ const again: PasswordField = { control: 'password', name: 'again', rule: { custom: matches } }
696
+
697
+ evaluateField(again, 'hunter3', { password: 'hunter2' })
698
+ // [{ field: 'again', message: 'Both passwords must match' }]
699
+ ```
700
+
701
+ **`custom` runs on an absent value too**, after every named rule, which is what makes "required once
702
+ the sibling says yes" expressible without a second mechanism. An unanswered field can therefore
703
+ carry a `required` message and this validator's own together, and each one is a separate
704
+ `FieldError`.
705
+
706
+ ```ts
707
+ import { evaluateField } from '@orkestrel/form'
708
+ import type { FieldValidator, TextField } from '@orkestrel/form'
709
+
710
+ const whenBusiness: FieldValidator = (value, values) =>
711
+ values.account === 'business' && value === undefined ? 'A VAT number is required' : true
712
+
713
+ const vat: TextField = { control: 'text', name: 'vat', rule: { custom: whenBusiness } }
714
+
715
+ evaluateField(vat, undefined, { account: 'business' })
716
+ // [{ field: 'vat', message: 'A VAT number is required' }]
717
+ evaluateField(vat, undefined, { account: 'personal' }) // []
718
+ ```
719
+
720
+ The same seam closes a list of addresses. `TextField` has no `multiple`, because that word already
721
+ means a list of file names on `FileField` and one word cannot hold two value shapes. A field that
722
+ takes several addresses is `text` plus a `custom` that splits the value and tests each part with the
723
+ exported `EMAIL_PATTERN` — the same pattern the `email` rule uses, so the two agree by construction.
724
+
725
+ ```ts
726
+ import { evaluateField, EMAIL_PATTERN } from '@orkestrel/form'
727
+ import type { FieldValidator, TextField } from '@orkestrel/form'
728
+
729
+ const addresses: FieldValidator = (value) =>
730
+ typeof value !== 'string' ||
731
+ value
732
+ .split(',')
733
+ .map((entry) => entry.trim())
734
+ .every((entry) => EMAIL_PATTERN.test(entry))
735
+ ? true
736
+ : 'Every address must be valid'
737
+
738
+ const to: TextField = { control: 'text', name: 'to', rule: { custom: addresses } }
739
+
740
+ evaluateField(to, 'ada@example.com, grace@example.com', {}) // []
741
+ evaluateField(to, 'ada@example.com, nope', {})
742
+ // [{ field: 'to', message: 'Every address must be valid' }]
743
+ ```
744
+
745
+ ### Messages
746
+
747
+ `FormOptions.messages` replaces a rule's default copy, keyed by `FieldRuleName`. `{limit}` in the
748
+ replacement is substituted with the rule's operand exactly as it is in `RULE_MESSAGES`. `custom` is
749
+ absent from `FieldRuleName` because a custom rule supplies its own message and nothing keyed by a
750
+ rule name would ever be read for it.
751
+
752
+ ### Patterns and where trust lives
753
+
754
+ `pattern` is authored regular-expression source, so it is the one rule that can carry an attack.
755
+ `PATTERN_LIMIT` bounds the source this package will compile, and the wire boundary keeps a schema
756
+ data only. Each is deliberate.
757
+
758
+ `PATTERN_LIMIT` is 256 characters. A longer source is never compiled: `auditSchema` reports it, so
759
+ `createForm` and `parseForm` both refuse the schema, and `evaluateField` fails the field on the
760
+ `pattern` rule rather than handing the source to `RegExp`.
761
+
762
+ A pattern within `PATTERN_LIMIT` can still backtrack catastrophically. This package applies no time
763
+ bound. Evaluating an untrusted pattern spends the caller's thread. The wire boundary remains data
764
+ only: `serializeForm` drops every `custom` validator on the way out, and `parseForm` drops every
765
+ `custom` member on the way in. Parse a peer's schema through `parseForm`, which refuses an over-long
766
+ or uncompilable pattern, and decide whether its remaining patterns are trusted before evaluation.
767
+
768
+ ```ts
769
+ import { auditSchema, evaluateField, PATTERN_LIMIT } from '@orkestrel/form'
770
+ import type { TextField } from '@orkestrel/form'
771
+
772
+ const long: TextField = {
773
+ control: 'text',
774
+ name: 'code',
775
+ rule: { pattern: 'a'.repeat(PATTERN_LIMIT + 1) },
776
+ }
777
+
778
+ auditSchema({ fields: [long] })
779
+ // ['Field "code" has a pattern longer than 256']
780
+ evaluateField(long, 'aaa', {})
781
+ // [{ field: 'code', message: 'Must match the required format', rule: 'pattern' }]
782
+ ```
783
+
784
+ ### Budgets
785
+
786
+ `PATTERN_LIMIT` is one of the budgets. The others bound how much a schema and its answers can be, so
787
+ a document that arrives from a wire cannot cost unbounded memory or unbounded scanning before
788
+ anything decides to trust it. Every one is exported, so a host can check against the same number the
789
+ package checks against.
790
+
791
+ | Constant | Value | Unit | Bounds |
792
+ | -------------- | ------- | ----------------------- | ----------------------------------------- |
793
+ | `FIELD_LIMIT` | 512 | fields | One schema's `fields` |
794
+ | `GROUP_LIMIT` | 64 | groups | One schema's `groups` |
795
+ | `CHOICE_LIMIT` | 1024 | choices | One `select` or `checkbox` field's list |
796
+ | `NAME_LIMIT` | 128 | UTF-16 code units | Each schema, group, and field name |
797
+ | `STRING_LIMIT` | 65536 | UTF-16 code units | Any one retained string |
798
+ | `TEXT_LIMIT` | 1048576 | UTF-16 code units | Every string one schema retains, together |
799
+ | `NODE_LIMIT` | 16384 | records, arrays, leaves | Everything one schema retains, together |
800
+ | `LIST_LIMIT` | 1024 | entries | One list-valued answer |
801
+
802
+ They bind at the schema door and the value door, and which door a limit sits at is the whole story.
803
+
804
+ **The schema door reports.** `auditSchema` counts fields, groups, choices, names, strings, total
805
+ text, and total nodes — `meta` included, since it is retained like everything else — and returns one
806
+ human diagnostic per breach, beside `PATTERN_LIMIT`'s. `createForm` throws `SCHEMA` carrying them
807
+ and `parseForm` refuses the schema, so no over-budget schema is ever held.
808
+
809
+ **The value door refuses.** `matchesField` checks `STRING_LIMIT` on any string and `LIST_LIMIT` on
810
+ any list before it consults the control, so the check happens **before any regular expression sees
811
+ the value**. `fill` and a seeded value throw `CONTROL`; `parseValue` and `parseValues` return
812
+ `undefined`.
813
+
814
+ `STRING_LIMIT` is the one that stands at both: the same ceiling holds a schema's own strings and an
815
+ answer's, so no string this package retains is longer than 65536 code units whichever way it
816
+ arrived.
817
+
818
+ The whole-schema ceilings are what make the arithmetic safe. Whatever the per-item limits admit, one
819
+ audited schema retains at most 1048576 string code units and at most 16384 nodes — roughly two
820
+ megabytes of text — so the worst case is `TEXT_LIMIT` and `NODE_LIMIT`, never the product of the
821
+ others.
822
+
823
+ Regular-expression time, a `custom` validator's own work, and the structural read at the parse door
824
+ stay unbounded, each for its own reason. Regular-expression **time** is not bounded here, exactly as
825
+ the "Guards are total and parsers refuse" invariant under [Contract](#contract) states: a source
826
+ within `PATTERN_LIMIT` can still backtrack catastrophically, and evaluating an untrusted pattern
827
+ spends the caller's thread. And `custom` is in-process code the schema's own author wrote, so it is
828
+ trusted like any other function the host calls; it does not cross the wire, and nothing here limits
829
+ what it does.
830
+
831
+ The structural **read** at the parse door is unbounded for a different reason, and refusing an
832
+ over-budget schema is where it shows. The budgets bound what a schema may **retain**, and they bound
833
+ how far the audit **walks** to name a fault: the field pass stops at `FIELD_LIMIT` and the node pass
834
+ stops at `NODE_LIMIT`, so a fault beyond either ceiling goes unnamed while the breached ceiling
835
+ itself is reported. They do not bound the read that happens before any of that. `parseForm` copies
836
+ and guards every field that arrived before the audit sees one of them, so a payload four times over
837
+ `FIELD_LIMIT` is read four times over and then refused. Bound the size of a payload at the transport
838
+ that delivers it, which is the only layer holding the bytes.
839
+
840
+ ```ts
841
+ import { auditSchema, matchesField, LIST_LIMIT, STRING_LIMIT } from '@orkestrel/form'
842
+ import type { CheckboxField } from '@orkestrel/form'
843
+
844
+ auditSchema({ fields: [{ control: 'text', name: 'n'.repeat(129) }] })
845
+ // ['Schema contains a name longer than 128']
846
+ auditSchema({ fields: [{ control: 'text', name: 'a', label: 'x'.repeat(STRING_LIMIT + 1) }] })
847
+ // ['Schema contains a string longer than 65536']
848
+
849
+ const topics: CheckboxField = {
850
+ control: 'checkbox',
851
+ name: 't',
852
+ choices: [{ value: 'a', label: 'A' }],
853
+ }
854
+
855
+ matchesField(
856
+ topics,
857
+ Array.from({ length: LIST_LIMIT + 1 }, () => 'a'),
858
+ ) // false — refused by count
859
+ matchesField({ control: 'text', name: 'a' }, 'x'.repeat(STRING_LIMIT + 1)) // false — before any regex
860
+ ```
861
+
862
+ ### Auditing a schema
863
+
864
+ `auditSchema` is the semantic pass that structural validation cannot do: duplicate names, a missing
865
+ group, a default its own control cannot hold, a rule on a control that cannot measure it, a minimum
866
+ above its maximum, an uncompilable pattern, or a breach of any named budget. It also reports the
867
+ bounds no answer could satisfy: a required closed `select` with no enabled choice, a `checkbox` whose
868
+ positive `minimum` exceeds its enabled-choice count, and a negative `maximum` on a control that
869
+ measures a length or a count — `text`, `editor`, `password`, `checkbox`, or `file`. A required
870
+ `checkbox` alone remains satisfiable because `[]` is a present answer, `maximum: 0` on a `text` is
871
+ satisfiable by `''`, and a negative `maximum` on a `number` is an ordinary value bound.
872
+
873
+ **Every one of those faults holds for every field, disabled or not.** `auditSchema` takes the schema
874
+ and nothing else: no satisfiability arm reads `FieldBase.disabled`, and no runtime `disable` or
875
+ `enable` can change a diagnostic. That is why a declared-disabled field earns no exemption — it can
876
+ be put back into play at any moment, so a field that would be unanswerable the instant it is enabled
877
+ is a fault the audit names while a schema editor can still fix it.
878
+
879
+ **What a passing audit proves is that list and nothing wider.** Each check is a fixed question about
880
+ the schema's own declarations, so a passing schema is free of those faults under every runtime
881
+ disabled set — none of them depends on one. It is not a proof that some answer set exists. `custom`
882
+ is a function, the audit never calls it, and a validator that refuses every value passes the audit
883
+ and fails at evaluation.
884
+
885
+ The audit runs inside `createForm` and inside `parseForm`, so a consumer rarely calls it directly —
886
+ but it is exported, because a schema editor wants the diagnostics before it constructs anything.
887
+
888
+ **Its returned strings are human diagnostics, not a stable machine contract.** Read them, show them,
889
+ log them. Do not branch on their text or parse a field name out of them: the wording is free to
890
+ change with the diagnostics, and only the emptiness of the list is a promise. Where a machine
891
+ outcome is what you need, use the guards, or use `parseForm` and read `undefined`.
892
+
893
+ ```ts
894
+ import { auditSchema } from '@orkestrel/form'
895
+
896
+ auditSchema({
897
+ fields: [
898
+ { control: 'text', name: 'a' },
899
+ { control: 'text', name: 'a' },
900
+ ],
901
+ })
902
+ // ['Field "a" is declared more than once']
903
+ auditSchema({ fields: [{ control: 'number', name: 'n', rule: { minimum: '3' } }] })
904
+ // ['Field "n" has a string minimum on number']
905
+ auditSchema({
906
+ fields: [
907
+ {
908
+ control: 'select',
909
+ name: 'plan',
910
+ disabled: true,
911
+ choices: [{ value: 'legacy', label: 'Legacy', disabled: true }],
912
+ rule: { required: true },
913
+ },
914
+ ],
915
+ })
916
+ // ['Field "plan" is required but offers no enabled choice'] — a declared-disabled field is no exemption
917
+ auditSchema({ fields: [{ control: 'text', name: 'code', rule: { maximum: -1 } }] })
918
+ // ['Field "code" has a negative maximum on text'] — no answer has a negative length
919
+ auditSchema({ fields: [{ control: 'text', name: 'email' }] }) // []
920
+ ```
921
+
922
+ ### The temporal patterns are lexical
923
+
924
+ `DATE_PATTERN`, `TIME_PATTERN`, and `DATETIME_PATTERN` check spelling, not calendars. They accept a
925
+ four-digit year, a month in 01–12, and a day in 01–31 — with no knowledge of month length and no
926
+ knowledge of leap years. `'2026-02-31'` is therefore a lexically valid `date` value, and this
927
+ package accepts it.
928
+
929
+ That is the boundary this package draws, and it draws it on purpose: a calendar is a host concern,
930
+ and the host that renders a date control already refuses an impossible day. Where a real calendar
931
+ date matters to your domain, add the check as a `custom` rule, which is exactly the seam it belongs
932
+ in.
933
+
934
+ ```ts
935
+ import { matchesField } from '@orkestrel/form'
936
+ import type { DateField } from '@orkestrel/form'
937
+
938
+ const when: DateField = { control: 'date', name: 'when' }
939
+
940
+ matchesField(when, '2026-02-31') // true — lexically valid, no calendar is consulted
941
+ matchesField(when, '2026-13-01') // false — month 13 is not spelled correctly
942
+ ```
943
+
944
+ ## Lifecycle and state
945
+
946
+ A form opens `editing`, turns `settled` on its first valid submit, and turns `abandoned` when it is
947
+ destroyed before settling. Both end states are terminal, and every write to a form in either one is
948
+ refused with a `FormError`. Every getter keeps answering afterwards.
949
+
950
+ A destroy requested while a mutation batch is open records the request, refuses every subsequent
951
+ write from that instant, and defers teardown until the outermost batch closes. The batch's own
952
+ outcome wins. If it settles the form, the form ends `settled`, `answer` resolves, and no `abandon`
953
+ is emitted. Teardown never advances into the batch, and the batch is never aborted or rolled back.
954
+ The pending request is private, unnamed state, so `FormStatus` gains no fourth member.
955
+
956
+ **There is no `check()`.** `errors` is computed at construction and after every mutation whose
957
+ evaluation completes, and the `validate` event fires exactly when that list's content changes. If a
958
+ custom validator throws mid-mutation, the throw escapes after earlier state changes and leaves the
959
+ previous error list in place. The "Errors are current after completed evaluation" invariant under
960
+ [Contract](#contract) states the exact partial-state boundary.
961
+
962
+ **`valid` and `dirty` are derived on read.** `valid` is true when `errors` is empty. `dirty` is true
963
+ once the answers differ from `baseline`, the ones the form opened with. Neither is stored, so
964
+ neither can drift.
965
+
966
+ **`touched` is the fields somebody has visited.** It is what lets a renderer withhold an error until
967
+ the person has had their turn at the field. A failed submit marks every enabled field touched, so
968
+ the errors the person has not reached yet become showable at exactly the moment they matter.
969
+
970
+ ```ts
971
+ import { createForm } from '@orkestrel/form'
972
+
973
+ const form = createForm({
974
+ fields: [
975
+ { control: 'text', name: 'email', rule: { required: true, email: true } },
976
+ { control: 'confirm', name: 'terms', rule: { required: true } },
977
+ ],
978
+ })
979
+
980
+ form.errors.length // 2 — current from the moment the form opens
981
+ form.valid // false
982
+ form.dirty // false
983
+ form.status // 'editing'
984
+
985
+ form.field('email')?.control // 'text'
986
+ form.touch('email')
987
+ form.touched.has('email') // true
988
+
989
+ form.fill('email', 'ada@example.com')
990
+ form.dirty // true
991
+ form.errors.length // 1
992
+
993
+ form.submit().success // false — `terms` is still unanswered
994
+ Array.from(form.touched) // ['email', 'terms'] — a failed submit touches every enabled field
995
+
996
+ form.fill('terms', true)
997
+ form.submit() // { success: true, value: { email: 'ada@example.com', terms: true } }
998
+ form.status // 'settled'
999
+ ```
1000
+
1001
+ ### The visibility switches
1002
+
1003
+ They differ in what they remove, and the difference is load-bearing.
1004
+
1005
+ | Switch | Renderer obligation | `fill` | Validated | Submitted |
1006
+ | ---------- | ---------------------------- | ------- | --------- | --------- |
1007
+ | `hidden` | omit | accepts | yes | yes |
1008
+ | `locked` | render without person edits | accepts | yes | yes |
1009
+ | `disabled` | omit or render without edits | accepts | no | no |
1010
+
1011
+ `hidden` keeps a field out of the rendered form while it still travels. `locked` renders it
1012
+ unwritable. `disabled` takes the field out of the form entirely: it is neither evaluated nor
1013
+ submitted, and its value may still appear in `values` so a renderer can show it.
1014
+ `fill` refuses none of `hidden`, `locked`, and `disabled`; they constrain rendering, evaluation,
1015
+ and submission, not programmatic writes.
1016
+
1017
+ `FieldBase.disabled` is the field's **declared, opening** state. `FormInterface.disabled` is the
1018
+ **current fact**, and the next section is how it moves.
1019
+
1020
+ ```ts
1021
+ import { createForm } from '@orkestrel/form'
1022
+
1023
+ const form = createForm({
1024
+ fields: [
1025
+ { control: 'text', name: 'email', rule: { required: true } },
1026
+ {
1027
+ control: 'text',
1028
+ name: 'legacy',
1029
+ disabled: true,
1030
+ default: 'kept',
1031
+ rule: { required: true, email: true },
1032
+ },
1033
+ ],
1034
+ })
1035
+
1036
+ form.values // { legacy: 'kept' } — present for a renderer
1037
+ form.errors.length // 1 — only `email`; the disabled field is not evaluated
1038
+
1039
+ form.fill('email', 'ada@example.com')
1040
+ form.submit() // { success: true, value: { email: 'ada@example.com' } } — `legacy` is not submitted
1041
+ ```
1042
+
1043
+ ### Taking a field out, and putting it back
1044
+
1045
+ `disable` and `enable` move a field between being in the form and being out of it, while the form is
1046
+ live. Each is one verb whose overloads take no argument for every field, one name for one field, or
1047
+ a list of names for those. There is no group overload, because a host expands a group in one line
1048
+ from the schema it already holds, and a group argument would be a second way to say the same thing.
1049
+
1050
+ ```ts
1051
+ import { createForm } from '@orkestrel/form'
1052
+
1053
+ const form = createForm({
1054
+ groups: [{ name: 'billing', label: 'Billing' }],
1055
+ fields: [
1056
+ { control: 'text', name: 'card', group: 'billing', rule: { required: true } },
1057
+ { control: 'text', name: 'zip', group: 'billing', rule: { required: true } },
1058
+ { control: 'text', name: 'email', rule: { required: true } },
1059
+ ],
1060
+ })
1061
+
1062
+ // A group, expanded by the host from the schema it already holds.
1063
+ const billing = form.schema.fields.filter((field) => field.group === 'billing')
1064
+ form.disable(billing.map((field) => field.name))
1065
+
1066
+ Array.from(form.disabled) // ['card', 'zip']
1067
+ form.errors.length // 1 — only `email` is still in the form
1068
+ ```
1069
+
1070
+ **The schema declares, the form decides.** `FieldBase.disabled` is what the schema said when the
1071
+ form opened. Each `disable` or `enable` records a runtime decision that sits over that declaration,
1072
+ and `form.disabled` is the declaration and the decision read together — the current fact, and the
1073
+ set every other part of the form reads: evaluation skips it, a submit leaves it out of the answers,
1074
+ and a failed submit does not touch it.
1075
+
1076
+ **A batch is all-or-nothing.** Every name in a list is checked against the schema before any field
1077
+ moves, so one unknown name throws `FormError` coded `FIELD` and the call changes nothing.
1078
+
1079
+ **Each announces only what moved.** `disable` and `enable` each fire once per field whose effective
1080
+ state actually changed, in the order the schema declares the fields. A call that moves nothing —
1081
+ disabling what is already out — returns before it writes anything: no event, no recompute, no
1082
+ overlay entry. `fill` is not the same. An unchanged answer suppresses the `fill` event, but the
1083
+ answer is still rewritten, the field's invalidation is still dropped, and the error list is still
1084
+ recomputed.
1085
+
1086
+ **An invalidation survives the trip.** A field's external failure is kept while it is out, withheld
1087
+ from `errors`, and reappears when it comes back. Disabling a field to skip its rules does not erase
1088
+ what a server told you about it.
1089
+
1090
+ **`clear` resets the overlay.** Clearing returns the form to how it opened, and the runtime decisions
1091
+ are part of that: `disabled` reads the schema's declarations again, beside the restored answers and
1092
+ the cleared `touched` set. **The `clear` event is the whole announcement of that reset**: the
1093
+ restored answers emit no `fill`, and the overlay reset emits no `disable` and no `enable`. A listener
1094
+ that maintains its own picture from events alone reads one `clear` as everything having gone back,
1095
+ rather than waiting for per-field news that never comes.
1096
+
1097
+ Both are writes, so a settled or abandoned form refuses them with `SETTLED` or `ABANDONED`. And
1098
+ because any declared-disabled field can be enabled at any moment, `auditSchema` holds every field to
1099
+ the same satisfiability standard whether it is declared disabled or not: it reads the schema alone,
1100
+ so no declaration and no runtime decision changes a diagnostic.
1101
+
1102
+ ```ts
1103
+ import { createForm } from '@orkestrel/form'
1104
+
1105
+ const moved: string[] = []
1106
+
1107
+ const form = createForm(
1108
+ {
1109
+ fields: [
1110
+ { control: 'text', name: 'email', rule: { required: true } },
1111
+ { control: 'text', name: 'nickname', rule: { required: true } },
1112
+ { control: 'text', name: 'legacy', disabled: true, default: 'kept' },
1113
+ ],
1114
+ },
1115
+ {
1116
+ on: {
1117
+ fill: (name) => moved.push(`fill ${name}`),
1118
+ disable: (name) => moved.push(`disable ${name}`),
1119
+ enable: (name) => moved.push(`enable ${name}`),
1120
+ },
1121
+ },
1122
+ )
1123
+
1124
+ Array.from(form.disabled) // ['legacy'] — the schema's declaration, before anything moves
1125
+ form.errors.length // 2 — `email` and `nickname`
1126
+
1127
+ form.disable('nickname')
1128
+ Array.from(form.disabled) // ['nickname', 'legacy'] — in schema order
1129
+ form.errors.length // 1 — a field that is out is not evaluated
1130
+ form.disable('nickname') // already out: nothing moves, nothing is announced
1131
+
1132
+ form.fill('email', 'ada@example.com')
1133
+ form.invalidate('email', 'That address is already registered')
1134
+ form.errors // [{ field: 'email', message: 'That address is already registered' }]
1135
+
1136
+ form.disable('email')
1137
+ form.errors // [] — the failure is held, not lost
1138
+ form.enable('email')
1139
+ form.errors // [{ field: 'email', message: 'That address is already registered' }]
1140
+
1141
+ try {
1142
+ form.disable(['email', 'nope'])
1143
+ } catch {
1144
+ // Coded FIELD. Every name is checked first, so `email` never left the form.
1145
+ }
1146
+ form.disabled.has('email') // false
1147
+
1148
+ form.clear()
1149
+ Array.from(form.disabled) // ['legacy'] — back to the declaration
1150
+ form.values // { legacy: 'kept' } — and the answers went back with it
1151
+ moved // ['disable nickname', 'fill email', 'disable email', 'enable email'] — `clear` added nothing
1152
+ ```
1153
+
1154
+ The same set travels to the pure helpers. `evaluateForm` takes `EvaluationOptions`, whose `disabled`
1155
+ **replaces** the schema's declarations rather than adding to them, because a live form always
1156
+ supplies its own current set, and the schema's declarations beside it would disagree.
1157
+
1158
+ ```ts
1159
+ import { evaluateForm } from '@orkestrel/form'
1160
+ import type { FormSchema } from '@orkestrel/form'
1161
+
1162
+ const schema: FormSchema = {
1163
+ fields: [
1164
+ { control: 'text', name: 'card', rule: { required: true } },
1165
+ { control: 'text', name: 'email', rule: { required: true } },
1166
+ ],
1167
+ }
1168
+
1169
+ evaluateForm(schema, {}, { disabled: new Set(['card']) })
1170
+ // [{ field: 'email', message: 'This field is required', rule: 'required' }]
1171
+ evaluateForm(schema, {}, { messages: { required: 'Needed' }, disabled: new Set(['card']) })
1172
+ // [{ field: 'email', message: 'Needed', rule: 'required' }]
1173
+ ```
1174
+
1175
+ ### Filling, clearing, and failing from outside
1176
+
1177
+ `fill` takes either one name and one value, or a whole record. Every answer is checked before any is
1178
+ written, so a refused write changes nothing. Passing `undefined` clears one field.
1179
+
1180
+ `invalidate` fails a field for a reason the rules cannot see — an address already registered, a
1181
+ coupon already spent. One field holds one external failure, a second call replaces the first, and
1182
+ the failure lasts until that field is filled again or the form is cleared.
1183
+
1184
+ `baseline` is those opening answers, held as a value: the schema's defaults overlaid with any seeded
1185
+ `values`, fixed when the form opens and never moved again. It is what `dirty` measures against and
1186
+ what `clear` returns to, so a host that wants to know _which_ answers moved — not merely that one
1187
+ did — reads `extractChanges(form.values, form.baseline)` and gets the names.
1188
+
1189
+ `clear` returns every answer to `baseline`. It also clears `touched`, every external failure, and
1190
+ every runtime `disable` or `enable`, so the form reads exactly as it opened.
1191
+
1192
+ ```ts
1193
+ import { createForm, extractChanges } from '@orkestrel/form'
1194
+
1195
+ const form = createForm({
1196
+ fields: [
1197
+ { control: 'text', name: 'email', rule: { required: true, email: true } },
1198
+ {
1199
+ control: 'select',
1200
+ name: 'plan',
1201
+ choices: [{ value: 'free', label: 'Free' }],
1202
+ default: 'free',
1203
+ },
1204
+ ],
1205
+ })
1206
+
1207
+ form.baseline // { plan: 'free' } — the answers it opened with, fixed for its whole life
1208
+
1209
+ form.fill('email', 'ada@example.com')
1210
+ form.valid // true
1211
+ form.dirty // true
1212
+ Array.from(extractChanges(form.values, form.baseline)) // ['email'] — which answer moved, by name
1213
+
1214
+ form.invalidate('email', 'That address is already registered')
1215
+ form.errors // [{ field: 'email', message: 'That address is already registered' }]
1216
+ form.valid // false
1217
+
1218
+ form.fill('email', 'grace@example.com')
1219
+ form.errors // [] — refilling the field clears its external failure
1220
+
1221
+ form.clear()
1222
+ form.values // { plan: 'free' } — back to the answers the form opened with
1223
+ form.dirty // false
1224
+ ```
1225
+
1226
+ ### Park-as-Promise: `answer`
1227
+
1228
+ `answer` is the form's whole point on a server. It resolves with the submitted values on the first
1229
+ valid submit, and rejects with a `FormError` coded `ABANDONED` when teardown abandons the form
1230
+ before it settles. One task can await it while an entirely different task fills and submits the
1231
+ form, which is what a parked question looks like when a promise is the only thing that has to cross
1232
+ between them.
1233
+
1234
+ Nothing has to await it. An unawaited form that is destroyed does not take the host down with it.
1235
+
1236
+ ```ts
1237
+ import { createForm } from '@orkestrel/form'
1238
+
1239
+ const form = createForm({ fields: [{ control: 'text', name: 'name', rule: { required: true } }] })
1240
+
1241
+ // One task parks on the answer.
1242
+ const parked = form.answer
1243
+
1244
+ // Another task — a request handler, a socket message, a keyboard — supplies it.
1245
+ form.fill('name', 'Ada')
1246
+ form.submit()
1247
+
1248
+ await parked // { name: 'Ada' }
1249
+ ```
1250
+
1251
+ ### Abandoning a parked answer
1252
+
1253
+ Destroying a form before it settles rejects every parked `answer` with a `FormError` coded
1254
+ `ABANDONED`, which the parked task recovers through `isFormError`.
1255
+
1256
+ ```ts
1257
+ import { createForm, isFormError } from '@orkestrel/form'
1258
+
1259
+ const abandoned = createForm({ fields: [{ control: 'text', name: 'name' }] })
1260
+ const pending = abandoned.answer
1261
+
1262
+ abandoned.destroy()
1263
+ abandoned.status // 'abandoned'
1264
+
1265
+ try {
1266
+ await pending
1267
+ } catch (error) {
1268
+ if (isFormError(error)) error.code // 'ABANDONED'
1269
+ }
1270
+ ```
1271
+
1272
+ ### Settle once
1273
+
1274
+ The first valid submit is the only one. It resolves `answer`, emits `submit`, sets `status` to
1275
+ `settled`, and every later write — `fill`, `touch`, `invalidate`, `disable`, `enable`, `submit`,
1276
+ `clear` — throws a `FormError` coded `SETTLED`. A failed submit settles nothing and leaves the form
1277
+ open.
1278
+
1279
+ `destroy` tears the form down. Destroying twice does nothing the second time. A form that already
1280
+ settled keeps its `settled` status and announces nothing. An editing form turns `abandoned`, rejects
1281
+ `answer`, and emits `abandon` unless the request was deferred behind a mutation batch that settles
1282
+ before teardown.
1283
+
1284
+ ### The submit decision
1285
+
1286
+ A `validate` listener can write to the form while a submit is deciding. The rules that follow say
1287
+ what the submit does about it.
1288
+
1289
+ **A submit that changes the error list announces before it decides.** Its own evaluation moves that
1290
+ list only when a `custom` validator answers differently than it did at the last mutation — every
1291
+ other rule reads state that only a mutation changes, and every mutation already recomputed. When it
1292
+ does move, `validate` fires and the listeners run; if any of them wrote, the submit evaluates once
1293
+ more before deciding.
1294
+ That is one further evaluation, not a loop until nothing changes.
1295
+
1296
+ **One submit can therefore emit `validate` more than once.** A listener that fills, invalidates,
1297
+ disables, or enables announces its own change as it makes it, so a host counting emissions inside one
1298
+ submit can see more than one.
1299
+
1300
+ **A refusal is the list checked at the decision, not a view of the form.** An evaluation that already
1301
+ failed stays the answer even when a listener then repairs or disables the field that failed. So
1302
+ `submit()` can return `{ success: false, error: [...] }` while `form.errors` reads `[]` and `valid`
1303
+ reads `true` the line after. The returned result is what the submit decided; the form is what the
1304
+ form holds now.
1305
+
1306
+ **A settlement made during listener work wins.** A listener that repairs the form and calls `submit`
1307
+ itself settles it, and that settlement is what the outer call returns — one `submit` event, one
1308
+ resolved `answer`, and no evaluation after it.
1309
+
1310
+ ### Retrying a submit
1311
+
1312
+ **`submit` is the commit, not the attempt.** It is the moment this document is finished with, which
1313
+ is why it settles the form and why nothing may be written afterwards. A network request that can be
1314
+ refused and tried again is a different act, it belongs to the host, and it happens **before**
1315
+ `submit` — never inside it.
1316
+
1317
+ That gives one sequence, and it is short:
1318
+
1319
+ 1. **Ask the form the synchronous question.** A submit that fails settles nothing, marks every
1320
+ enabled field touched, and leaves `status` at `editing`. Calling it for exactly that is correct
1321
+ and repeatable — sync errors are the case a local failed submit is for. Read the errors off the
1322
+ returned result rather than off the form: with a `validate` listener that writes, the returned
1323
+ result and the form can already disagree, as the preceding section sets out.
1324
+ 2. **Run the attempt against `values`.** The request is the host's: its own timeout, its own retry
1325
+ count, its own backoff. The form is not involved and knows nothing about it.
1326
+ 3. **Report a refusal through `invalidate`, not through a submit.** The message lands as a
1327
+ `FieldError` on the field it belongs to, `valid` turns false, and the form is still open for the
1328
+ person to fix it and for the host to try again.
1329
+ 4. **Call `submit` only once the attempt succeeded.** That commits: `answer` resolves, `submit`
1330
+ fires, and the form is terminal.
1331
+
1332
+ **`idle`, `submitting`, `succeeded`, and `failed` are the host's request states, not the form's.**
1333
+ `FormStatus` has a member for each of a document's fates — being answered, finished, abandoned —
1334
+ and a retried request has none of them. A host that needs those states holds them beside the form,
1335
+ in whatever it already uses for in-flight requests, so the form never gains a status meaning a
1336
+ request about it is in the air.
1337
+
1338
+ ```ts
1339
+ import { createForm } from '@orkestrel/form'
1340
+
1341
+ const form = createForm({
1342
+ fields: [{ control: 'text', name: 'email', rule: { required: true, email: true } }],
1343
+ })
1344
+
1345
+ // 1. The synchronous question. Nothing settles, and every enabled field is now touched.
1346
+ form.submit().success // false
1347
+ form.status // 'editing'
1348
+ Array.from(form.touched) // ['email']
1349
+
1350
+ form.fill('email', 'ada@example.com')
1351
+
1352
+ // 2. The host's own attempt, against the answers the form holds. A real one is a request; this
1353
+ // one decides locally so the example runs.
1354
+ const refused = form.values.email === 'ada@example.com'
1355
+
1356
+ // 3. A refusal comes back as an invalidation. The form stays open and retryable.
1357
+ if (refused) form.invalidate('email', 'That address is already registered')
1358
+ form.valid // false
1359
+ form.status // 'editing'
1360
+
1361
+ // 4. The attempt that succeeds is the one that commits.
1362
+ form.fill('email', 'grace@example.com')
1363
+ form.submit().success // true
1364
+ form.status // 'settled'
1365
+ ```
1366
+
1367
+ ## Events
1368
+
1369
+ Each event carries what a listener needs to act without reading the form back.
1370
+
1371
+ | Event | Payload | Fires |
1372
+ | ---------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1373
+ | `fill` | the field's `name`, and its new value | Once per field whose answer actually moved, in the order written. The value is `undefined` when the answer was cleared. A `clear` is the exception; see its row. |
1374
+ | `validate` | every current `FieldError` | Whenever the error list's content changes — after a fill, an invalidate, a disable, an enable, a clear, or a submit. Empty when the change was to no errors at all. One submit can fire it more than once: the submit announces its own change, and a listener that writes announces that change too. |
1375
+ | `disable` | the field's `name` | Once per field taken out of the form, in schema order. A call that moves nothing announces nothing. A `clear` is the exception; see its row. |
1376
+ | `enable` | the field's `name` | Once per field put back into the form, in schema order. A call that moves nothing announces nothing. A `clear` is the exception; see its row. |
1377
+ | `submit` | the submitted `FormValues` | On the submit that settles the form, and only that one. |
1378
+ | `clear` | nothing | On a completed `clear`, before any `validate` it caused. `clear` is the whole announcement of the reset: restored answers emit no `fill`, and the overlay reset emits no `disable` or `enable`. A custom-validator throw during reevaluation resets state but emits no `clear` and leaves the previous errors. |
1379
+ | `abandon` | nothing | On the `destroy` that abandons an unsettled form. Never on a settled one. |
1380
+
1381
+ Wire listeners at construction through `FormOptions.on`, or afterwards through the `emitter`. Both
1382
+ reach the same typed emitter, and a listener that throws is isolated and reported to
1383
+ `FormOptions.error` rather than breaking its siblings or the form.
1384
+
1385
+ ```ts
1386
+ import { createForm } from '@orkestrel/form'
1387
+
1388
+ const seen: string[] = []
1389
+
1390
+ const form = createForm(
1391
+ { fields: [{ control: 'text', name: 'email', rule: { required: true } }] },
1392
+ {
1393
+ on: {
1394
+ fill: (name, value) => seen.push(`fill ${name} ${String(value)}`),
1395
+ validate: (errors) => seen.push(`validate ${errors.length}`),
1396
+ submit: () => seen.push('submit'),
1397
+ },
1398
+ error: (error) => console.error(error),
1399
+ },
1400
+ )
1401
+
1402
+ form.emitter.on('abandon', () => seen.push('abandon'))
1403
+
1404
+ form.fill('email', 'ada@example.com')
1405
+ form.submit()
1406
+
1407
+ seen // ['fill email ada@example.com', 'validate 0', 'submit']
1408
+ ```
1409
+
1410
+ ## Wire safety
1411
+
1412
+ A schema is data, so it travels. `serializeForm` projects it into JSON — dropping every `custom`
1413
+ validator and every absent member — and `parseForm` reads unknown JSON back into an owned schema,
1414
+ refusing anything that is not structurally valid and semantically sound. The round trip is exact for
1415
+ everything that travels.
1416
+
1417
+ ```ts
1418
+ import { parseForm, serializeForm } from '@orkestrel/form'
1419
+ import type { FormSchema } from '@orkestrel/form'
1420
+
1421
+ const schema: FormSchema = {
1422
+ name: 'signup',
1423
+ label: 'Sign up',
1424
+ groups: [{ name: 'account', label: 'Account' }],
1425
+ fields: [
1426
+ { control: 'text', name: 'email', group: 'account', rule: { required: true, email: true } },
1427
+ { control: 'checkbox', name: 'topics', choices: [{ value: 'a', label: 'A' }], default: ['a'] },
1428
+ ],
1429
+ }
1430
+
1431
+ const wire = JSON.stringify(serializeForm(schema))
1432
+ const received = parseForm(JSON.parse(wire))
1433
+
1434
+ JSON.stringify(serializeForm(received ?? schema)) === wire // true
1435
+ parseForm({ fields: 'not a list' }) // undefined
1436
+ ```
1437
+
1438
+ Answers travel too, and they arrive as strings far more often than not — a query string, a form
1439
+ post, a CSV cell. `parseValue` coerces a numeric string into a `number` for a `number` field, and
1440
+ `'true'` or `'false'` into a boolean for a `confirm` field, and nothing else. Every other value must
1441
+ already have its control's shape.
1442
+
1443
+ `parseValues` is strict in both directions: an unknown key refuses the whole record, and so does one
1444
+ value its field's control cannot hold. There is no partial result, because a half-accepted answer
1445
+ set is worse than a rejected one.
1446
+
1447
+ ```ts
1448
+ import { parseValue, parseValues } from '@orkestrel/form'
1449
+ import type { ConfirmField, FormSchema, NumberField } from '@orkestrel/form'
1450
+
1451
+ const age: NumberField = { control: 'number', name: 'age' }
1452
+ const ok: ConfirmField = { control: 'confirm', name: 'ok' }
1453
+ const schema: FormSchema = { fields: [age, ok] }
1454
+
1455
+ parseValue(age, '42') // 42
1456
+ parseValue(age, 'abc') // undefined
1457
+ parseValue(ok, 'true') // true
1458
+ parseValue(ok, 'yes') // undefined
1459
+
1460
+ parseValues(schema, { age: '42', ok: 'true' }) // { age: 42, ok: true }
1461
+ parseValues(schema, { nope: '1' }) // undefined
1462
+ ```
1463
+
1464
+ The guards are the same boundary read one field at a time, and every one of them is total.
1465
+
1466
+ ```ts
1467
+ import {
1468
+ isFieldChoice,
1469
+ isFieldControl,
1470
+ isFieldError,
1471
+ isFieldRule,
1472
+ isFieldValue,
1473
+ isFormField,
1474
+ isFormGroup,
1475
+ isFormSchema,
1476
+ isFormStatus,
1477
+ isFormValues,
1478
+ } from '@orkestrel/form'
1479
+
1480
+ isFieldControl('datetime') // true
1481
+ isFieldControl('radio') // false — a radio group is a `select`
1482
+ isFormStatus('settled') // true
1483
+ isFieldValue(['a', 'b']) // true
1484
+ isFieldValue({}) // false
1485
+ isFieldChoice({ value: 'a', label: 'A' }) // true
1486
+ isFieldChoice({ value: 'a', label: 'A', colour: 'red' }) // false — an unknown member refuses it
1487
+ isFieldRule({ required: true, minimum: 8 }) // true
1488
+ isFormField({ control: 'text', name: 'email' }) // true
1489
+ isFormField({ control: 'text' }) // false
1490
+ isFormGroup({ name: 'account', label: 'Account' }) // true
1491
+ isFormSchema({ fields: [{ control: 'text', name: 'a' }] }) // true
1492
+ isFormValues({ a: 'b', c: 2 }) // true
1493
+ isFieldError({ field: 'a', message: 'b', rule: 'required' }) // true
1494
+ ```
1495
+
1496
+ ### Owning what arrives
1497
+
1498
+ The cloners are how a value stops being the caller's. The form clones the schema at construction,
1499
+ before the structural guard and the audit run, so those checks run against the copy. A caller's
1500
+ object that answers each read differently cannot pass a check on one value and be stored as
1501
+ another. A later edit to the caller's object changes nothing inside the form. The form also clones
1502
+ each list value it stores and each it returns, so no caller ever holds a reference to internal
1503
+ state. The cloners are exported because a consumer building its own schema store needs the same
1504
+ guarantee.
1505
+
1506
+ ```ts
1507
+ import { cloneChoices, cloneFormField, cloneFormSchema, cloneValue } from '@orkestrel/form'
1508
+
1509
+ const topics = ['releases']
1510
+ const owned = cloneValue(topics)
1511
+ owned === topics // false
1512
+ Object.isFrozen(owned) // true
1513
+ cloneValue('text') // 'text' — a scalar is already its own value
1514
+
1515
+ Object.isFrozen(cloneChoices([{ value: 'a', label: 'A' }])) // true
1516
+ Object.isFrozen(cloneFormField({ control: 'text', name: 'email' })) // true
1517
+ Object.isFrozen(cloneFormSchema({ fields: [{ control: 'text', name: 'email' }] })) // true
1518
+ ```
1519
+
1520
+ ### Deriving without a form
1521
+
1522
+ The evaluation and derivation helpers are pure and take a schema plus values, so a caller that has
1523
+ no form — a server checking a posted body, an editor previewing a schema — reaches the same answers
1524
+ the form would give.
1525
+
1526
+ ```ts
1527
+ import {
1528
+ appliesRule,
1529
+ computeDefaults,
1530
+ evaluateForm,
1531
+ extractGroups,
1532
+ matchesValue,
1533
+ matchesValues,
1534
+ } from '@orkestrel/form'
1535
+ import type { FormSchema } from '@orkestrel/form'
1536
+
1537
+ const schema: FormSchema = {
1538
+ groups: [
1539
+ { name: 'account', label: 'Account' },
1540
+ { name: 'unused', label: 'Unused' },
1541
+ ],
1542
+ fields: [
1543
+ { control: 'text', name: 'email', group: 'account', default: 'ada@example.com' },
1544
+ { control: 'confirm', name: 'terms', default: false },
1545
+ { control: 'password', name: 'secret' },
1546
+ ],
1547
+ }
1548
+
1549
+ computeDefaults(schema) // { email: 'ada@example.com', terms: false } — `password` seeds nothing
1550
+ extractGroups(schema) // [{ name: 'account', label: 'Account' }] — `unused` is referenced by nobody
1551
+ evaluateForm(schema, {}) // [] — no field declares a rule
1552
+ appliesRule('number', 'step') // true
1553
+ matchesValue(['a'], ['a']) // true
1554
+ matchesValues({ topics: ['a'] }, { topics: ['a'] }) // true
1555
+ ```
1556
+
1557
+ ## Methods
1558
+
1559
+ The public methods of `FormInterface`, which the `Form` class implements exactly and adds nothing
1560
+ to. Its readonly data members stay in the preceding `## Surface` rows, in the `Shape` cell before
1561
+ `plus`, and are not repeated here.
1562
+
1563
+ A record of answers and a list of names are checked in full before anything moves, so a refused call
1564
+ changes nothing.
1565
+
1566
+ Every other row in the Surface tables is a data shape, a union, a constant, a function, or an error
1567
+ class, so none of them carries a method table. `FieldValidator` is a callable function type with one
1568
+ call signature and no named members.
1569
+
1570
+ #### `FormInterface`
1571
+
1572
+ | Method | Returns | Summary |
1573
+ | ------------ | -------------------------- | ------------------------------------------------------------------ |
1574
+ | `field` | `FormField` or `undefined` | Finds one field by name. |
1575
+ | `fill` | `void` | Answers one field, or several at once. |
1576
+ | `touch` | `void` | Records that somebody has visited a field. |
1577
+ | `invalidate` | `void` | Fails a field from outside, for what the rules cannot see. |
1578
+ | `disable` | `void` | Takes one field, several fields, or every field out of the form. |
1579
+ | `enable` | `void` | Puts one field, several fields, or every field back into the form. |
1580
+ | `submit` | `FormResult` | Checks every answer and settles the form when they all pass. |
1581
+ | `clear` | `void` | Returns every answer to the ones the form opened with. |
1582
+ | `destroy` | `void` | Tears the form down, abandoning it when it has not settled. |
1583
+
1584
+ ## Errors
1585
+
1586
+ `FormError` carries a machine-readable `code` and an optional structured `context`. Narrow a caught
1587
+ value with `isFormError` and branch on `code`; never match on message text. A custom validator's own
1588
+ throw is the caller's exception and escapes unchanged.
1589
+
1590
+ | Code | Raised when |
1591
+ | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1592
+ | `SCHEMA` | The schema is not a form schema, `auditSchema` found a domain fault, or `cloneFormField` cannot own a field's `meta`. The constructor raises each; `cloneFormField` and `serializeForm` each raise the metadata one on their own. |
1593
+ | `FIELD` | A name given to `fill`, `touch`, `invalidate`, `disable`, or `enable` is one the schema does not declare. |
1594
+ | `CONTROL` | A value written or seeded is one its field's control cannot hold. |
1595
+ | `SETTLED` | A write reached a form that has already settled. |
1596
+ | `ABANDONED` | A write reached a form that was destroyed before it settled, or `answer` rejected for that reason. |
1597
+
1598
+ ```ts
1599
+ import { createForm, isFormError } from '@orkestrel/form'
1600
+
1601
+ try {
1602
+ createForm({ fields: [{ control: 'text', name: '' }] })
1603
+ } catch (error) {
1604
+ if (isFormError(error)) error.code // 'SCHEMA'
1605
+ }
1606
+
1607
+ const form = createForm({ fields: [{ control: 'number', name: 'age' }] })
1608
+
1609
+ try {
1610
+ form.fill('nope', 1)
1611
+ } catch (error) {
1612
+ if (isFormError(error)) error.code // 'FIELD'
1613
+ }
1614
+
1615
+ try {
1616
+ form.fill('age', 'twelve')
1617
+ } catch (error) {
1618
+ if (isFormError(error)) error.code // 'CONTROL'
1619
+ }
1620
+
1621
+ form.values // {} — a refused write changed nothing
1622
+ ```
1623
+
1624
+ `createForm` and `new Form(...)` are the same construction. Prefer the factory at a call site that
1625
+ only needs `FormInterface`; reach for the class where a class holds a form as its own field and
1626
+ wants the concrete type.
1627
+
1628
+ ```ts
1629
+ import { Form } from '@orkestrel/form'
1630
+
1631
+ const form = new Form({ fields: [{ control: 'text', name: 'email', rule: { required: true } }] })
1632
+ form.fill('email', 'ada@example.com')
1633
+ form.submit().success // true
1634
+ ```
1635
+
1636
+ ## Contract
1637
+
1638
+ These invariants hold across [`src/core`](../src/core) and this guide.
1639
+
1640
+ 1. **Documented surface equals exported surface.** Every row in the `## Surface` tables is a real
1641
+ barrel export of `src/core`, and every barrel export is a row — both directions, exhaustively.
1642
+ Nothing in this module is internal, so the parity suite's internal list is empty.
1643
+ 2. **Documented methods equal interface methods.** The `## Methods` table for `FormInterface` lists
1644
+ exactly its call-signature members, and the `Form` class implements every one and adds no public
1645
+ behavior beyond them.
1646
+ 3. **The schema is owned before it is read.** `Form` clones the schema at construction and freezes
1647
+ every nested group, field, rule, choice, and list. The clone is the only read of the caller's
1648
+ object: `isFormSchema` and `auditSchema` both run against the owned copy, and that same copy is
1649
+ what the form keeps. A later edit to the caller's object changes nothing inside the form, and no
1650
+ getter returns a live internal reference.
1651
+ 4. **Errors are current after completed evaluation.** `errors` is recomputed at construction and
1652
+ after every mutation whose evaluation completes, and `validate` fires exactly when that list's
1653
+ content changes. A custom-validator throw escapes mid-evaluation. After a throwing `fill`, the
1654
+ form holds the new answers beside the pre-fill errors. A throwing `invalidate` records its
1655
+ failure but keeps that stale list. A throwing `clear` resets answers, touched fields, and
1656
+ invalidations but emits no `clear` and leaves the previous errors. There is no `check`. A failed
1657
+ `submit` returns the list it checked at the decision, which is a value rather than a view: after a
1658
+ `validate` listener wrote during that submit, `errors` can already differ from it.
1659
+ 5. **`valid` and `dirty` are derived.** Both are computed on read, from `errors` and from the
1660
+ answers against `baseline`, so neither can drift from what the form holds. `baseline` itself is
1661
+ fixed when the form opens and never moves again.
1662
+ 6. **A write is all-or-nothing.** `fill` checks every answer against its control before writing any,
1663
+ and `disable` and `enable` check every name against the schema before any field moves, so a
1664
+ `FIELD` or `CONTROL` failure leaves the form exactly as it was.
1665
+ 7. **Settle once, terminally.** The first valid submit resolves `answer`, emits `submit`, and sets
1666
+ `status` to `settled`; every later write throws. A destroy not overtaken by an in-flight
1667
+ settlement sets `abandoned`, rejects `answer` with `ABANDONED`, and emits `abandon`. The exception
1668
+ is a destroy deferred behind a mutation batch that settles before teardown: the form ends
1669
+ `settled`, `answer` resolves, and no `abandon` is emitted. Neither end state is left, and every
1670
+ getter keeps answering in both. A settlement made by a nested `submit` inside a `validate`
1671
+ listener is that one settlement: the outer call returns it, `submit` still fires once, and nothing
1672
+ is evaluated after it.
1673
+ 8. **`undefined` is the only absence.** A field is unanswered when `values` holds no key for it, and
1674
+ answered otherwise, so `''`, whitespace, `[]`, `false`, and `0` all satisfy `required`. This is
1675
+ not HTML's model, which fails `required` on the exact empty string. A binding that wants HTML's
1676
+ answer projects at the binding — `fill(name, matchesAnswer(raw) ? raw : undefined)` — and core
1677
+ evaluation never applies that projection itself.
1678
+ 9. **A disabled field is out of the form, and the form decides which.** A field that is out is
1679
+ neither evaluated nor submitted, while `hidden` and `locked` are rendering facts only and are
1680
+ both still evaluated and still submitted. `FieldBase.disabled` is the declaration and
1681
+ `FormInterface.disabled` is the current fact: `disable` and `enable` move a field either way,
1682
+ each announcing once per field that actually moved in schema order, an invalidation is held
1683
+ while its field is out and restored when it returns, and `clear` resets the overlay to the
1684
+ declarations — announcing that reset with `clear` alone, never with a per-field `disable` or
1685
+ `enable`. `auditSchema` therefore holds every field to the same satisfiability standard,
1686
+ disabled or not: it reads the schema alone, so no declaration and no runtime set changes a
1687
+ diagnostic, and a passing schema carries none of the faults it enumerates whatever is disabled
1688
+ at runtime. It claims nothing beyond that list, and `custom` is outside it.
1689
+ 10. **Guards are total and parsers refuse.** No `is*` throws for any input — hostile prototype,
1690
+ symbol key, cycle, or depth. No `parse*` throws; each returns `undefined` on refusal. A
1691
+ guard-valid value is never refused by its parser, and every parsed result satisfies its guard.
1692
+ A pattern within `PATTERN_LIMIT` can still backtrack catastrophically; this package applies no
1693
+ time bound, so evaluating an untrusted pattern spends the caller's thread.
1694
+ 11. **Every retained size is budgeted.** `auditSchema` reports a breach of `FIELD_LIMIT`,
1695
+ `GROUP_LIMIT`, `CHOICE_LIMIT`, `NAME_LIMIT`, `STRING_LIMIT`, `TEXT_LIMIT`, `NODE_LIMIT`, or
1696
+ `PATTERN_LIMIT`, so `createForm` throws `SCHEMA` and `parseForm` refuses; `matchesField` refuses
1697
+ a value breaching `STRING_LIMIT` or `LIST_LIMIT` before any regular expression sees it, so
1698
+ `fill` and a seeded value throw `CONTROL` and the parsers return `undefined`. `TEXT_LIMIT` and
1699
+ `NODE_LIMIT` are whole-schema ceilings, `meta` included, so the per-item limits never multiply.
1700
+ They bound retention and the audit's own walk, never the structural read at the parse door,
1701
+ which is the transport's to bound. Regular-expression time and a `custom` validator's own work
1702
+ stay unbounded too.
1703
+ 12. **Only data crosses the wire.** `serializeForm` drops every `custom` validator on the way out
1704
+ and `parseForm` drops every `custom` member on the way in, so no function crosses in either
1705
+ direction. Everything that does cross survives the round trip exactly, `meta` verbatim key for
1706
+ key: evaluation never reads it, and this package defines no key in it.
1707
+ 13. **`auditSchema` returns diagnostics, not a contract.** The list's emptiness is the promise. The
1708
+ wording of its strings is not: never parse them.
1709
+ 14. **The temporal patterns are lexical.** `date`, `time`, and `datetime` values are checked for
1710
+ spelling, never against a calendar, so `'2026-02-31'` is a valid `date` value here. Bounds also
1711
+ compare lexically, so operands and values must use the same precision; `'09:00'` sorts before
1712
+ `'09:00:00'`.
1713
+
1714
+ ## Concept inventory
1715
+
1716
+ What this package deliberately does not do, and where the work goes instead. Each line is a
1717
+ boundary taken on evidence, not an omission — so a reader can tell a boundary from a gap, and the
1718
+ next change knows what it is reopening. `Layer` names who owns the concept, and a row reading
1719
+ **seam** is one this package answers today through a mechanism it already exposes.
1720
+
1721
+ | Concept | Layer | Why it sits there |
1722
+ | -------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1723
+ | Derived `hidden` | out | A `hidden` computed from siblings turns a declared fact into a derived one, which puts a recompute pass back in front of every read — the `check` this design removed. `hidden` stays what the schema said. |
1724
+ | Required when a sibling says so | seam | `custom` runs after the named rules and on an absent value, so a required-when rule is one validator reading `values`. The unanswered field carries both messages when both fail. |
1725
+ | Relevance, when a field stops applying | seam | A field that no longer applies goes out of the form with `disable` and comes back with `enable`: not evaluated, not submitted, and its invalidation held meanwhile. |
1726
+ | Repeating field arrays | out | A field group answered many times over. It changes `FormValues` from a flat record into a tree, and every rule, guard, parser, and error path with it. |
1727
+ | Wizards and multi-step | host | Pages, ordering, and progress are presentation. A wizard is several forms and a host that sequences them. |
1728
+ | Pagination | host | Sections of one long form are presentation for the same reason a wizard is, and `group` already carries the arrangement a host paginates on. |
1729
+ | `month` and `week` | out | More temporal controls with more lexical patterns and no new idea. They join when a real consumer asks for one. |
1730
+ | Temporal `step` | out | `step` is number-only. A temporal step means intervals over calendar arithmetic, which is the same calendar this package deliberately does not carry. |
1731
+ | Presentation hints | renderer | Switch, radio, and range are affordances for questions already modelled as `confirm`, `select`, and `number`. A hint here would be product policy; `meta` is the carrier when a host must ship one anyway. See [Rendering](#rendering) for the catalog category each control maps to. |
1732
+ | Input masks | renderer | A mask is how characters are typed and shown as they are typed. `pattern` states what the finished value must be, which is the part that has to travel. |
1733
+ | Accessibility IDs | renderer | `label` and `help` are the strings an accessible control needs. The `id`s tying them to inputs belong to the layer that owns the elements and their uniqueness. |
1734
+ | First-error focus | renderer | `errors` is ordered by schema then rule, so the first error is the first entry. Which element takes focus is a decision only a renderer can make. |
1735
+ | File bytes | host | A `file` value is names. Bytes are a transport concern with a host-specific representation, and putting them in the document would make the document unserializable. |
1736
+ | Async validation | host | Every rule here is synchronous, so `errors` stays current after every mutation. An async check is the host's attempt, reported through `invalidate` on refusal — `submit` is the commit, never the attempt. |
1737
+ | Validation timing and debounce | host | `errors` is current after every mutation, so when to _show_ it is policy over `touched` and the host's own timers rather than a mode inside the form. |
1738
+ | Warnings and first-error-only modes | host | Every failure is an error and `errors` carries all of them in order. A severity axis, or a switch that reports only the first, is display policy applied over that list. |
1739
+ | Server-error bags | host | A map of field names to server messages is a loop over `invalidate`, which already holds one external failure per field and drops it when the field is refilled. |
1740
+ | Form-level validators | seam | A rule about the form as a whole. `custom` already reads every answer, so the same check runs today attached to the field it would fail. |
1741
+ | Address lists | seam | `TextField` has no `multiple`: that word already means a list of file names on `FileField`, and one word cannot carry two value shapes. Several addresses is `custom` plus the exported `EMAIL_PATTERN`. |
1742
+ | Group-level disable | seam | `disable` takes no group argument. A host expands a group from the schema in one line, and a second way to name the same set is a second thing to keep consistent. |
1743
+ | Computed fields | host | A field deriving its value from siblings would be a second writer of `values` and could disagree with `fill`. The host computes and fills, so one writer stays one writer. |
1744
+ | Async and live choices | host | `choices` is data in the schema. A list fetched or filtered while somebody types is the host building a new schema, which `parseForm` audits for it. |
1745
+ | Drafts and autosave | host | `values` is readable at any moment and `parseValues` reads a stored record back, so persistence is a host loop over `values` and `parseValues` with the host's own storage and cadence. |
1746
+ | Undo and history | host | The form holds the answers and `baseline` holds the ones it opened with. A stack of everything in between is a host concern with a host's retention policy. |
1747
+ | Schema migration | host | Versioning a stored schema and moving old answers onto a new one is the host's data problem. `parseForm` and `parseValues` are the gates on each side of it. |
1748
+ | Trim and normalize | binding | The form stores what it is given. Trimming, case folding, and Unicode normalization belong to the binding, and `matchesAnswer` is where a value that is blank after trimming becomes absence. |
1749
+ | Localization | host | `FormOptions.messages` replaces a rule's copy, and `label` and `help` are the schema author's strings. Locale selection, plurals, and message catalogs are the host's. |
1750
+ | Value localization | binding | A `date` value is the control's own ISO string and a `number` is a number. Reading `31/12/2026` or `1.234,5` from a person is the binding's parse, and it fills the parsed value. |
1751
+ | Group and choice `meta` | out | `meta` is on `FieldBase` alone, because a field carrier is what the first consumer asked for. The exact guards refuse it on `FormGroup` and `FieldChoice` until one asks. |
1752
+ | Constraint-API binding | `src/browser` | Reflecting `required` and `minimum` onto real elements, and reading `validity` back, is binding work. It belongs where the elements are. |
1753
+ | `FormData` and duplicate names | `src/browser` | Several values under one name, the submitter button, and the `FormData` encoding are host wire formats. `FormValues` holds one answer per field name by design. |
1754
+ | Browser binding | `src/browser` | Binding a schema to real elements belongs in a future `src/browser`, taking a form and an element. It is not in this round because nothing renders here yet. |
1755
+ | Terminal adoption | terminal | A terminal driver parking a whole form rather than one prompt belongs in the terminal package, on the same `answer` promise this document already exposes. |
1756
+
1757
+ ## Tests
1758
+
1759
+ - [`tests/guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ barrel bijection, the
1760
+ `FormInterface` ↔ `Form` method bijection, and the equality gate: every `Summary` cell against its
1761
+ declaration's description paragraph, the titled `Open a form, answer it, and settle it` fence
1762
+ against the `@example` block of that title (pinned so the titled pair cannot be retired silently),
1763
+ and the README pitch against this guide's tagline. It also runs the preceding flagship fences
1764
+ against the real source, so a documented value that the code contradicts fails.
1765
+ - [`tests/src/core/Form.test.ts`](../tests/src/core/Form.test.ts) — construction, state, `baseline`,
1766
+ `fill`, `touch`, `invalidate`, `disable`, `enable`, `submit`, `clear`, `destroy`, and the rule
1767
+ paths through the entity.
1768
+ - [`tests/src/core/helpers.test.ts`](../tests/src/core/helpers.test.ts) — `matchesField`,
1769
+ `matchesAnswer`, `appliesRule`, `evaluateField`, `evaluateForm`, `computeDefaults`,
1770
+ `matchesValue`, `extractChanges`, `matchesValues`, `formatMessage`, `createFieldError`,
1771
+ `defineEntry`, `freezeEntry`, `serializeForm`, `extractGroups`, `auditSchema`, the budgets, and
1772
+ the control-by-rule matrix.
1773
+ - [`tests/src/core/validators.test.ts`](../tests/src/core/validators.test.ts) — every guard against
1774
+ valid, off-shape, and hostile input, plus guard/parser soundness in both directions.
1775
+ - [`tests/src/core/parsers.test.ts`](../tests/src/core/parsers.test.ts) — `parseValue`,
1776
+ `parseValues`, `parseForm`, the wire round trip, and answer parking.
1777
+ - [`tests/src/core/cloners.test.ts`](../tests/src/core/cloners.test.ts) — every clone is owned,
1778
+ frozen, and deep enough that no caller reference survives.
1779
+ - [`tests/src/core/constants.test.ts`](../tests/src/core/constants.test.ts) — the registries, the
1780
+ permitted-member table, the default messages, and each shipped pattern.
1781
+ - [`tests/src/core/errors.test.ts`](../tests/src/core/errors.test.ts) — `FormError`'s `code` and
1782
+ `context`, and `isFormError` narrowing.
1783
+ - [`tests/src/core/factories.test.ts`](../tests/src/core/factories.test.ts) — `createForm` returns a
1784
+ working `FormInterface`.
1785
+ - [`tests/src/core/index.test.ts`](../tests/src/core/index.test.ts) — the barrel resolves every
1786
+ documented export.
1787
+
1788
+ ## See also
1789
+
1790
+ - [`AGENTS.md`](../AGENTS.md) — the coding contract this package is written against.
1791
+ - [`README.md`](README.md) — the guides index.