Skip to content

A tenant and an id are both strings, and swapping them queries the wrong tenant #81

Description

@btravers

Split out of #80, which covers the cosmetic half of the same class. This is the
half where the consequence is a wrong row rather than a mislabelled payload.

The hazard

Every repository and use case names its tenant positionally, next to a string
that is not a tenant:

OrderRepository.find(tenantId, id)
OrderRepository.remove(tenantId, id)
CustomerRepository.find(tenantId, id)
PlaceOrder.execute(tenantId, id, quantity)
FindOrder.execute(tenantId, id)

Verified: swapping them compiles.

declare const find: (tenantId: string, id: string) => void;
find(orderId, tenant);   // no diagnostic

Two adjacent strings in a fixed order, and the compiler has nothing to say. The
result is not a wrong error payload — it is a query scoped to the wrong tenant,
in an application whose entire multi-tenancy story rests on that argument being
right. Outbox.pending(tenantId, limit) is the same shape with a number beside
it, which is the one variant TypeScript would catch.

Why branding the tenant alone is enough

Verified: branding only the tenant makes the swap a compile error, and every
id can stay a plain string.

declare const find: (tenantId: TenantId, id: string) => void;
find(orderId, tenant);   // Argument of type 'string' is not assignable to 'TenantId'

That is the whole economy of this proposal, and why it is much cheaper than #80:
a pair only has to differ in one position to become unswappable. There is no
need to brand OrderId or CustomerId to close this.

The price

  • 21 tenantId: string positions in order-application's ports and use
    cases become TenantId.
  • A handful of boundaries parse once, and they are already the places each
    transport converts: order-api's two controllers (from
    context.principal.tenantId and from input.tenantId), the AMQP handler's
    envelope, the Temporal activities' args.tenantId, and OUTBOX_TENANTS in
    the relay's config.
  • Contract inputs stay string. No client call site changes — the parse
    happens on the server, at the boundary, which is where a validation belongs
    anyway. This keeps the position contract.ts already states: a brand is a
    compile-time fiction, and it is never asked of a caller.
  • TenantId joins OrderId, CustomerId and Quantity, which
    order-domain already defines with z.string().brand(…). No new mechanism.

The relationship to #65

#65 fixed exactly this hazard for provider dependencies — positional deps
silently rebinding when two service shapes happened to match — by naming them.
This is the same hazard one layer down, in the domain's own argument lists, and
#65's fix does not reach it: a use case's parameters are a function signature,
not a deps record.

Naming is not available here the way it was there — a repository method taking
{ tenantId, id } is a different and larger API change. Branding is the cheap
equivalent: it does not name the arguments, it just makes two of them
un-confusable.

Acceptance

  • TenantId defined in order-domain beside the ids already there.
  • order-application's ports and use cases take it.
  • Each transport parses once at its own boundary; contract inputs unchanged.
  • A type test pins that a plain string — and an order id — cannot be passed
    where a tenant is declared.
  • A decision on whether Outbox.pending(tenantId, limit) and the relay's
    OUTBOX_TENANTS config parse at the same boundary or earlier.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Should land before 1.0 — real DX cost, or a decision blocking other work

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions