The exact problem: “post code is required when country is United Kingdom” and “country is required when a post code is entered” are both reasonable-sounding rules, and together they are a cycle — a form that can never settle, and that blames whichever field the reader filled second.

Context and Prerequisites

Detection is covered in building a field dependency graph: a failed topological sort is a cycle. This page is about the shapes that create one and the rewrites that remove it, because a detected cycle still has to be fixed.

Three Shapes That Create a Cycle

Mutual requirement. A requires B, B requires A. Neither can be filled first, so the reader is stuck whichever they choose. The intent is almost always “at least one of A or B”, which is not a cycle at all:

// Wrong: two rules, each naming the other, and no valid starting point.
{ id: 'a-needs-b', reads: ['b'], writes: ['a'] }
{ id: 'b-needs-a', reads: ['a'], writes: ['b'] }

// Right: one rule reading both, writing a form-level requirement.
{ id: 'at-least-one', reads: ['a', 'b'], writes: ['__form'] }

Mutual exclusion. “Only one of these may be filled” written as two clearing rules — filling A clears B, filling B clears A — oscillates. One rule that reads both and reports on both terminates:

{ id: 'exactly-one', reads: ['card', 'account'], writes: ['__form'],
  evaluate: (v) => (!!v.card === !!v.account)
    ? { error: 'Enter either a card or an account, not both' } : {} }

Derivation both ways. Price and quantity each computing the other from a total. Pick one direction, mark the other read-only, and let the reader switch which is derived if they genuinely need to.

Three cycles, and the acyclic rule that replaces each Mutual requirement, where A requires B and B requires A, leaves the reader with no valid first move; the intent is at least one of A or B, which is a single rule reading both fields and writing a form-level requirement. Mutual exclusion written as two clearing rules oscillates, because each clear triggers the other; the intent is exactly one, which is again a single rule reading both. Two-way derivation, where each of two fields computes the other, has no fixed point; choose one direction and make the other field read-only, offering a control to switch which is derived if that is genuinely needed. The cyclic shape What goes wrong The acyclic rewrite A requires B, B requires A no valid first move one rule: "at least one of A or B" A clears B, B clears A oscillates forever one rule: "exactly one of A or B" A derives B, B derives A no fixed point one direction; the other read-only The pattern in all three: a relationship between two fields is ONE rule that reads both, not two rules that point at each other. A rule that reads both and writes a form-level result has no outgoing edge to either field, so it cannot participate in a cycle. That is also why the error belongs on the group or the form rather than being duplicated onto both fields. Two rules that point at each other, or one that reads both Two rules pointing at each other each own half the relationship, so neither can be understood alone, both have to be kept in step, and together they form an edge in each direction — a cycle by construction. One rule that reads both fields owns the whole relationship, is testable as a single pure function of two values, and has no outgoing edge to either field because it writes to the group instead. The rewrite is almost always shorter than what it replaces. two rules pointing at each other each owns half the relationship neither is understandable alone an edge in each direction a cycle by construction one rule reading both owns the whole relationship a pure function of two values writes to the group, not the fields no outgoing edge, so no cycle The rewrite is usually shorter than what it replaces, which is unusual for a correctness fix.

Step-by-Step Walkthrough

  1. Write the relationship as one rule. If a sentence mentions two fields, it is one rule reading both — not two rules pointing at each other.

  2. Write to a group, not to the participants. A rule writing __form or a fieldset id has no outgoing edge to either field, so it cannot close a loop.

  3. Choose a direction for derivations. One field is the input; the other is computed and read-only.

  4. Attach the message where the action is. For “exactly one”, that is the group. For “B is required when A is X”, that is B, because B is what the reader must fill.

  5. Assert acyclicity in a test. Over the real rule set, so a new rule that closes a loop fails review.

  6. Prefer structure over rules. A field required only in one mode is often better expressed as a discriminated union than as a conditional rule.

Failure Modes and Edge Cases

1. The cycle only exists for some values

A requires B when A is filled and B requires A when B is filled is a cycle only when both are filled. The graph is value-independent, so it reports the cycle always — which is right: a rule set that can cycle will.

2. Clearing as a side effect

A rule that clears another field’s value creates an edge that is easy to miss, because it looks like housekeeping. Declare it in writes.

3. A three-rule cycle

A affects B, B affects C, C affects A. No pair looks wrong in review; only the sort finds it. This is the strongest argument for the assertion.

4. Visibility rules

Showing and hiding fields creates edges exactly like validation does, and a pair of rules that hide each other is a cycle that manifests as flickering.

5. Requiredness driven by a server response

An async rule that makes a field required, where that field feeds the same request, is a cycle across an await. The graph will not catch it; a bounded retry count will.

Where a group-level error attaches, and what focuses A rule about a pair of fields inside a fieldset attaches its message to the fieldset, is described by an element after the legend, and focuses the first field of the group when the summary entry is activated. A rule about the whole form attaches to the form-level summary, has no field to describe, and focuses the summary itself. Neither case attaches a message to both participants, which would be announced twice and counted twice. Scope Message attaches to Summary entry focuses a pair in a fieldset the fieldset the first field of the group the whole form the form-level summary the summary itself one field that field that field both participants never do this announced twice, counted twice The bottom row is the tempting one, because it looks more helpful — and it makes the summary claim two problems where there is one.

Verification Checklist


Related

Cross-Field Dependency Logic

Frequently Asked Questions

Is a cycle always a bug?

In a rule set, yes. A cycle means there is no order in which every rule sees settled inputs, so whichever field the reader touches first determines the outcome — the form is non-deterministic from their point of view. What is usually not a bug is the intent behind it: mutual requirement, mutual exclusion and two-way derivation are all reasonable things to want, and all three have an acyclic expression.

Where should the error for a mutual rule go?

On the group, not duplicated onto both fields. A message on each of two fields is announced twice and counted twice in the summary, while telling the reader nothing more than one message would. Put it on the fieldset that contains them, which is also where aria-describedby belongs for a group-level problem, and let focus land on the first field of the group.

Can a discriminated union replace a conditional requirement?

Often, and it is stronger when it can. ‘Post code is required when delivery is home’ is a rule that has to be maintained; a union where the home variant has a post code field and the collect variant does not is a shape where the requirement cannot be violated. The type narrows with it, so code reading a collect order cannot even refer to a post code. Use a rule when the branch is not structural — a threshold, a date comparison — and structure when it is.