What an author writes, what rides the wire, and what the server enforces at
submit. The client half is docs/form-engine.md; the rule vocabulary is
the reactivity contract and docs/reactivity/catalogue.md.
The rule of the whole subsystem: client evaluation is cosmetic. Every
declarative rule that affects visibility or requiredness is re-evaluated
server-side against the submitted state, and every sets derivation is
recomputed there, so a bypassed UI cannot change what is validated or
persisted.
The when() DSL
when(path) opens a condition on a flat state key (dotted for JSON columns,
./-prefixed inside a repeater row). Every terminal method returns plain
JsonLogic — the exact JSON the fixtures pin — so rules compose, nest, and can
be stored in a constant.
import { F, when } from '@adonia/core'
F.datetime('publishedAt')
.visibleWhen(when('status').is('published'))
.requiredWhen(when('status').is('published'))
F.text('coupon').visibleWhen(
when.all([when('type').is('discount'), when('amount').gt(100)])
)
F.text('vatId').requiredWhen(when.not(when('country').is('US')))| Builder | Emits |
|---|---|
.equals(v) / .is(v) |
{"===": [{"var": path}, v]} |
.notEquals(v) / .isNot(v) |
{"!==": …} |
.in([…]) |
{"in": [{"var": path}, […]]} |
.notIn([…]) |
{"!": [{"in": …}]} |
.gt/.gte/.lt/.lte(v) |
{">": …}, … — null-safe, never coerced to 0 |
.contains(v) |
{"in": [v, {"var": path}]} (state is the haystack) |
.truthy() / .isTruthy() |
bare {"var": path} — consumers Boolean() it |
.falsy() / .isFalsy() |
{"!": [{"var": path}]} |
when.all(…) / when.any(…) |
{"and": […]} / {"or": […]} (array or variadic) |
when.not(rule) |
{"!": [rule]} |
when.ref(path) |
{"var": path} — the operand form, for cross-field comparisons |
The field shorthand is unchanged and compiles identically:
visibleWhen('status', 'published') ≡ visibleWhen(when('status').is('published')),
and an array matcher ≡ .in([...]). A raw rule object still passes through.
Comparisons take a second rule as their operand, which is how a cross-field condition is expressed without leaving the subset:
F.text('overlapReason').requiredWhen(when('endDate').lt(when.ref('startDate')))packages/core/tests/when_dsl.spec.ts re-emits every reactive block in
docs/reactivity/fixtures/ from DSL calls and compares it byte-identically
with the committed JSON, and fails if a fixture block has no DSL spelling.
The closure fallback
A closure passed where a rule was expected cannot be serialized. The field is
flagged reactive.refetchSchema: true — it stays validated and fillable, and
the client refetches the descriptor after the state settles. Outside
production the descriptor compiler warns through the app logger (module: 'adonia', §20) naming the resource, the field and the offending call:
resource "posts", field "legacy": visibleWhen(<closure>) passed a server
closure, so the node is flagged refetchSchema:true and the form refetches
its descriptor after every state change. Express the condition with the
when() DSL to stay on the fast path.sets derivations, server side
A client that never ran a derivation still submits, and a hostile one submits
whatever it likes for the target. So the server recomputes every entry from
the submitted sources and overlays the result onto the validated payload for
targets that are in the fill set (R6). A tampered slug loses.
F.text('title').live().sets('slug', 'slugify')
F.toggle('sameAsShipping').live(0).sets('billingStreet', 'copy', {
source: 'shippingStreet',
if: when('sameAsShipping').isTruthy(),
})source defaults to the declaring field; if guards the entry. Built-ins are
slugify, lowercase, uppercase and copy, and they MUST agree
character-for-character with the client’s copies — the fixture corpus is the
shared oracle.
fn:<name> resolves from the server registry:
import { registerTransform } from '@adonia/core'
registerTransform('sumPrices', (items) =>
Array.isArray(items)
? items.reduce<number>((total, row) => total + Number((row as { price?: number }).price ?? 0), 0)
: 0
)Register at boot (a provider boot() hook or a plugin). Transforms MUST be
pure — they run on every submit, and the client runs its own copy on every
keystroke.
An unresolvable fn:* fails compilation, naming the resource, field,
target and transform. The client can only warn and skip; the server can fail
loudly, and it should: silently persisting the un-derived value is the one
outcome nobody can debug from the outside.
What the validator does with a rule
compileValidator folds R1–R6 into one Vine schema (see
packages/core/src/validation/validator_compiler.ts):
- hidden ⇒ absent.
visibleWhenfalse deletes the key before Vine runs and drops it from the validator key set and the fill set (R3). - required ⇒ mirrored. A simple equality becomes Vine’s native
requiredWhen(field, '=', value). Any richer shape — compositeand/or/!, a numeric threshold, a cross-field comparison — becomes a complementaryvine.grouppair, so the key is shape-validated whether or not it is required. - disabled ⇒ never required, never filled.
disabledWhenfolds INTO the requiredness rule rather than sitting beside it, so a disabled input cannot be demanded (R5). - derived ⇒ recomputed. The
setsoverlay is applied last, after validation (R6).
Conformance
packages/core/tests/mirroring.spec.ts runs the WHOLE corpus in
docs/reactivity/fixtures/ — every case, in both modes unless the case pins
one — asserting exact validator/required/fill key sets, that a hidden key
survives neither the validator nor the fill, that a mirrored requirement
holds when the UI is bypassed, and that derivations match. The @adonia/ui
parity suite reads the same files. A new catalogue pattern ships its fixture
in the same PR (plan §5) and is enrolled automatically.