feat(examples)!: a tenant is not a string, and an id is a UUIDv7 - #84
Conversation
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.
There was a problem hiding this comment.
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) inexamples/order-domain, update application ports/use-cases/adapters/tests to acceptTenantIdrather thanstring. - Change example ids to UUIDv7 across domain entities and transport contracts; add
InvalidOrderIdand map it toBAD_REQUEST(HTTP),nonRetryablecontract errors (Temporal), andNonRetryableError(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 InvalidOrderId → BAD_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 + InvalidOrderId → BAD_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 InvalidOrderId → BAD_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.
| .errors({ | ||
| INVALID_QUANTITY: { data: orderRef }, | ||
| BAD_REQUEST: { data: orderRef }, | ||
| CONFLICT: { data: orderRef }, | ||
| }), |
There was a problem hiding this comment.
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.
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.
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 sayabout: the swap compiled and queried the wrong tenant.
TenantId(order-domain) brands one half of each pair, which is all it takesfor the pair to become unswappable. The ids stay
string; branding them is aseparate 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:
bearerAuthenticatorfor the markedHTTP half,
TenantId(input.tenantId)for the unmarked one, each Temporalactivity's own input, and
tenantsOffor the relay'sOUTBOX_TENANTS— whichanswers 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-outboxisthe 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.tsis the gate: the id in firstposition is a
@ts-expect-erroron bothrepository.findandplaceOrder.execute.An id is a UUIDv7
Declared once on the entity (
OrderId,CustomerId) and again on eachcontract's schema, so a malformed id is refused at the transport before a use
case sees it. That format gave
placeOrdera second way to fail: while theid was an unconstrained string the quantity was the only field a typed caller
could get wrong, so collapsing
Order.make'sInvalidEntitytoInvalidQuantitywas sound — with a format it became a mislabelling.InvalidOrderIdis the arm that fixes it, told apart fromInvalidQuantitybywhich field the entity named (
Entity.keysOfover the issue's path), neverby message text. Each transport carries the third arm:
BAD_REQUESTover HTTP,a
nonRetryableInvalidOrderIdon Temporal, aNonRetryableErroron thequeue.
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.