# Capability Spec: form-builder **Package**: `@suaveplan/form-builder` **Status**: SHIPPED (2026-05-25, change `enterprise-form-builder`) > Every requirement below carries a `**Verified by:**` line. ## Requirements ### Requirement: Zod-validated form schema parsing The package SHALL expose `parseFormSchema(raw)` that validates an unknown input against the canonical Zod schema for `FormSchema` and SHALL throw `FormError` with code `FORM_BUILDER.CONFIG_INVALID` when validation fails. The Zod schema SHALL enforce non-empty `id` and `name`, integer `version >= 1`, ISO-8601 `createdAt` and `updatedAt`, and unique field ids. #### Scenario: valid schema parses - **WHEN** `parseFormSchema` is given a well-formed `FormSchema` - **THEN** the returned object structurally equals the input and `freeze` is applied to top-level arrays #### Scenario: empty name fails - **WHEN** `parseFormSchema({ id: "x", name: "", version: 1, fields: [], createdAt: now, updatedAt: now })` - **THEN** a `FormError` is thrown with `code === "FORM_BUILDER.CONFIG_INVALID"` #### Scenario: duplicate field id fails - **WHEN** `parseFormSchema` is given two fields with the same `id` - **THEN** a `FormError` is thrown with `code === "FORM_BUILDER.CONFIG_INVALID"` **Verified by:** `src/schema/schema/schema.test.ts::"parses a valid schema"` AND `src/schema/schema/schema.test.ts::"rejects empty name"` AND `src/schema/schema/schema.test.ts::"rejects duplicate field ids"` --- ### Requirement: Extensible field-type registry The package SHALL expose a `FieldRegistry` that registers, at construction, all 17 built-in field types (`text`, `number`, `email`, `url`, `tel`, `password`, `select`, `multiselect`, `checkbox`, `radio`, `textarea`, `date`, `file`, `range`, `hidden`, `group`, `array`), and SHALL allow consumers to `register(type, descriptor)`, `unregister(type)`, `has(type)`, `get(type)`, and `list()`. #### Scenario: built-ins available - **WHEN** a fresh `FieldRegistry` is constructed - **THEN** `list().length === 17` and `has("text") === true` #### Scenario: custom field-type registration - **WHEN** a caller `register("rating", { defaultValue: 0 })` - **THEN** `has("rating") === true` and `get("rating")?.defaultValue === 0` **Verified by:** `src/fields/field-registry/field-registry.test.ts::"exposes 17 built-in field types"` AND `src/fields/field-registry/field-registry.test.ts::"accepts custom field-type registration"` --- ### Requirement: Eleven built-in validation rules The package SHALL provide rule runners for `required`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `email`, `url`, `oneOf`, `equalsField`, and `custom`. Each runner SHALL accept the candidate `value`, the full form `values` record, and an optional `AbortSignal`, returning either `null` (passed) or a `ValidationError` shaped `{ fieldId, rule, message }`. `custom` rules MAY return a `Promise`. #### Scenario: required passes on present string - **WHEN** `runRule({ type: "required" }, "alice", {})` is invoked - **THEN** the return value is `null` #### Scenario: required fails on empty string - **WHEN** `runRule({ type: "required" }, "", {})` is invoked - **THEN** the return value is a `ValidationError` with `rule === "required"` #### Scenario: equalsField compares two field values - **WHEN** `runRule({ type: "equalsField", value: "password" }, "x", { password: "y" }, "confirm")` - **THEN** the return value is a `ValidationError` with `rule === "equalsField"` #### Scenario: custom async rule resolves to error - **WHEN** a custom rule returns `Promise.resolve({ fieldId, rule: "custom", message: "no" })` - **THEN** the returned promise resolves to that `ValidationError` **Verified by:** `src/validation/validators/validators.test.ts::"required passes on present value"` AND `src/validation/validators/validators.test.ts::"required fails on empty"` AND `src/validation/validators/validators.test.ts::"equalsField detects mismatch"` AND `src/validation/validators/validators.test.ts::"custom rule supports async"` --- ### Requirement: Field and form validation with OTel + AbortSignal The package SHALL expose `runFieldValidation(field, value, values, signal?)` and `runFormValidation(fields, values, signal?)` that iterate the rules, honour `AbortSignal` cancellation (resolving to `[]` when aborted), and emit the `form_builder.validation.run_total` counter and `form_builder.validation.duration_ms` histogram on every call. #### Scenario: aborted form validation returns no errors - **WHEN** an `AbortSignal` is aborted before `runFormValidation` is invoked - **THEN** the returned array is empty and no rules are executed #### Scenario: errors aggregate per field - **WHEN** `runFormValidation` runs over two fields each with one failing rule - **THEN** the returned array has length 2 with one entry per field **Verified by:** `src/validation/validation/validation.test.ts::"honours abort signal"` AND `src/validation/validation/validation.test.ts::"aggregates per-field errors"` AND `src/validation/validation/validation.test.ts::"emits counter and histogram"` --- ### Requirement: Conditional logic engine with 9 leaf operators and boolean composition The package SHALL evaluate `ConditionalRule` values against current form values. The leaf shape SHALL support operators `eq`, `neq`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`, and `contains`. The boolean shapes `and`, `or`, and `not` SHALL compose sub-rules. The legacy upstream shape `{ field, operator, value }` SHALL be accepted and treated as a `leaf` rule. #### Scenario: leaf eq evaluates strictly equal - **WHEN** `evaluateConditional({ kind: "leaf", field: "x", operator: "eq", value: 1 }, { x: 1 })` - **THEN** the return value is `true` #### Scenario: and short-circuits on first false - **WHEN** an `and` rule with three children evaluates and the first child is false - **THEN** the return value is `false` and no further children are evaluated (verified by spy) #### Scenario: legacy shape is accepted - **WHEN** `evaluateConditional({ field: "x", operator: "eq", value: 1 }, { x: 1 })` - **THEN** the return value is `true` **Verified by:** `src/conditional/conditional/conditional.test.ts::"leaf eq evaluates equal"` AND `src/conditional/conditional/conditional.test.ts::"and short-circuits"` AND `src/conditional/conditional/conditional.test.ts::"accepts legacy leaf shape"` --- ### Requirement: Dependency graph with topological sort and cycle detection The package SHALL build a `DependencyGraph` from a field list by inspecting each field's `conditional` rule and `equalsField` validators, producing a DAG keyed by field id. `topoSort()` SHALL return a stable ordering. If a cycle is detected, the call SHALL throw `FormError` with code `FORM_BUILDER.CYCLIC_DEPENDENCY`. #### Scenario: linear chain topo sorts in order - **WHEN** field `b` depends on `a` and field `c` depends on `b` - **THEN** `topoSort()` returns an order in which `a` precedes `b` precedes `c` #### Scenario: cycle raises CYCLIC_DEPENDENCY - **WHEN** field `a` depends on `b` and field `b` depends on `a` - **THEN** `topoSort()` throws a `FormError` with `code === "FORM_BUILDER.CYCLIC_DEPENDENCY"` **Verified by:** `src/conditional/dependency-graph/dependency-graph.test.ts::"topo-sorts linear chain"` AND `src/conditional/dependency-graph/dependency-graph.test.ts::"throws on cycle"` --- ### Requirement: Form state machine The package SHALL expose a `FormState` machine with `setValue(field, value)`, `setValues(values)`, `touch(field)`, `reset(defaults?)`, `getState()` returning `{ values, errors, touched, dirty, valid }`. `dirty` SHALL flip true on any value mutation and flip false on `reset`. `valid` SHALL reflect the most recently computed validation result. #### Scenario: setValue marks the field touched and dirty - **WHEN** `state.setValue("name", "alice")` is called on a fresh `FormState` - **THEN** `state.getState().dirty === true` and `state.getState().touched.has("name")` #### Scenario: reset clears values and dirty - **WHEN** `state.reset({ name: "default" })` is called after a mutation - **THEN** `state.getState().values.name === "default"`, `state.getState().dirty === false`, `state.getState().touched.size === 0` **Verified by:** `src/state/form-state/form-state.test.ts::"setValue marks dirty and touched"` AND `src/state/form-state/form-state.test.ts::"reset clears state"` --- ### Requirement: Bounded undo/redo stack The package SHALL expose `UndoRedoStack` that records reverse-ops with a bounded capacity (default `DEFAULT_UNDO_CAPACITY === 100`). `push(op)` SHALL drop the oldest entry when capacity is exceeded and clear the redo stack. `undo()` and `redo()` SHALL return the inverse / forward op respectively, or `undefined` when empty. #### Scenario: capacity bound drops oldest - **WHEN** an `UndoRedoStack` with capacity 2 receives three pushes - **THEN** the first pushed op is no longer recoverable via `undo()` #### Scenario: new push clears redo - **WHEN** an `UndoRedoStack` has one undone op and a new `push` arrives - **THEN** `redo()` returns `undefined` **Verified by:** `src/state/undo-redo/undo-redo.test.ts::"drops oldest when capacity is exceeded"` AND `src/state/undo-redo/undo-redo.test.ts::"new push clears redo stack"` --- ### Requirement: JSON serialise / deserialise round-trip The package SHALL expose `serialiseForm(schema)` returning a versioned JSON string, and `deserialiseForm(json)` returning a validated `FormSchema`. Round-trip equality SHALL hold for any schema produced by `FormBuilder`. `deserialiseForm` SHALL raise `FORM_BUILDER.CONFIG_INVALID` on malformed input. #### Scenario: round-trip preserves structure - **WHEN** a schema is serialised then deserialised - **THEN** the deserialised value deep-equals the original (excluding `Set` instances, which are JSON-rebuilt as `Set`) #### Scenario: malformed JSON raises CONFIG_INVALID - **WHEN** `deserialiseForm("{not json")` is called - **THEN** a `FormError` is thrown with `code === "FORM_BUILDER.CONFIG_INVALID"` **Verified by:** `src/serialise/serialise/serialise.test.ts::"round-trips a schema"` AND `src/serialise/serialise/serialise.test.ts::"raises on malformed json"` --- ### Requirement: Zod schema generator The package SHALL expose `toZodSchema(form)` returning a `z.ZodObject` whose validation behaviour matches `runFormValidation` for the canonical 11 rule kinds. The generated schema SHALL respect conditional `required` (a field is required only when its conditional evaluates true). #### Scenario: required field is required in zod - **WHEN** a schema declares `name: text { required }` and `toZodSchema(form).safeParse({})` - **THEN** `safeParse` returns `success === false` #### Scenario: conditional required is optional when condition is false - **WHEN** `b` requires `a === "yes"` and `safeParse({ a: "no" })` - **THEN** `success === true` **Verified by:** `src/zod-schema/zod-schema/zod-schema.test.ts::"enforces required"` AND `src/zod-schema/zod-schema/zod-schema.test.ts::"respects conditional required"` --- ### Requirement: Submission pipeline The package SHALL expose `submit({ form, values, submitter, signal?, transform? })` that runs `validate → optional transform → submitter(values)`. If validation produces errors, the call SHALL reject with `FORM_BUILDER.VALIDATION_FAILED`. `signal` SHALL be threaded into the submitter. The submission counter and histogram SHALL be emitted with `result: ok|invalid|failed`. #### Scenario: invalid values short-circuit - **WHEN** `submit` is called with values that fail required validation - **THEN** the returned promise rejects with `code === "FORM_BUILDER.VALIDATION_FAILED"` #### Scenario: ok path calls submitter once - **WHEN** `submit` is called with valid values - **THEN** the `submitter` is invoked exactly once with the (possibly transformed) values **Verified by:** `src/submission/submission/submission.test.ts::"rejects when validation fails"` AND `src/submission/submission/submission.test.ts::"invokes submitter on ok path"` --- ### Requirement: FormBuilder aggregate API The package SHALL expose `FormBuilder` with `createSchema`, `addField`, `removeField`, `updateField`, `addSection`, `removeSection`, `getField`, `getSectionById`, `getSchema`, `validateField`, `validateForm`, `getVisibleFields`, `setValues`, `resetForm`, `getState`, `toJSON`, `enableHistory`, `undo`, `redo`, and `submit`. The builder SHALL emit typed events on `EventBus` for `field.added`, `field.removed`, `field.updated`, `section.added`, `section.removed`, `values.changed`, `form.validated`, and `form.reset`. #### Scenario: addField then duplicate raises DUPLICATE_ID - **WHEN** the same field id is added twice - **THEN** the second call throws `FormError` with `code === "FORM_BUILDER.DUPLICATE_ID"` #### Scenario: removeField on missing id raises FIELD_NOT_FOUND - **WHEN** `removeField("nope")` is called on a fresh builder - **THEN** the call throws `FormError` with `code === "FORM_BUILDER.FIELD_NOT_FOUND"` #### Scenario: getVisibleFields hides on false conditional - **WHEN** field `b` declares `{ field: "a", operator: "eq", value: "yes" }` and `values.a === "no"` - **THEN** `getVisibleFields(values)` excludes `b` #### Scenario: setValues emits values.changed - **WHEN** a listener subscribes to `values.changed` and `setValues({ a: 1 })` is called - **THEN** the listener is invoked exactly once with `{ a: 1 }` #### Scenario: undo after addField restores previous state - **WHEN** `enableHistory()` then `addField(f)` then `undo()` are invoked - **THEN** `getSchema().fields` is empty **Verified by:** `src/builder/form-builder/form-builder.test.ts::"raises DUPLICATE_ID on duplicate addField"` AND `src/builder/form-builder/form-builder.test.ts::"raises FIELD_NOT_FOUND on missing removeField"` AND `src/builder/form-builder/form-builder.test.ts::"hides field when conditional is false"` AND `src/builder/form-builder/form-builder.test.ts::"emits values.changed on setValues"` AND `src/builder/form-builder/form-builder.test.ts::"undo reverses addField"` --- ### Requirement: Stable error-code namespace The package SHALL expose a `FORM_BUILDER_CODES` table built with `defineCodes("FORM_BUILDER", …)` covering exactly `FIELD_NOT_FOUND`, `SECTION_NOT_FOUND`, `DUPLICATE_ID`, `CONFIG_INVALID`, `VALIDATION_FAILED`, and `CYCLIC_DEPENDENCY`. Every code SHALL be reachable from a real call site exercised in tests. #### Scenario: every code is reachable - **WHEN** the test suite for `form-error.ts` runs - **THEN** at least one factory or builder method is asserted to throw with each of the six codes **Verified by:** `src/errors/form-error/form-error.test.ts::"every code is reachable from a factory"` --- ### Requirement: Universal-tier deterministic boundary The package SHALL import zero browser DOM APIs, zero React APIs, and zero Node-specific globals. Construction SHALL accept optional `Clock` and `IdGenerator` so consumers can pin timestamps and ids for snapshot tests. Defaults derive from `@suaveplan/types`. #### Scenario: injected clock controls createSchema timestamps - **WHEN** `new FormBuilder({ clock: () => 12345 }).createSchema("a", "b")` is called - **THEN** the returned schema's `createdAt` and `updatedAt` derive from `12345` **Verified by:** `src/builder/form-builder/form-builder.test.ts::"uses injected clock for timestamps"` --- ### Requirement: OpenTelemetry instruments The package SHALL lazily initialise five OTel instruments via `metrics.getMeter("@suaveplan/form-builder")`: counters `form_builder.field.mutated_total`, `form_builder.validation.run_total`, `form_builder.submission.total`; histograms `form_builder.validation.duration_ms` and `form_builder.submission.duration_ms`. Each instrument SHALL be reachable from a test that installs a real `MeterProvider` from `@opentelemetry/sdk-metrics`. #### Scenario: every instrument records at least one observation - **WHEN** an in-memory `MeterProvider` is installed and the public API is exercised end-to-end - **THEN** every named instrument records at least one observation **Verified by:** `src/otel/instruments/instruments.test.ts::"every instrument records at least one observation"`