# Two places, one fact

> When one fact lives in two stores, every writer must update both or the system develops a split brain: the UI shows the setting saved, the enforcer reads the stale copy, and the user is told they never made the choice they just made. The fix is one sync function every writer calls, a read path that derives from the store the enforcer uses, and a test that writes through the user's door and asserts at the gate.

URL: https://biloh.com.au/docs/engineering-notes/two-places-one-fact
Category: Engineering notes | Audience: builder | Updated: 2026-08-07

An operator opened the dispatch board, tried to assign two jobs, and was told there were **no compliant contractors**. Reasonable — except he had, minutes earlier, opened his own contractor portal and ticked the capability boxes for exactly those services. The portal said saved. The dispatcher disagreed.

Nothing was down. No error appeared anywhere. The system had two copies of one fact and had updated the wrong one.

## The shape of the bug

Contractor capabilities lived in two places:

- a **denormalised array column** on the contractor record — convenient for rendering a profile form, and
- a **join table** of contractor-to-service rows — the store the assignment compliance gate queries.

The operator-side edit path had been taught to keep both in step. The **portal** path — added later, a second writer of the same fact — wrote only the column. So the ticks were genuinely saved, genuinely displayed back, and completely invisible to the gate that decides eligibility.

Note what makes this expensive: **every individual component was correct.** The form saved. The database wrote. The gate read. Reviewing any one of them in isolation shows nothing wrong, which is why the defect survives code review and lands in production.

## Why "cache it in a column" grows teeth

A denormalised copy is fine when it has **exactly one writer**. It stops being fine the moment a second writer appears — a self-service portal, a bulk import, an agent tool — because the new writer only knows about the store it was written against.

The durable fix is not to hand-patch the second writer. It is to make the synchronisation **a function, not a habit**:

- one exported `sync` that takes the target set and reconciles the join table — insert new, restore previously-removed rows in place rather than creating duplicates, soft-delete the ones no longer wanted;
- **every** writer of the cached column calls it in the same request;
- the read path derives what it displays from the store the **enforcer** uses, so a drifted column cannot show a user a state the system will not honour.

That last point is the one people skip. If the profile form renders from the column, then a column that drifts for any other reason still shows a comforting lie. Derive the display from the authoritative store and the two can never disagree on screen.

## The read path drifts too

The same incident had a second half. The question *"which contractors can take this job?"* was answered by two different code paths: an agent-facing tool and the dispatcher's own API route.

The tool had been improved months earlier to run the compliance gate over **every** active contractor and return each failure with its reason. The route still used the original approach — pre-filter to contractors holding a capability row, then gate what survives. With zero capability rows, it filtered everything away and returned an empty list with an empty reason set.

So the operator's screen said "no compliant contractors" and could not say why, while the agent asking the same question got a full explanation. One question, two answers, and the worse one belonged to the surface a human actually looked at.

> A write path that drifts corrupts data and eventually gets noticed. A read path that drifts just shows someone less than the truth — and an empty list looks like an answer.

The remedy is the same one used for writes ([one write path, many callers](/docs/engineering-notes/one-write-path-many-callers)): extract a single core, make both callers thin delegates, and add a **source-level test** that fails the build if either caller re-implements the logic locally. Read convergence deserves the same enforcement as write convergence.

## The test that would have caught it

The tests that existed passed. They wrote the column and asserted the column; they called the tool and asserted the tool. Both stayed green while the two halves disagreed.

The assertion that catches this class writes through **the user's real door** and asserts at **the enforcement point**:

```
save capabilities [A, B] through the portal's own endpoint
  → assert the join table holds exactly A and B
  → assert the compliance gate now PASSES for service A
```

That chain crosses the seam. Add its inverse too — untick B, assert B's row is soft-deleted and A survives; re-tick B, assert the original row is restored rather than duplicated — plus a deliberately drifted column proving the read path ignores it.

The general rule: **never assert on the store you just wrote.** Assert on the store that decides something. A test that stays inside one representation of a fact can only ever prove that representation is self-consistent.

## What to take from it

1. One fact, one authoritative store. A cached copy is a rendering detail, never the truth.
2. If a cached copy exists, synchronisation is a shared function every writer calls — and new writers are exactly where the bug enters.
3. Display from the authoritative store, so the interface cannot promise what the enforcer will refuse.
4. Read paths converge like write paths, and lock the convergence with a source test.
5. Test across the seam: write at the user's entry point, assert at the enforcement point.

## Related

- [One write path, many callers](/docs/engineering-notes/one-write-path-many-callers)
- [Why a contractor can't be assigned to a job](/docs/concepts/why-a-contractor-cant-be-assigned)
- [A handler nothing subscribes to does nothing](/docs/engineering-notes/wiring-an-event-to-its-handler)
- [Every record needs a visible bucket](/docs/engineering-notes/every-record-needs-a-visible-bucket)
