ADR-0087 — Per-start namespace routing + a multi-namespace worker fleet
- Status: Proposed (scoped 2026-07-12)
- Date: 2026-07-12
- Deciders: Gera (owner); Fede
- Relates:
- ADR-0045 — the one-time STOP-THE-WORLD clean cut of the tenant backend from the misnamed
propflow-renewal-prod.hmqmaonto the correctly-namedpropflow-prod.hmqma. This ADR does not replace 0045; it is the capability that makes the next namespace move (a shard-driven one) routine instead of a stop-the-world event. Do 0045 now, while small; build this later, when a single namespace is no longer enough. See "Relationship to ADR-0045" below. - ADR-0025 — the decision to put renewals (and now the whole tenant backend) on Temporal.
- ADR-0026 — the PII boundary; a routing layer must not widen what crosses the workflow/activity boundary.
- ADR-0044 — cross-domain signaling (NTV → turnover → renewal-decline). Any routing rule that splits these domains across namespaces breaks direct signaling; the routing granularity must keep co-signaling domains together (see "Consequences → What gets harder").
- ADR-0085 — sharding the renewal-saga DDB hot key. Same class of problem one layer down (throughput convergence forcing a horizontal split); the routing-rule shape here deliberately mirrors 0085's deterministic-hash-to-shard model so the two shard vocabularies stay legible together.
- ADR-0045 — the one-time STOP-THE-WORLD clean cut of the tenant backend from the misnamed
Context
Temporal Cloud has no "move a running workflow" primitive and no "rename a namespace" primitive — namespace names and regions are immutable (ADR-0045, Alternative 3). Every namespace move is therefore always create-new-namespace + migrate-the-population + decommission-old. The only variable is how the population moves.
Today PropFlow resolves the namespace once, globally:
- Every domain reads one env var.
getTemporalConfig()(src/lib/temporal/connection.ts) resolves a singleTEMPORAL_NAMESPACEfor the whole process — renewal, maintenance, maintenance-comms, tour, turnover, prospect-outreach, tenant-confirmation, topics. There is no per-domain, per-property, or per-workflow routing anywhere (ADR-0045 Finding 0, verified live 2026-07-05). - Every client binds to that one namespace.
client.ts'sgetClient()caches a singleClientatnamespace: getTemporalConfig().namespace;maintenance-client.ts,maintenance-comms-client.ts,tour-client.ts,turnover-client.ts,leasing-client.ts,conversation-topics-client.ts, and every other client insrc/lib/temporal/do the same. EverystartWorkflow/signalWithStartcall site inherits that namespace implicitly — there is no seam at the start site where a routing decision could be injected. - One worker deployment polls one namespace.
services/renewal-worker/index.tsopens oneNativeConnectionfromgetConnectionOptions()and creates N Worker Entities — all pinned toconfig.namespace— in a single Fargate process (ECS servicerenewal-worker,infra/temporal-worker/+.github/workflows/deploy-renewal-worker.yml). The fleet can poll many task queues but only ever one namespace.
This global-env model is exactly right for ADR-0045's clean cut. At today's scale — ~12 real renewals, one real customer (Camellia), a greenfield target namespace — a stop-the-world cut costs a minutes-to-hours orchestrated window and no throwaway routing code. 0045 correctly rejects a rolling drain for that move as strictly-worse complexity the single-env architecture can't even realize.
But the clean cut is a one-time affordance of being small. Two forces make it un-repeatable:
Stop-the-world stops being safe as the workflow population grows. The 0045 cut works because the live population is tiny and mostly re-materializable (maintenance-comms via
signalWithStart, tour via its cohort walker, renewal via a 10-row terminate+recreate). At hundreds of properties and thousands of concurrent workflows across seven domains, a stop-the-world cut means (a) a real inbound-processing outage window that scales with the population, and (b) a terminate+recreate migration whose per-workflow risk multiplies by the row count. There is no version of "pause the whole tenant backend for an hour" that is acceptable once real customers depend on it 24/7.A single namespace eventually runs out of room. Temporal Cloud namespaces have finite per-namespace throughput (actions/sec), retention windows, and — most importantly — a shared blast radius: a poison workflow, a bad deploy, a runaway signal storm, or a Cloud-side incident on one namespace takes down every domain co-located on it. As PropFlow grows, the tenant backend will need to shard across namespaces — by customer, by region, or by domain-group — for throughput, retention isolation, and blast-radius containment. That shard split is, mechanically, a namespace move — and by the time we need it, force (1) has already made stop-the-world impossible.
So the durable question this ADR answers is: how do we move workflows between namespaces, at any scale, without stopping the world? The answer has to exist before we need it, because the moment we need it is precisely the moment stop-the-world is off the table.
Note also why the "just let old work drain" pattern (the strangler-fig approach 0045's original draft proposed and then rejected) cannot work for this system as currently built, independent of scale:
- There is no routing seam. Draining requires sending new work to the new namespace while old work finishes in the old one. With one global env var and every client bound to it, you cannot address two namespaces at once — flipping the env moves everything, in-flight included, to a namespace where the in-flight workflows don't exist.
- Some domains never drain. Maintenance-comms workflows are per-WO,
signalWithStart-driven withUSE_EXISTING, and long-lived/perpetual (maintenance-comms-client.ts) — they re-materialize on the next inbound message and effectively never reach a terminal state. "Wait for the old namespace to empty" never terminates for a perpetually-signaled domain. (0045 sidesteps this by re-materializing comms in the new namespace on the next message — a move that only works because the cut is total and instantaneous, not a gradual drain.)
This ADR proposes the two capabilities that together turn a namespace move into a rolling, reversible, any-scale operation.
Decision
Introduce per-start namespace routing and a multi-namespace worker fleet. Neither is built now; this ADR sanctions the shape so the next namespace move is rolling instead of stop-the-world. Concretely:
1. Namespace becomes a per-startWorkflow decision, resolved from a routing rule
Instead of getTemporalConfig() returning one global namespace, a new resolver — call it resolveNamespaceForStart(routingKey) — maps a routing key to a target namespace at the moment a workflow is started. The routing key is derived from the workflow's natural anchor:
- per-property / per-customer —
propertyId(or its owningorganizationId) → namespace. The primary axis: this is how a shard-by-customer or shard-by-cohort move addresses "which world does this property's work live in." - per-domain — the domain (renewal / maintenance / tour / …) → namespace, for a shard-by-domain-group split (e.g. move the high-volume maintenance domains onto their own namespace to isolate their throughput/blast-radius from renewals).
- per-cohort — an explicit cohort tag on the routing rule, for canarying a single named cohort onto a new namespace before the fleet-wide flip.
The rule itself is durable config, not a code constant — a NamespaceRoutingRule read-model (see Entity classification) that a start site consults, with a code-level default so an unconfigured environment resolves to today's single namespace (fail-safe: no rule ⇒ current behavior). The critical invariant:
New work routes by the rule at start time; in-flight work finishes where it started. A workflow's namespace is fixed at birth. Signals, queries, and continuation always address the namespace the running execution lives in — never the current rule. This is what makes a move rolling: flipping the rule diverts only future starts.
This requires threading the resolved namespace through every startWorkflow / signalWithStart seam. Today those seams read the namespace implicitly from the cached client; the change makes namespace an explicit input to the start, and makes signal/query helpers resolve the running execution's namespace (from the entity's persisted workflowNamespace stamp — see below) rather than assuming the global one.
2. A multi-namespace worker fleet — every namespace in the routing rule has a live poller
A workflow only makes progress if a worker is polling its namespace. During a rolling move, both the old and the new namespace hold live executions simultaneously, so both must have live pollers. Two viable shapes, decided at build time:
- (A) One worker process polls a configured SET of namespaces. The Fargate entrypoint (
services/renewal-worker/index.ts) opens oneNativeConnectionper namespace inNAMESPACE_FLEET(a list, replacing the single implicitconfig.namespace) and instantiates the full task-queue registry against each. Same task definition, same IAM role; N× the Worker Entities in one process. Simple to deploy; couples all namespaces' capacity to one process's resources. - (B) One worker DEPLOYMENT per namespace. Each namespace gets its own ECS service from the same image, parameterized by a single
TEMPORAL_NAMESPACE. Strong resource + blast-radius isolation (a wedged worker for one shard can't starve another); more infra to manage (N services, N deploy targets).
Recommendation: start with (A) for the transient migration window, keep (B) as the end-state for a genuine permanent shard. During a move you want both namespaces polled by one deploy so the transient dual-poller window is a config-list change, not a new ECS service. Once a shard is permanent and load justifies isolation, promote it to its own deployment (B). The routing rule and the worker-fleet config are the two knobs; they move independently.
3. Migration becomes a rolling flip, reversible at any scale
With (1) and (2) in place, a namespace move is:
- Stand up the target namespace (register + verify search attributes per-namespace — the ADR-0045
prep-namespace.ts/verify-namespace-prep.tsmechanics generalize), create its schedules paused. - Add the target to the worker fleet so it has a live poller (config-list change under shape A).
- Flip the routing rule for the moving cohort (one property / one customer / one domain at a time, or all at once). New starts for that cohort now land on the target; everything already running stays put and drains naturally as those workflows reach their terminal states or re-materialize (on their next signal) in the namespace their entity now routes to.
- Decommission the old namespace when empty — leave it read-only through its retention window (histories = audit + rollback forensics), then delete.
Reversible at every step before decommission: flip the rule back and future starts return to the origin namespace; the fleet already polls both, so nothing wedges. There is no stop-the-world window at any scale, because the population never has to move all at once and inbound processing never pauses.
Relationship to ADR-0045 (sequencing, not competition)
- 0045 lands first, as written — a clean cut, now, while small. Building this routing layer to avoid the 0045 cut would be exactly the throwaway dual-namespace complexity 0045 correctly rejected: at 12 renewals it is strictly-worse. Do the cut.
- 0045 produces the correctly-named
propflow-prod— which this routing layer later treats as one shard among several. 0045 fixes the name; this ADR builds the mechanism for the next move. They are sequential, not alternatives. - The trigger to build this is a shard need, not a rename need. We build per-start routing + the multi-namespace fleet when a concrete pressure appears — a namespace approaching its throughput/retention ceiling, or a blast-radius/tenant-isolation requirement (e.g. a large customer that must not share a namespace). Until then, one namespace + the global env is correct and cheaper. This ADR is the capability on the shelf, not a commitment to build now.
Entity classification (per ADR-0027)
| Entity | Class | Naming | Spine trace (canonical) OR derived-from / rebuilt-by / drift-tolerance (derived) |
|---|---|---|---|
NamespaceRoutingRule |
canonical | bare name | Durable config — the operator-authored map (routing key → target namespace, + cohort tags + effective-from). No spine anchor (it is infra config, not a human/person entity). Written only by the routing-admin surface; read at every workflow start. |
workflowNamespace (a stamped field, not a new entity) |
— | field on the existing per-domain entity (e.g. RenewalSaga.workflowNamespace, WorkOrder.workflowNamespace) |
Records the namespace a workflow was born into, stamped at start. Signal/query helpers read it to address the running execution. This is the seam that keeps in-flight work addressable independent of the current rule. |
NamespaceRoutingRule is the only genuinely new entity; it is canonical infra config with a single writer (the routing-admin surface), no PII, no spine stamp. The workflowNamespace stamp is an additive field on entities that already exist — mirroring how ADR-0085's shard is recomputable but here it must be persisted (a namespace can't be re-derived from the entity once the rule has moved on).
Implementation sketch (rough — this is a proposal, not a plan)
Concrete surfaces a build would touch, so the cost is legible:
The routing-rule config surface.
- A
NamespaceRoutingRuleread-model (DDB config row, mirroringCONFIG/RENEWAL_ARMS) + a small resolverresolveNamespaceForStart({ propertyId?, organizationId?, domain, cohortTag? })with a documented precedence (explicit cohort > per-property > per-domain > global default) and a fail-safe default = today's single namespace when no rule matches. - An operator surface to author/inspect the rule (admin dev page or a
scripts/set-namespace-routing.tsCLI in the shape ofset-renewal-arms.ts), plus a read-only "who routes where" census (mirrorspine-census.ts).
The start-site change (the bulk of the effort).
- Every
startWorkflow/signalWithStartcall inclient.ts,maintenance-client.ts,maintenance-comms-client.ts,tour-client.ts,turnover-client.ts,leasing-client.ts,conversation-topics-client.ts,tenant-confirmation-review-client.ts,prospect-outreach, the schedule upserts, etc. — resolve the namespace explicitly and construct/select the client for that namespace (a per-namespace client cache keyed by namespace name, replacing today's singlecachedClient). - Stamp
workflowNamespaceon the owning entity at start. Change signal/query helpers (signalWithOptionalStart,getRenewalWorkflowHandle, the commssignalWithStart, …) to resolve the running execution's namespace from that stamp, not the global config. This is the fiddliest part — thesignalWithStartrace (delivery callback beats the start write) has to preserve its idempotency within the correct namespace. - Temporal Schedules are namespace-scoped: a rolling move needs the daily scanners/walkers created (paused) on the target namespace and the routing decision reflected in what each scanner starts. Scanners that open workflows must themselves route per-start.
The worker-fleet change.
- Shape (A):
services/renewal-worker/index.tsreads aNAMESPACE_FLEETlist, loops theNativeConnection.connect+ full-registryWorker.createper namespace (the registry loop already exists — it becomes namespace × task-queue). One shared workflow bundle across all of them (already built once today). Watch per-Workeridentityuniqueness (add the namespace to the${taskQueue}-${buildId}-${ECS_TASK_ID}identity string) and the process-wide activity-slot ceiling (N namespaces × M queues ×WORKER_MAX_ACTIVITY_TASK_EXECUTIONS). - Shape (B): parameterize the existing task definition by
TEMPORAL_NAMESPACE, stamp out one ECS service per namespace viainfra/temporal-worker/. Deploy workflow becomes a matrix.
Drift guards you'd want (the same discipline as the rest of the Temporal layer).
- No implicit-namespace starts. A test asserting no
startWorkflow/signalWithStartcall site readsgetTemporalConfig().namespacedirectly — every start must go throughresolveNamespaceForStart. (Mirrorstemporal-naming.drift.test.ts's structural enforcement.) - Every fleet namespace is a valid rule target and vice-versa. A namespace that appears in a routing rule but has no poller in
NAMESPACE_FLEET= a workflow that starts and never runs (a silent wedge). Guard set-equality between rule targets and fleet members (shape A) or deployed services (shape B). workflowNamespaceis stamped at construction. For any entity that owns a workflow, itsworkflowNamespacemust be non-null once a workflow exists — a signal helper that falls back to the global namespace is the bug class this guards against.- Co-signaling domains stay co-located. A structural check that domains which signal each other cross-domain (NTV → turnover → renewal-decline, ADR-0044) resolve to the same namespace under any rule — routing granularity must never split a signaling pair (see Consequences).
- Fail-safe default. A test that
resolveNamespaceForStartwith an empty rule returns today's single namespace.
Honest cost. This is a large, invasive, cross-cutting change — it touches every Temporal client, every start/signal seam, the worker entrypoint, the infra, and adds a new config surface with its own admin UI and guards. It is emphatically not worth doing until a concrete shard pressure exists; doing it early is the same throwaway-complexity mistake 0045 warns against, just deferred. The value is entirely in when the move comes, it's rolling. Effort estimate: multi-PR, dedicated-session scale (comparable to the ADR-0035 spine migration in seam-count, smaller in data risk since nothing is re-keyed — workflows are born into the right namespace rather than moved).
Consequences
What this commits us to
- A namespace becomes a per-start decision. Once shipped, "which namespace does this workflow live in" is answered by config at start time, not by a process-global env — a permanent shift in how the Temporal layer is reasoned about. The
workflowNamespacestamp becomes load-bearing for all signal/query routing. - The worker fleet becomes multi-namespace. Either a fleet-list config (A) or a per-namespace deployment (B) is now part of the ops surface; capacity planning is per-namespace.
What gets easier
- Every future namespace move is rolling and reversible. Shard splits, region moves, blast-radius isolation of a big customer, retiring a namespace — all become "flip the rule, drain, decommission," with no inbound outage and no terminate+recreate of live populations.
- Blast-radius containment and tenant isolation become available knobs. A customer that needs its own namespace, or a domain-group that needs throughput isolation, is a routing-rule + fleet change, not an architecture project.
- The single-env architecture's fragility (0045 Finding 0) is retired — no future domain can silently accrete onto the wrong namespace because "wrong namespace" is now an explicit, guarded routing decision.
What gets harder
- Cross-domain signaling constrains routing granularity. Workflows in different namespaces cannot signal each other directly (ADR-0045 Context §1). The NTV → turnover → renewal-decline chain (ADR-0044) requires those domains to share a namespace. So the routing rule cannot split a property below the granularity of its co-signaling domain-group — a real constraint the rule schema and a drift guard must encode. (This is the same "one bounded context = same world" logic ADR-0045 uses; it survives sharding — you shard across bounded contexts / customers, not within a signaling group.)
- More moving parts, more failure surface. A routing rule that points at a namespace with no poller = a silent wedge. A
workflowNamespacestamp that's missing = a signal addressed to the wrong namespace. These are new failure modes the drift guards above exist to catch, but they are real added complexity — the reason not to build this until the shard need is concrete. - Schedules multiply. Namespace-scoped Temporal Schedules must be created (paused) on each target and reconciled with the rule; the daily scanners themselves must route per-start.
Follow-up work this implies (when built)
- A routing-admin surface + census + the drift-guard suite above.
- Generalizing the ADR-0045
prep-namespace/verify-namespace-prepscripts into a repeatable "stand up a new shard namespace" runbook. - A decision on shape (A) vs (B) as the permanent end-state, and the ECS/infra work if (B).
Alternatives considered
Keep the global env + stop-the-world forever (the status quo, extrapolated). Every future namespace move repeats the ADR-0045 clean-cut: pause the tenant backend, terminate+recreate the live population, flip the env, resume. Why it fails at scale: the pause window and the per-workflow migration risk both grow with the population. At 12 renewals it's a minutes-to-hours window and a 10-row terminate+recreate — trivial. At thousands of concurrent workflows across seven domains with real customers depending on 24/7 inbound processing, "pause the whole backend" is an unacceptable outage and the terminate+recreate risk is unbounded. Correct now (which is why 0045 uses it); a dead end later. This ADR exists precisely because the status quo doesn't survive growth.
Strangler-drain on the current architecture (send new work to the new namespace, let old work age out). The pattern ADR-0045's original draft proposed. Why it fails for THIS system: (a) no routing seam — with one global env and every client bound to it, you can't address two namespaces at once, so "new work to new, old work stays" is unreachable without building the very routing layer this ADR proposes; and (b) perpetual domains never drain — maintenance-comms is per-WO
signalWithStart/USE_EXISTINGand long-lived (maintenance-comms-client.ts), re-materializing on each inbound message and never reaching a terminal state, so "wait for the old namespace to empty" never terminates. Strangler-drain is this ADR — but only once the routing seam and multi-namespace fleet exist. Proposing it without those (as the 0045 draft did) is why 0045 rejected it. In other words: this ADR is the strangler-drain done right; the alternative is strangler-drain attempted on an architecture that can't support it.A namespace per domain, statically (renewal-ns, maintenance-ns, …). Rejected for the same reason ADR-0045 rejects it (Alternative 2): it breaks direct cross-domain signaling (NTV → turnover → renewal-decline), fragments domains that must coordinate, and multiplies ops — permanently, not just during a move. Per-start routing subsumes the useful part (you can shard a domain-group onto its own namespace when throughput justifies it) without hard-coding the split or severing signaling pairs.
Rename / move the running workflows in place. Impossible — Temporal Cloud namespace names + regions are immutable and there is no native "move running workflow" (ADR-0045 Alternative 3). "Move" always decomposes to create-new + migrate. This ADR is about making the migrate step rolling; it cannot avoid the create-new.
Build this now, ahead of need (skip the 0045 clean cut, do the first move as a rolling migration). Rejected. At current scale the routing layer is throwaway complexity for a move a stop-the-world cut handles in an afternoon — the exact trade ADR-0045 already weighed and rejected. Building the capability before a concrete shard pressure is speculative infrastructure. Correct sequence: cut now (0045), build this when a namespace ceiling or isolation requirement makes the next move real.
Open questions
- O1 — Routing-key precedence + the co-signaling constraint schema. The exact precedence (cohort > property > domain > default) and how the rule schema forbids splitting a co-signaling domain-group across namespaces (compile-time? runtime guard? both?). The ADR-0044 signaling graph is the input; the rule schema must make an illegal split unrepresentable or loudly rejected.
- O2 — Worker-fleet shape (A vs B) as end-state. (A) one process polling a namespace set is clearly right for the transient migration window; whether a permanent shard should graduate to (B) its own deployment, and the load threshold that triggers it, is unresolved.
- O3 — The trigger metric. What concrete signal says "a single namespace is no longer enough" and starts this build — a Temporal Cloud actions/sec ceiling, a retention-window pressure, a named large-customer isolation requirement, or a blast-radius incident. This ADR should not be built on speculation; O3 is the gate.
- O4 — Interaction with ADR-0085's DDB sharding vocabulary. Both this ADR and 0085 introduce a "shard" concept (namespace shard vs GSI4 partition shard). Whether they should share a routing key (e.g. shard-by-customer aligning the namespace and the DDB partition) or stay independent axes is worth settling before either vocabulary calcifies.