Skip to content

ADR 0092: A condition travels with the form

Status: Accepted — amended 2026-08-19 (an operand names one thing)

Five public slots take a JavaScript function:

Slot Declared in
MdyFieldOptions.when packages/core/src/typed-form.ts
MdyFieldOptions.asyncWhen packages/core/src/typed-form.ts
MdyGroupOptions.when packages/core/src/typed-form.ts
MdyAsyncValidatorOptions.when packages/core/src/types.ts
serverValidator({ when }) packages/core/src/server-validator.ts

A schema holding one of them is not data. It cannot be stored, sent, versioned, diffed, or checked anywhere other than the process that built it, and a document arriving at runtime cannot carry one at all without becoming a remote-code-execution hole — which is why the dynamic contract never offered the option and says so in expression.ts’s own first paragraph.

The consequence is a framework split down the middle. The typed schema expresses a condition as code; the document expresses it as data, through MdyExpression — a closed tree of sixteen enumerated operators over {path} operands and literals, depth-capped at 32, with a cost gate on patterns, validateExpression to report a malformed one and expressionPaths to derive what it reads. The two halves say the same things in two languages, and the seam is where capability is lost rather than where it is translated:

  • Studio holds a skipWhen as a serializable StudioExpression and then prints a closure, because serverValidator() accepts nothing else.
  • When that expression names a field other than its own, Studio discards it and reports UNSUPPORTED_SKIP_WHEN — a capability the model has and the boundary cannot carry.
  • A document has no per-field when and no condition relative to a collection row. It has form-level rules targeting a leaf. What the typed form can say, the document cannot.

Measured rather than assumed: every non-test when and asyncWhen in this repository — examples/plain, examples/lit, examples/angular, docs/guides/typed-forms.md, docs/examples/typed-forms, packages/studio-preview — was classified individually against the existing operator set. All of them are expressible: equals on a path, equals on a nested path, notEquals on the field’s own value, lengthAtLeast on the field’s own value, matches with a literal pattern, not around an expression. None requires an operator that does not exist. About half require a reference to the field’s own value, which the language does not have.

A condition is data. when and asyncWhen accept an MdyExpression and nothing else, on the typed schema and in the document alike, so a form’s conditions travel with the form.

Three operand forms are added beside {path}, and they are the whole of what the language gains:

  • { self: true } — the value of the field the clause is written on.
  • { root: true } — the whole form value.
  • { context: "key" } — a fact supplied by the host application.

An expression is evaluated against what encloses the clause. Inside a collection row that is the row, for both clauses, so a rule written once for an item reads its own row and cannot name a key or an index. { root: true } is how a row-level condition reaches back out to the form.

A host supplies its context once for the application, not once per form — role, tenant, today’s date, feature flags: facts the app has regardless of which document arrives. A document declares the context keys it reads. A document naming a key the host does not supply is refused before anything is painted, and the contract version carries the declaration, because a context is an API between the application and whoever authors documents for it.

An expression is written in TypeScript through a builder that produces exactly that tree and types its paths against the schema. There is no string syntax and no parser.

The operator set grows only when a real predicate requires it. Today’s measurement requires none.

Amendment: what the document half looks like, shipped

Section titled “Amendment: what the document half looks like, shipped”

Batch 1 (the three operands) and batch 2 (the document) are in. What batch 2 turned out to be is worth recording, because it is smaller than the decision above suggests:

  • A node’s own when — on a field and on a group — and asyncWhen on a field. Not on a collection: the typed descriptors a document compiles into carry a condition at those two levels, and a collection that must come and go is a collection inside a group that says when. One spelling rather than two that mean the same.
  • Contract v4: MdyDynamicFormConfigV4 adds version: 4 and requiresContext. Everything else is v3’s, so a v3 document is a v4 document with the version raised, and rules is untouched.
  • No public slot changed type. buildDynamicFormSchema(schema, { context }) compiles each expression into the closure MdyFieldOptions.when already takes. That is what makes the document half additive and shippable before the breaking batch — and it means the two halves are running side by side, which is the state a migration wants rather than a flag day.
  • MdyCondition gained the whole form value as a third argument, because { root: true } from inside a row had nothing to read otherwise: a clause in a row is enclosed by the row.

The parser reads a clause against what encloses it — inside item that is the row’s own cells — and refuses a path nothing there declares, an expression that is not one (MDY_DYNAMIC_INVALID_CONDITION), and a context key the document did not declare (MDY_DYNAMIC_UNDECLARED_CONTEXT). A key the host does not supply refuses the build, which is the “before anything is painted” this record asks for.

This also closes what was registered as a limit rather than a defect: a document can now condition a cell inside a collection row, which no rules entry could express.

An operand is an object naming one of four things — a field ({path}), the value the clause is written on ({self}), the whole form ({root}), or a value the host supplies ({context}). One carrying two of them was claimed by two guards at once and accepted by the validator, so which half it meant was decided by the order a reader happened to ask in. That is a document meaning one thing in this reader and another in the Rust or Java reader of the same contract, on a document all three accept — and no fixture carries the shape, so the shared corpus would not catch the disagreement.

An operand naming more than one of the four is refused where an expression is validated, and each guard answers false for it: an ambiguous operand is not read as either half. A context key of no characters names nothing a host could supply, so it is not a context reference either — the guard and the validator agree, where before one claimed what the other turned away.

This is a breaking change, released major, with a changeset carrying the closure-to-expression translation table. Sixty-four call sites in this repository change, about ten of them outside tests.

A condition can no longer be impure, because there is nothing left to be impure with. The dev-time check in composeConditions that catches a predicate disagreeing with itself becomes unreachable by construction and is removed with the tests that exercise it.

Studio stops degrading: schema-mapper prints the tree instead of a closure, and UNSUPPORTED_SKIP_WHEN — a diagnostic that existed only to describe this boundary — is deleted along with the JavaScript compiler behind it.

What becomes harder: a condition needing host logic can no longer be written inline. In a typed form the host computes the fact into a field and the condition reads that field; in a document it reads a context key. A condition that needs something neither of those can supply is not expressible, and that is the intended shape of the limit rather than a gap to be filled later — a document the application has never seen cannot invoke code the application wrote for it.

asyncWhen changes root. Inside a collection row it reads the row where it used to read the whole form. This change is silent in the type system: the expression still compiles, the path simply does not resolve, and the clause decides false — which skips the server call. It is the sharpest edge of this decision and is guarded by a test rather than by a note in a guide.

The migration is staged, because sixty-four sites in one commit guarantees a broken intermediate: the operands first (additive, green), then the document contract (additive), and the five slots last.

Widen the type to Predicate | MdyExpression and deprecate the function. Additive, classified minor, breaks nobody today. Rejected because it leaves two spellings for one thing for a whole major cycle, and every reader in between has to know which wins — the position the contract already refuses elsewhere, in as many words, for the row track count in v3.

A separate whenExpression option. The same two spellings, permanently.

A registry of named host predicates, where a document names a predicate and the host registers the implementation. Rejected because it presupposes that the application knows the form. For a document arriving at runtime that is false by definition: an application cannot have registered a predicate for a document it has never seen. It would have worked only for the typed half, which is the half that needs it least.

Precomputing the fact into a form field. Rejected as the general answer for the same reason, and for the same half. It remains the right technique in a typed form, where the host does know the schema, and it is what the guide teaches there — but it is not a mechanism, so it is not this decision.

A string syntax parsed into the tree. The most readable to write, and the place an editor plugin would offer completion. Rejected because a parser is a second hostile entry point, held to the same standard as a path — and because it is a second spelling of a language that already has one. The tree stays canonical; readability is the builder’s job, where the compiler can also reject a path that does not exist.

npm run contract:diff classifies the change major. Should it disagree, the differ is reported before the change proceeds rather than after.

Under battle-tests/adversarial/, red before the change and green after:

  • a when naming a context key the host did not supply closes the field and reports a diagnostic — never the direction that opens, the property an-operator-nobody-declared.battle.test.mjs already defends for unknown operators;
  • { self: true } inside a collection row reads that row’s cell, not the first row’s;
  • { root: true } inside a row reads the form and not the row;
  • asyncWhen inside a row reads the row — the behaviour change above, pinned so a regression relights it;
  • a v2 document with rules decides identically after the contract gains v4;
  • each closure in the measurement gives the same verdict as its translation over the same values, as a differential rather than a single assertion — Object.is against === diverges on NaN and on -0, which is where a translation can be wrong while looking right.

npm run test:core, npm run battle, npm run test:contract-schema (the published JSON Schema against the parser), npm run test:studio, npm run test:guides.

The direction of travel is toward safety and the reason the dynamic contract was built this way: a condition that is data cannot execute, and a document from an untrusted source gains no new capability by carrying one. {path} operands pass isSafeFieldPath as they already do, so a document asking about constructor is refused rather than answered from the prototype behind the form. The operator set stays closed, the depth cap and the pattern cost gate stay where they are, and an unknown operator still decides false.

{ context: "key" } is the new surface and it points the other way. A context carries role, tenant and entitlement — the facts an application uses to decide what a person may see — into an expression that may sit in a document written elsewhere. Two obligations follow. A context value is read by a condition and never written into the form value, so it does not reach the payload, the draft or the devtools panel by that route; sensitive protects fields and has nothing to say about a context, which is why the boundary is drawn at the value instead. And a condition is not an access control: hiding a field on {context:"role"} decides what is asked, not what is permitted, and a server that trusts a document’s conditions to enforce a permission is trusting input it received from the client.