Skip to content
SynthoCore Automation Inc.
  • Home
  • SynthoDesk
  • SynthoFlow
  • SynthoOps
  • Company
  • Engineering
  • Pricing
  • Contact
Sign in

Engineering

Two accounts, or it didn't happen: proving isolation with two-subject tests

Published September 3, 2026 · SynthoCore Automation Inc.

Sign in as one customer, load a page, and see exactly the records that belong to that customer — no more, no less. It is a satisfying test to watch pass, and it is the test most teams reach for when they want to prove that one customer’s data stays walled off from another’s. But it proves nothing of the sort. It shows that a subject can see its own data. Isolation is the opposite claim — that a subject cannot see anyone else’s — and no amount of watching one account behave correctly can establish a fact about the boundary between two. A single-subject test is a statement with only one party to it, and isolation is a relationship. To test a relationship you need both ends of it present at once.

We hold to a plain rule for anything that claims to keep tenants, sessions, or customers apart: two accounts, or it didn’t happen. If the test suite has only ever exercised one subject, the isolation property is unproven, however green the run.

What a single-subject test actually measures

Frame the boundary as two questions the system must answer correctly. First: does subject A see everything that belongs to A? Second: does subject A see anything that belongs to B? These are independent. A query can answer the first perfectly and the second catastrophically — returning all of A’s rows and a stray handful of B’s, or every row in the table when a filter silently drops off. A one-account test only ever asks the first question. It confirms completeness for the subject under test and stays completely silent on leakage, because there is no second subject whose data could leak in for the assertion to catch.

The failure hides comfortably in that silence. Seed a test database with a single account and its records, sign in, and assert the page shows those records. The assertion passes whether the read is correctly scoped or scoped to nothing at all — when the table holds only one subject’s data, “return everything” and “return only mine” produce byte-for-byte the same result. The test cannot separate a working boundary from a missing one, because the fixture never contained anything on the far side of the boundary to reveal the difference.

The two-subject pattern

The fix is structural, not a matter of more assertions. Populate the world with two independent subjects, each carrying data the other must never see, then drive the system as one and check what it can reach of the other. The shape is always the same: establish A and B, act strictly as A, and assert two things — that A gets all of A, and that A gets none of B.

// Arrange — two independent subjects, each with its own data const a = createSubject(); seedRecords(a, ["a-1", "a-2"]); const b = createSubject(); seedRecords(b, ["b-1", "b-2"]); // Act — strictly as A const seen = listRecords({ as: a }); // Assert — completeness AND non-observation assert seen == ["a-1", "a-2"]; // A sees all of A assert seen intersect b.records == {}; // A sees none of B // And the direct-reference probe: A must not fetch B by id assert getRecord({ as: a, id: "b-1" }) == DENIED; // not 200-with-body

That last line matters as much as the list check. Filtering a collection is one code path; fetching a single object by its identifier is often another, and a boundary can hold on the first while leaking on the second. So the probe asks for B’s record by reference while acting as A, and requires a refusal — a not-found or forbidden, never a success with B’s body attached. The two-subject arrangement is what gives that probe teeth: “b-1” is a real, resolvable object owned by someone else, exactly the input a broken boundary hands over and a sound one denies.

Note also that we assert a denial, not merely the absence of B’s data from a list. Absence can be an accident of ordering, pagination, or an empty seed; a denial on a known-present foreign object is a positive signal that the boundary saw the attempt and refused it. Where it fits, run the mirror case too — act as B and confirm B cannot reach A — because a boundary that leaks in only one direction is still a boundary that leaks.

“The query filters by tenant” is a claim about code, not a proof about behaviour

The most common objection to two-subject tests is that they are redundant: the query already filters by tenant, you can read the where clause right there, so of course B’s data cannot come back. This confuses a claim about the code with a proof about the behaviour, and the gap between them is precisely where isolation bugs live.

“The query filters by tenant” is true of the line you are looking at. It says nothing about the code path that skips that query under a cache hit, the second endpoint added last quarter that forgot the clause, the admin view that widens the scope on purpose, the join that reintroduces rows the filter removed, or the migration that renamed the column the filter depends on so the condition now compares against null. Every one of those leaves the reviewed line untouched and correct while the system’s behaviour changes underneath it. Reading the filter verifies the filter. It does not verify that every route to the data passes through the filter, and isolation is a property of every route, not the one you happened to read.

A two-subject test asserts the behaviour instead of the mechanism. It does not care how the boundary is implemented — row filter, separate schema, per-customer store, a check in the handler — only that, observed from outside, one subject cannot reach another’s data. That is the property customers actually care about, and it is the one that keeps holding when the implementation is refactored, cached, or extended by someone who never read the original where clause. When the boundary later springs a leak, the behavioural test goes red; the code-reading never had the chance to.

Where this applies

Anywhere the system’s job is to keep one subject’s things away from another’s, the same two-subject discipline holds. Multi-tenant data is the obvious case — two tenants, assert neither can list or fetch the other’s rows. Session scoping is another: two concurrent sessions, assert one cannot read or act on state belonging to the other, and that a token issued for one does not resolve under the other. Per-customer stores — a bucket, a namespace, a key prefix, a separate database per customer — want the same treatment, because a mistyped prefix or a shared default resolves to a real neighbour’s store, and only a second populated store makes that mistake visible. The unifying idea is that isolation is never a property you can confirm from the inside of one subject; it is only ever visible from the seam between two.

The through-line

A test that exercises one subject measures whether that subject is served. Isolation is a different question entirely — whether the wall between subjects holds — and a question about a wall cannot be answered from one side of it. So we build the second subject into the fixture, act as one, and require that the other stays invisible and unreachable, by list and by direct reference alike. We test the behaviour at the seam rather than reading the filter, because the filter is a claim and the seam is the proof. Two accounts, or it didn’t happen: until a second subject’s data has sat on the far side of the boundary and been denied, the isolation is asserted, not demonstrated.

If this is the kind of rigour you want in how your systems are built and reviewed, get in touch.

SynthoCore Automation Inc.

Engineering-automation software — SynthoDesk, SynthoFlow and SynthoOps.

Email us: info@synthocore.ca

Products

  • SynthoDesk
  • SynthoFlow
  • SynthoOps
  • Pricing

Company

  • About SynthoCore
  • Governed AI
  • FAQ
  • Engineering
  • Contact
  • Privacy Policy
  • Terms of Service
© 2026 SynthoCore Automation Inc. All rights reserved. Legal