ADR 0032: The id names are `NewType`s

Status

Accepted. **Amends

0002 two store ports** and

0006 the public surface is gated in

typing only: the ports' method signatures are unchanged as written, the

exported set is unchanged, and every runtime contract either of them states is

untouched. What changes is that the four names the ports are written in are

now distinct to a type checker.

0001 event log schema and granularity

stands unamended, and that is the load-bearing part: NewType has no

representation of its own, so no persisted event, no Neo4j property and no

Postgres column changes. An event log already written cannot be migrated, and

this decision was only available because it does not ask to.

Context

redstring.domain.ids declared four names as bare aliases:


EntityId = UUID
RelationshipId = UUID
TenantId = UUID
SourceId = str

Three of them were the same object. EntityId is TenantId was true, and

the module's own tests asserted exactly that — assert EntityId is uuid.UUID,

four times, which is a test that cannot distinguish four names from one and

passes identically if three of them are deleted.

The reference documentation was honest about the consequence: "passing a

TenantId where an EntityId is expected is not a type error. Nothing in the

library guards against it." The distinction was described as documentary.

**The swap those aliases fail to prevent is the one this codebase has already

shipped.** Every store port keys on (tenant_id, id) and takes both as

arguments, adjacent, of what was one type:


async def get_entity(self, entity_id: EntityId, tenant_id: TenantId) -> Entity | None: ...

Transposing those two arguments is a tenant-isolation defect — one tenant's

read answered from another's data — and it type-checked cleanly. CLAUDE.md's

table of test shapes that prove nothing carries the matching row: *"ids drawn

from uuid4(), never colliding across tenants — a (tenant_id, id) key

compared on id alone: one tenant's write vouches for another's."* That row

was filed because the defect happened, in a fix round that cited the table, to

an implementer who had just read it. A rule stated in prose did not survive

contact with a habit. A type checker is not a habit.

Decision

Declare all four as NewType over the base they already had.


EntityId = NewType("EntityId", UUID)
RelationshipId = NewType("RelationshipId", UUID)
TenantId = NewType("TenantId", UUID)
SourceId = NewType("SourceId", str)

NewType and not a wrapper class, and the distinction is the whole reason

this is affordable. NewType compiles to the identity function: TenantId(u)

is u, so isinstance checks, dict keys, uuid5 seeding, pydantic

validation, JSON serialisation and every existing caller passing a bare

uuid4() all behave exactly as before. A wrapper class would have changed all

of those, and would have needed a migration of the event log to go with it.

The asymmetry is what keeps the annotation burden small. A TenantId is a

UUID to mypy, so passing one anywhere a plain UUID is expected is fine; a

plain UUID is not a TenantId, so producing one names the role. The cost

therefore lands exactly at the boundaries where a raw UUID enters the domain

and nowhere else — a row read back from Neo4j, an id minted by uuid5 in

extraction/mapping.py, and event.tenant_id, which is typed by

eventsource-py's TenantDomainEvent and cannot be annotated from here.

Consequences

**The library's own type checking is what enforces this, and it already

covers every module.** mypy --strict runs over all of src/redstring with

no exclude (see

0014), so a

new argument transposition is a gate failure rather than a review catch.

tests/ is not type-checked, which bounds both the benefit and the cost: no

test had to change, and a test can still transpose the arguments freely.

Downstream callers get it too, and only because of the py.typed marker.

A NewType in a dependency is invisible to a consumer's type checker unless

the installed package declares itself typed. That marker exists, and

tests/integration/test_wheel_contents.py asserts it survives packaging —

which is the reason this decision buys anything outside this repository

rather than only inside it.

It is not validation, and should not be mistaken for it. EntityId(x)

checks nothing at runtime. What it prevents is a well-formed id of the wrong

kind reaching a position that expects another; what it does not prevent is a

malformed one, an id belonging to a deleted entity, or an id from the wrong

tenant passed knowingly. Tenant isolation is still asserted behaviourally, by

the never_crosses_tenants case every store port's compliance suite carries

per read method.

**Three projections now call an id constructor for a value they did not

build.** TenantId(event.tenant_id) in projections/graph.py and

projections/chunk.py is the seam between the event framework's vocabulary

and this library's. It reads as ceremony and is not: it is the one place the

two type systems meet, and writing it out is what stops the alternative —

widening the port signatures back to UUID — from looking like the tidier

option.

The alternative of a wrapper class stays closed for a stated reason.

Validation on construction is occasionally proposed for id types, and it would

buy a genuine thing: EntityId("garbage") failing loudly. It costs a changed

wire format, a changed isinstance contract, and a migration of every

persisted event, which is a price

0001 says outright is not

payable. If ids ever need validation, it belongs in a validator on the

pydantic models that carry them, not in the id type.