Skip to content

Two schemas of the same shape mean different things, and nothing separates them #80

Description

@btravers

From investigating a defect that occurred twice in one day — once in
examples/order-api-contract (#76) and again in a documentation sample that had
copied it (#77).

The defect

The customers fragment declared its NOT_FOUND payload with orderRef:

const orderRef = z.object({ id: z.string() });   // "which order it was about"

const customersContract = {
  find: oc.…errors({ NOT_FOUND: { data: orderRef } }),   // a CUSTOMER id
};

Both schemas are z.object({ id: z.string() }), so nothing separates them.
OrderRef is exported, so a client consuming the contract was told the customers
fragment answers with an order id. Fixed twice by hand, by a reader noticing.

What I checked, and what it rules out

Branding the contract boundary alone catches nothing. Verified: the
controller passes error.id from a domain error, and every domain error carries
readonly id: string. A plain string flows into any branded slot, so no
diagnostic fires.

The brand already exists and is dropped. order.ts and customer.ts define
OrderId, CustomerId, CustomerName and Quantity with z.string().brand(…),
and entities use them — id: Entity.field(OrderId, { immutable: true }). But all
7 error declarations across order.ts, customer.ts and fulfillment.ts
take a bare id: string. The brand is thrown away one line after it is minted.

Branding both sides does catch it. Verified in a scratch file: a CustomerId
in an OrderRef slot is a compile error.

The cost is asymmetric, and that decides the shape

what breaks cost
Error payloads and outputs nothing for a client — it only ever receives these, and a branded string is a string 7 error declarations, ~21 construction sites
Inputs all 21 client call sites: client.orders.find({ id: "o-1" }) stops compiling and becomes OrderId.parse("o-1") high, paid by every consumer forever

Also verified: a branded input genuinely rejects { id: "o-1" }.

Proposal: brand the errors and outputs, never the inputs

That catches this defect at the controller — which contract.ts's own comment
already calls "the one place the two are converted" — and leaves a client's
ergonomics untouched.

It also keeps the contract's stated position mostly intact. "A brand is a
compile-time fiction that does not survive serialization"
stays true; the
fiction is only ever asked of the server, never of a caller.

Most of the ~21 construction sites should be free, since an entity's id is
already OrderId. The ones that cost are where an id arrives as a raw string —
which is exactly where a .parse() belongs.

Why this is not urgent

Both occurrences were caught in review. The blast radius is a mislabelled error
payload, not a wrong query. And the more dangerous version of the same confusion
is not addressed by any of this — see below.

The sharper sibling, worth its own look

Every repository and use case names its tenant positionally:

OrderRepository.find(tenantId, id)
PlaceOrder.execute(tenantId, id, quantity)

Two strings, adjacent, in that order. Swapping them type-checks today and returns
another tenant's row — or none — rather than mislabelling an error. Branding
TenantId alongside OrderId would close that, and it is the case where the
consequence is real rather than cosmetic.

Note this is the same class of problem #65 just fixed for provider dependencies
(positional deps silently rebinding when the service shapes match). This is the
same hazard one layer down, in the domain's own argument lists, and #65's fix
does not reach it.

Acceptance

  • Domain errors carry branded ids; the contract's error-payload and output
    schemas match them.
  • Contract inputs stay unbranded, with the reason recorded next to the
    decision so it is not "tidied" later.
  • A type test pins that a CustomerId cannot be passed where an OrderId is
    declared — the exact defect, made a compile error.
  • A decision on TenantId, either here or in its own issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Wanted, not urgent

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions