Skip to content

feat(examples)!: a tenant is not a string, and an id is a UUIDv7 - #84

Merged
btravers merged 11 commits into
mainfrom
feat/uuidv7-ids-and-branded-tenant
Aug 21, 2026
Merged

feat(examples)!: a tenant is not a string, and an id is a UUIDv7#84
btravers merged 11 commits into
mainfrom
feat/uuidv7-ids-and-branded-tenant

Conversation

@btravers

Copy link
Copy Markdown
Contributor

Closes #81.

A tenant is not a string

Every port in the example application named its tenant positionally, next to a
string that is not one — find(tenantId, id), execute(tenantId, id, quantity)
— and two strings in a fixed order are what the compiler has nothing to say
about: the swap compiled and queried the wrong tenant.

TenantId (order-domain) brands one half of each pair, which is all it takes
for the pair to become unswappable. The ids stay string; branding them is a
separate question (#80). The constructor is a cast, not a parse — the value
arrived through a contract that already validated it, and .parse() throws.

Each path claims the brand exactly once: bearerAuthenticator for the marked
HTTP half, TenantId(input.tenantId) for the unmarked one, each Temporal
activity's own input, and tenantsOf for the relay's OUTBOX_TENANTS — which
answers the issue's last acceptance item: deployment configuration is trusted at
the config boundary rather than parsed per sweep. The AMQP handlers claim
nothing, because neither calls a port that names a tenant. prisma-outbox is
the one read-back, so the one place the brand is re-applied rather than carried.

Contract inputs are unchanged — no client call site moves.

examples/order-application/src/tenant.test-d.ts is the gate: the id in first
position is a @ts-expect-error on both repository.find and
placeOrder.execute.

An id is a UUIDv7

Declared once on the entity (OrderId, CustomerId) and again on each
contract's schema, so a malformed id is refused at the transport before a use
case sees it. That format gave placeOrder a second way to fail: while the
id was an unconstrained string the quantity was the only field a typed caller
could get wrong, so collapsing Order.make's InvalidEntity to
InvalidQuantity was sound — with a format it became a mislabelling.
InvalidOrderId is the arm that fixes it, told apart from InvalidQuantity by
which field the entity named (Entity.keysOf over the issue's path), never
by message text. Each transport carries the third arm: BAD_REQUEST over HTTP,
a nonRetryable InvalidOrderId on Temporal, a NonRetryableError on the
queue.

Gate

All six green from the root: format --check, lint, typecheck (31/31),
knip, test (30/30, ~49s), build.

No changeset: nothing under packages/ changed but three READMEs.

Ids are still z.string() at this point, so this is a pure literal sweep:
every short order/customer id ("o-1", "c-1", "order-1", ...) across the
examples and docs becomes its mapped UUIDv7, per
.superpowers/sdd/2026-08-21-uuidv7-ids-and-branded-tenant/uuid-map.md.
Non-literal occurrences (error-message assertions, derived strings like
`auth-${orderId}`, doc sample output) moved with their inputs so no
test or sample went false. The tightening to a UUID format is Task 3's job.
packages/observability/README.md and docs/reference/observability.md both depict
examples/order-application's log line, so sweeping one and not the other is the
drift CLAUDE.md warns about. packages/di's sample is its own -- it appears
nowhere else and no di docs page carries a matching id -- and stays.
OrderId and CustomerId now parse as z.uuidv7().brand(...), and the three
transport contracts (order-api, order-amqp, order-temporal) tighten every
id- and tenant-shaped z.string() field to z.uuidv7() to match. Task 2 swept
every literal id and dynamic tenant fixture ahead of this; tightening turned
up a handful it missed — fixed alongside the schemas, detailed in
task-3-report.md.
placeOrder's TSDoc argued its InvalidEntity -> InvalidQuantity translation was
total "with an unconstrained OrderId". Giving OrderId a UUIDv7 format ended
that: placeOrder("o-1", 2) answered "asks for 2 items, which is not a positive
quantity" about a field the caller got right -- the mislabelling this branch
exists to remove.

InvalidOrderId is the second error, discriminated on which FIELD the entity
named rather than on message text: a schema issue carries a path, an
Entity.invariant violation carries none, and Entity.keysOf reads that path as
plain keys. When both fields are wrong the id wins.

Per transport: order-api answers BAD_REQUEST, because a malformed id is the
caller's mistake and 409 would point at the server's data; order-temporal
declares it nonRetryable on both the activity and the workflow, because a bad
id will never become good; order-amqp changes nothing -- its slices react to a
committed fact, so a placement's Err never crosses the broker. The error's
payload is a bare-string ref on both contracts: validating a malformed id
against z.uuidv7() would reject the only payload it ever carries.

Both new arms are exhaustiveness rather than live routes -- each contract's own
input schema refuses a malformed id first -- and the TSDoc, both READMEs and
the fourteen documentation samples say that the earlier decision was superseded
rather than wrong.
`docs/index.md`'s contract grew `BAD_REQUEST` while its `mapErrCases` kept
two arms, so the sample stopped compiling against an exhaustive matcher —
proved by extracting it into `examples/order-api/src` and watching tsc refuse
it. `docs/how-to/protect-a-procedure.md`'s fragment now declares the three
codes its controller calls.

The orders controller's comment named the wrong separator: oRPC's own
validation refusal does carry `data: { issues }`. What tells the two
`BAD_REQUEST`s apart is `inferable`, set only when a handler returns an
`ORPCError` as its output, which `isInferableError` reads — so
`api.spec.ts`'s `inferable: false` pins a mechanism, not a coincidence.
Every port in the example application names its tenant positionally, next to
a string that is not one — find(tenantId, id), execute(tenantId, id, quantity)
— and two strings in a fixed order are what the compiler has nothing to say
about: the swap compiled and queried the wrong tenant.

TenantId (order-domain) brands one half of each pair, which is all it takes for
the pair to become unswappable; the ids stay string, and branding them is a
separate question. The constructor is a cast, not a parse: the value arrived
through a contract that already validated it as a UUIDv7, and .parse() throws.

Each path claims the brand exactly once — bearerAuthenticator for the marked
HTTP half, TenantId(input.tenantId) for the unmarked one, each Temporal
activity's own input, and tenantsOf for the relay's OUTBOX_TENANTS. The AMQP
handlers claim nothing: neither calls a port that names a tenant.
prisma-outbox is the one read-back, so the one place the brand is re-applied.

tenant.test-d.ts is the gate.
…idates

http.md and protect-a-procedure.md each declared Identity with
tenantId: string and then passed context.principal.tenantId to a use
case expecting the branded TenantId — a contradiction the two pages
only exposed if compiled together. Both now import and declare
tenantId: TenantId, matching docs/examples/order-api.md's spelling,
and the two bearerAuthenticator samples cast with TenantId(tenantId).

tenant.ts's TSDoc claimed every TenantId "arrives through a contract
that has already validated it as a UUIDv7," which is false for the
HTTP-marked path: bearerAuthenticator only checks that the header's
tenant segment is non-empty before casting. Narrow the claim to name
the three boundaries separately — a contract validates, deployment
configuration is trusted, and the stand-in authenticator vouches.
The prose still described the ports as they were before issue #81.
docs/examples/order-application.md showed an OrderRepository with no tenant
argument at all; docs/reference/temporal.md and run-a-temporal-worker.md called
place.execute(args.orderId, args.quantity); log-and-correlate.md's interactor
took (id, quantity) and saved without a tenant; the two AMQP relay samples were
missing OUTBOX_TENANTS and tenantsOf; and test-an-application.md's fixture typed
the tenant `string` and minted it with randomUUID, which is a v4 the schema
rejects. Every touched sample was compiled in a scratch file inside a workspace
that has the dependencies, then deleted.

CLAUDE.md's multi-tenancy section claimed every order-api procedure names its
tenant on the input, which the authenticated `orders` fragment stopped doing;
said nothing about the UUIDv7 format or the second failure it gave placeOrder;
and argued only that a caller who FORGETS a tenant does not compile, when the
branded pair now refuses one that swaps it.

The "positional form the three router-shaped pages share" was wrong three ways:
docs-examples.test-d.ts pins the KEYED deps form, serve-orpc-over-http.md used
the positional one, and the positional one has not compiled since
`feat(di)!: a provider declares its dependencies by name` -- measured, TS2345
`not assignable to parameter of type 'Readonly<Record<string, AnyPort>>'`.
That page's deps record is fixed, so the three pages really do share one form
and it really is gated; the sentence now names them and records the diagnostic.
Five samples elsewhere still carry the dead array and are left for a commit of
their own, since that drift is di's, not this branch's.

No changeset: `git diff --name-only main..HEAD | grep '^packages/'` is three
README files and no src/, so no published API moved.

Spec counts in three READMEs drifted on this branch and are re-measured by
running the suites: order-domain 18 -> 21, order-application 7 -> 9,
order-infrastructure 18 -> 22.
Dead since 5807214 landed the keyed form on main; neither compiles today.
Both replacements compiled in scratch files inside order-application and
order-temporal-worker. Not an id or tenant matter -- a separate commit so it
stays reviewable on its own.
Copilot AI lite review requested due to automatic review settings August 21, 2026 16:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the example application to make multi-tenancy and identity handling safer and more explicit: it introduces a branded TenantId type to prevent argument swapping bugs (issue #81), standardizes example entity/contract ids on UUIDv7, and threads a new InvalidOrderId outcome through transports (HTTP/Temporal/AMQP) with an appropriate BAD_REQUEST mapping.

Changes:

  • Introduce TenantId (branded UUIDv7) in examples/order-domain, update application ports/use-cases/adapters/tests to accept TenantId rather than string.
  • Change example ids to UUIDv7 across domain entities and transport contracts; add InvalidOrderId and map it to BAD_REQUEST (HTTP), nonRetryable contract errors (Temporal), and NonRetryableError (AMQP).
  • Add internal test infra support for UUIDv7 generation and update docs/READMEs to reflect the new tenant/id semantics.

Reviewed changes

Copilot reviewed 82 out of 83 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
README.md Add BAD_REQUEST and map InvalidOrderId in sample triage
pnpm-lock.yaml Lockfile updates for workspace deps/dev tooling
packages/observability/README.md Update log example ids to UUIDv7
packages/http/README.md Document InvalidOrderIdBAD_REQUEST mapping
packages/amqp/README.md Include InvalidOrderId in non-retryable mapping example
internal/test-infra/vitest.config.ts Add Vitest config passthrough
internal/test-infra/src/uuid.ts Add UUIDv7 generator for tests
internal/test-infra/src/uuid.spec.ts Test UUIDv7 generator validity/uniqueness
internal/test-infra/README.md Document new uuid entry point
internal/test-infra/package.json Export ./uuid, add test deps/scripts
examples/README.md Update cross-runtime error mapping table
examples/order-temporal-worker/src/workflows.ts Map InvalidOrderId in workflow error triage
examples/order-temporal-worker/src/test-fixtures.ts Use TenantId + UUIDv7 in fixtures
examples/order-temporal-worker/src/temporal-runtime.spec.ts Update ids and add InvalidOrderId branches in tests
examples/order-temporal-worker/src/slices/fulfillment/activities.ts Claim TenantId at activity boundaries; map InvalidOrderId
examples/order-temporal-contract/src/contract.ts Switch inputs/refs to UUIDv7; add malformed-id error payload
examples/order-temporal-contract/src/contract.spec.ts Update contract tests for UUIDv7 and invalid-id rejection
examples/order-infrastructure/src/test-fixtures.ts Use TenantId fixture backed by UUIDv7
examples/order-infrastructure/src/prisma-outbox.ts Re-apply TenantId brand on DB read-back
examples/order-infrastructure/src/prisma-outbox.spec.ts Update ids to UUIDv7 and tenant typing
examples/order-infrastructure/src/prisma-order-repository.spec.ts Update repo specs for UUIDv7 ids and tenant branding
examples/order-infrastructure/src/prisma-customer-repository.spec.ts Update customer repo specs for UUIDv7 ids
examples/order-infrastructure/README.md Document branded TenantId and updated spec counts
examples/order-domain/src/test-fixtures.ts Update fixture entities to UUIDv7 ids
examples/order-domain/src/tenant.ts Add TenantId brand/type and rationale
examples/order-domain/src/order.ts Switch OrderId to UUIDv7; add InvalidOrderId and triage logic
examples/order-domain/src/order.spec.ts Add coverage for malformed id behavior and UUIDv7 ids
examples/order-domain/src/index.ts Export TenantId, CustomerId, InvalidOrderId
examples/order-domain/src/customer.ts Switch CustomerId to UUIDv7
examples/order-domain/src/customer.spec.ts Update customer specs for UUIDv7 ids
examples/order-domain/README.md Document UUIDv7 ids, InvalidOrderId, and TenantId branding
examples/order-application/src/use-cases.ts Update use-case APIs to accept TenantId; widen error channel
examples/order-application/src/test-fixtures.ts Update stub repositories to key by TenantId
examples/order-application/src/tenant.test-d.ts Add type-level gate preventing swapped tenant/id args
examples/order-application/src/ports.ts Update ports/events to use TenantId; widen PlaceOrder errors
examples/order-application/src/place-order.spec.ts Update tests for UUIDv7 ids and add malformed-id case
examples/order-application/src/needs-gate.test-d.ts Update type-level needs gate for TenantId and UUIDv7 ids
examples/order-application/src/find-customer.spec.ts Update tests for UUIDv7 customer ids
examples/order-application/README.md Update docs for new specs and structured logging attributes
examples/order-api/src/test-fixtures.ts Update fixtures for UUIDv7 ids; adjust tenant generation
examples/order-api/src/slices/orders/controller.ts Map InvalidOrderId to BAD_REQUEST in controller triage
examples/order-api/src/slices/customers/controller.ts Claim TenantId from unmarked tenant-bearing input
examples/order-api/src/docs-examples.test-d.ts Update docs type-test to include InvalidOrderId mapping + TenantId
examples/order-api/src/authenticator.ts Claim TenantId in authenticator identity
examples/order-api/src/auth.ts Update Identity.tenantId to branded TenantId
examples/order-api/src/api.spec.ts Update API integration tests for UUIDv7 and invalid-id refusal
examples/order-api/README.md Update sample triage and identity type docs
examples/order-api/package.json Add internal test infra devDependency
examples/order-api-contract/src/contract.ts Switch contract ids/tenant to UUIDv7; add malformedRef for BAD_REQUEST
examples/order-api-contract/src/client.spec.ts Update client contract tests for UUIDv7 ids
examples/order-amqp-worker/src/test-fixtures.ts Use TenantId fixture backed by UUIDv7
examples/order-amqp-worker/src/outbox-relay.ts Brand tenants from OUTBOX_TENANTS config; type relay sweep by TenantId
examples/order-amqp-worker/src/amqp-runtime.spec.ts Update AMQP runtime tests for UUIDv7 ids and tenant typing
examples/order-amqp-worker/package.json Add order-domain dependency for TenantId
examples/order-amqp-contract/src/contract.ts Switch AMQP envelope tenant/id to UUIDv7
examples/order-amqp-contract/src/contract.spec.ts Add invalid-id refusal test and UUIDv7 updates
examples/hexagonal-order-api/src/index.ts Update seed ids to UUIDv7
examples/hexagonal-order-api/src/index.spec.ts Update spec ids to UUIDv7
docs/tutorial/second-runtime.md Update TemporalActivities deps example to keyed form
docs/reference/temporal.md Update Temporal examples for TenantId + InvalidOrderId
docs/reference/observability.md Update JSON log samples to UUIDv7 ids
docs/reference/http.md Document TenantId identity + InvalidOrderIdBAD_REQUEST
docs/reference/amqp.md Include tenantId attributes in handler logging examples
docs/index.md Add BAD_REQUEST to contract example
docs/how-to/test-an-application.md Update snippets for UUIDv7 ids and TenantId fixtures
docs/how-to/swap-an-adapter.md Update example ids to UUIDv7
docs/how-to/split-a-worker-into-slices.md Include tenantId in AMQP handler snippet
docs/how-to/split-a-router-into-controllers.md Add BAD_REQUEST to contract example
docs/how-to/serve-orpc-over-http.md Add BAD_REQUEST to contract example; keyed deps example
docs/how-to/run-a-temporal-worker.md Update Temporal worker example for keyed deps + TenantId + InvalidOrderId
docs/how-to/read-the-ambient-unit.md Update tenancy explanation and AMQP snippet for tenantId field
docs/how-to/protect-a-procedure.md Add BAD_REQUEST and update identity typing to TenantId
docs/how-to/log-and-correlate.md Update provider deps example and log attributes to include tenant/id UUIDv7
docs/how-to/consume-amqp-messages.md Update AMQP docs for TenantId casting and relay config tenancy
docs/how-to/configure-from-the-environment.md Document required OUTBOX_TENANTS for relay
docs/explanation/the-kernel-maps-nothing.md Add InvalidOrderIdBAD_REQUEST mapping example
docs/examples/order-temporal-worker.md Update example snippet outputs/ids
docs/examples/order-application.md Update narrative/code for UUIDv7 ids, TenantId, and InvalidOrderId
docs/examples/order-api.md Update example contract/identity/triage for UUIDv7 + malformedRef
docs/examples/order-amqp-worker.md Update relay/handler docs for tenant-id behavior
docs/examples/index.md Update overview text for PostgreSQL + branded tenancy + InvalidOrderId
docs/examples/hexagonal-order-api.md Update example ids to UUIDv7
CLAUDE.md Update repo-level guidance to reflect branded tenant + UUIDv7 ids
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread docs/how-to/serve-orpc-over-http.md Outdated
Comment thread docs/index.md
Comment on lines 64 to 68
.errors({
INVALID_QUANTITY: { data: orderRef },
BAD_REQUEST: { data: orderRef },
CONFLICT: { data: orderRef },
}),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3098604. One correction to the premise: in this snippet orderRef was still z.object({ id: z.string() }) — the page had missed the UUIDv7 sweep entirely, so BAD_REQUEST sharing it was stale rather than unsatisfiable. Sweeping the ids to z.uuidv7() is what makes malformedRef necessary, and it now carries the same rationale the example contract states: the id it names is the value that failed the format, so validating it against the ref would reject the only payload the error is ever constructed with.

Comment thread docs/how-to/split-a-router-into-controllers.md Outdated
Comment thread docs/how-to/protect-a-procedure.md Outdated
The UUIDv7 sweep missed docs/index.md, protect-a-procedure,
split-a-router-into-controllers and serve-orpc-over-http: each still declared
its ids as z.string(), which left BAD_REQUEST sharing orderRef and hid why the
example contract gives that one error a schema of its own.

orderRef is a UUIDv7 in all four now, so malformedRef has to exist: the id it
names is the value that failed the format, and validating it against the ref
would reject the only payload the error is ever constructed with.

Found by review on #84; these are the pages docs-examples.test-d.ts
deliberately does not compile — the contracts are order-api-contract's
dependency, not order-api's — so nothing but a reader was going to catch it.
@btravers
btravers merged commit 5883d59 into main Aug 21, 2026
13 checks passed
@btravers
btravers deleted the feat/uuidv7-ids-and-branded-tenant branch August 21, 2026 16:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants