Infinite Graph
Journal

JOURNAL

For Graph Software, Test What Must Never Become False

Graph invariants define what must remain true across imports, edits, merges, deletions, and synchronization—and reveal whether a product's underlying model is coherent.

For graph software, testing examples is useful. Testing what must never become false is often better.

Most application testing begins with examples.

Click a button, and a modal should open.

Import a file, and twelve nodes should appear.

Delete an item, and it should disappear from the screen.

These tests are valuable because they verify observable behavior. They tell us whether a particular input produces the expected output under a particular set of conditions.

But graph software has an uncomfortable property: the number of possible states grows much faster than the number of features.

A graph is not merely a collection of objects rendered on a canvas. It is a system of entities, relations, identities, revisions, sources, user edits, imports, deletions, synchronization events, and derived state. Each operation can interact with several others, and the correctness of one operation may depend on events that happened much earlier.

That makes example-based testing necessary, but insufficient.

The problem is not the happy path. It is the state space.

Suppose an import test verifies the following:

Import document → 12 nodes created

The test passes.

But what happens when the document is imported twice?

What happens when the first section is inserted before every existing section and the document is imported again?

What happens when a heading is renamed?

What happens when one imported node has subsequently been edited by the user?

What happens when a relation points to a node that is later deleted?

What happens when two import passes independently conclude that two objects represent the same underlying entity?

Every feature adds another dimension to the state space.

Eventually, it becomes unrealistic to enumerate every meaningful combination as a collection of handcrafted examples.

This is where graph invariants become important.

An invariant is not a prediction about one interaction. It is a condition that must remain true across every valid state of the system.

For example:

  • A dangling edge must never exist.
  • A relation must not continue pointing to a deleted entity.
  • A source-backed node must retain a traceable origin.
  • Revision numbers must never move backward.
  • Two canonical nodes must not simultaneously represent the same identity.
  • Re-running an idempotent import must not create a second semantic copy of the same object.

These statements operate at a different level from conventional UI assertions.

They do not say what one screen should look like after one action.

They define what the product is allowed to become.

Invariants turn testing into a model of the product

This distinction matters because complex graph products are essentially state machines, whether or not their codebase explicitly describes them that way.

A user may create a node.

An importer may create another node.

A reconciliation process may determine that both nodes refer to the same entity.

The user may then modify one version.

A synchronization event may arrive from another device.

One source may disappear.

Another source may be re-imported with a different structure.

The graph now has to answer questions about identity, authorship, provenance, deletion, ownership, revision order, and conflict resolution.

If the system has no explicit rule governing those questions, the implementation will still produce an answer.

It will simply produce one accidentally.

That is one reason invariants are more than a testing technique. They are a method of discovering whether the underlying product model is coherent.

If I cannot state what must remain true after import, edit, merge, delete, sync, and restore operations are composed together, then I probably have not fully defined what a valid graph state is.

The test suite is exposing a product-design problem, not merely a QA problem.

Example tests verify events. Invariants verify reality.

There is a useful conceptual distinction here.

An example test might ask:

After deleting Node A, is Node A absent from the UI?

An invariant asks:

Is it possible for any surviving object in the graph to reference an entity that no longer exists?

The first question concerns an event.

The second concerns the integrity of the world after the event.

Likewise:

Did importing this document create the expected nodes?

is an example-level question.

But:

Can every source-derived node still be traced back to the source state that justified its existence?

is an invariant-level question.

The difference becomes especially important once persistence is involved.

A renderer can temporarily hide an invalid object and make the interface appear correct. A malformed relation can remain in storage unnoticed. A stale revision can survive a synchronization cycle. A duplicate identity can exist beneath a UI that currently renders only one visible representation.

From the user's perspective, the product may seem fine.

From the system's perspective, corruption has already occurred.

For stateful software, correctness therefore cannot be defined only at the presentation layer.

It has to be defined in the data model.

The strongest invariants often sit at architectural boundaries

While building Infinite Graph, I have increasingly found that the most important invariants tend to appear where subsystems meet.

Renderer and persistence.

Import and user edits.

Identity resolution and duplication.

Deletion and relations.

Local state and synchronization.

Source provenance and derived nodes.

These boundaries are where a locally reasonable operation can produce a globally invalid state.

For example, deleting an entity is trivial if the entity exists in isolation.

Deleting an entity that participates in fifteen relations, originated from an imported source, has local user modifications, exists in revision history, and may still be represented on another device is not a CRUD operation anymore.

It is a graph transition.

The relevant question becomes not simply:

Did delete() succeed?

but:

Which states must be impossible after delete() succeeds?

That change in framing has surprisingly large consequences.

It influences schema design.

It influences transaction boundaries.

It influences whether IDs are mutable.

It influences whether deletions are hard deletes, tombstones, or suppressed states.

It influences how imports are reconciled.

It influences which subsystem is permitted to mutate canonical data.

And it influences what should happen when the system cannot prove that an operation preserves integrity.

In other words, invariants are architecture expressed as constraints.

“Impossible states” are often a better product specification

Feature specifications usually describe capabilities:

Users can import documents.

Users can connect nodes.

Users can delete entities.

The system can synchronize changes.

The system can suggest relationships.

Those statements tell us what software should be able to do.

They say much less about what it must refuse to become.

For a simple application, that distinction may not matter much.

For a graph system, it does.

Consider the requirement:

Users can edit imported nodes.

This immediately raises questions.

Can the next import overwrite the edit?

If not, where is the boundary between source-owned state and user-owned state?

If the source changes, should the imported object fork, merge, conflict, or preserve the user modification?

Can an AI-generated suggestion modify it?

Can another device modify it?

Which version is canonical?

The feature description alone cannot answer these questions.

An invariant might.

For example:

A user-authored modification cannot be silently destroyed by a source reconciliation pass.

That statement is much more constraining.

It forces the system to choose a conflict model.

And that is precisely why invariants are useful: they convert vague product intentions into falsifiable structural rules.

They also change how bugs are understood

Without invariants, bugs often look unrelated.

A duplicate node appears after re-import.

A relation survives deletion.

A sync operation resurrects an old object.

A renamed heading creates an unexpected second entity.

These can easily become four separate tickets.

But if they violate the same deeper rule—for example, that canonical identity must remain unique across reconciliation—they may actually be manifestations of one architectural defect.

An invariant gives you a higher-level unit of diagnosis.

Instead of asking, “Why did this particular node duplicate?”, you can ask:

Under which transitions can identity uniqueness be violated?

That question is much more valuable.

It turns debugging from symptom removal into model correction.

This does not mean replacing example tests

Example tests remain essential.

Users still click buttons.

Imports still need expected outputs.

Specific regressions still need permanent test cases.

The point is not to replace conventional tests with abstract assertions.

The point is to layer them.

Example tests verify known scenarios.

Regression tests preserve lessons from known failures.

Invariant tests verify structural integrity across many scenarios.

Property-based or generative testing can then go further by producing combinations of operations humans would rarely think to write manually: create, connect, import, rename, delete, restore, re-import, synchronize, merge.

The sequence itself may be arbitrary.

The invariant is not.

After every sequence, the graph should still obey its laws.

That is a much stronger definition of correctness than “the twelve examples we anticipated still pass.”

The harder question is deciding what your laws actually are

Writing invariant tests is not necessarily the difficult part.

The difficult part is deciding which statements deserve to be invariants.

Some are obvious:

No dangling edges.

Others encode deep product decisions:

User-authored state always dominates machine-derived state.

Every source-derived claim must remain provenance-addressable.

Canonical identity must be globally unique within a workspace.

A synchronization event may resolve conflicts, but may never silently discard a committed user revision.

Each statement closes off entire regions of the state space.

That is powerful, but also dangerous if the rule is wrong.

An invariant should therefore not be treated as a convenient implementation assumption.

It should be treated as part of the product's constitution.

Once a system becomes sufficiently complex, this may be one of the clearest ways to distinguish a collection of features from an actual model.

A feature tells you what the product can do.

An invariant tells you what the product is.

And for graph software, that distinction becomes increasingly important as the number of possible interactions exceeds anything a team can enumerate manually.

A graph feature isn't finished until you know what states it must make impossible.

So the question I now find more interesting than “What should this feature do?” is:

What are the invariants of your product—the things that must remain true no matter how users combine its features?