The thesis
A microservice routing key is an opaque token. Its entire job is to let the gateway tell "this developer's locally running instance" apart from "that developer's" and from "the deployed one". Nothing consumes its contents. Nothing should.
We do not treat it that way. We derive it, from the machine, at every call site, by inference:
public static string GetDefaultRoutingKeyForMachine()
{
var devices = NetworkInterface.GetAllNetworkInterfaces().ToList();
// if we don't sort the list, then different conditions may change the routingKey order.
devices.Sort((a, b) => String.Compare(a.Id, b.Id, StringComparison.Ordinal));
for (var i = 0; i < devices.Count; i++)
{
var addrBytes = devices[i].GetPhysicalAddress().GetAddressBytes();
if (addrBytes.Length == 0) continue;
macBytes = devices[i].GetPhysicalAddress().GetAddressBytes();
break;
}
if (macBytes == null)
throw new InvalidOperationException(
"cannot get routingKey for a machine with no active network addresses");
// hash the macAddr to avoid leaking pii
using var md5 = System.Security.Cryptography.MD5.Create();
// ...
return (Environment.MachineName + "_" + macAddr).Replace(":", "_").Replace(",", "_").ToLowerInvariant();
}
Every defect below is downstream of that one decision. This issue proposes replacing the derivation with a vacuous generated ID, generated once, stored, and never interpreted - and then aggressively deleting the accreted alternatives, because there are more of them than anyone would guess.
What the derivation actually does
Measured on an Apple Silicon Mac, .NET 10, 2026-09-03, with the interfaces sorted exactly as the code sorts them:
anpi0 len=6 type=Ethernet status=Down mac=[4263987DE9F7] <- CHOSEN
anpi1 len=6 type=Ethernet status=Down
anpi2 len=6 type=Ethernet status=Down
ap1 len=6 type=Wireless80211 status=Down
awdl0 len=6 type=Wireless80211 status=Up
bridge0 len=6 type=Ethernet status=Down
en0 len=6 type=Wireless80211 status=Up mac=[7E5F77D2DAC9] <- the actual Wi-Fi, 7th in line
en1..en6 len=6 type=Ethernet status=Down
en7 len=6 type=Ethernet status=Up <- the actual dock, 14th
gif0 len=0 type=Tunnel status=Down
llw0 len=6 type=Wireless80211 status=Down
lo0 len=0 type=Loopback status=Up
stf0 len=0 type=Tunnel status=Down
utun0-5 len=0 type=Unknown status=Up
Read that carefully, because it is worse than "we pick a MAC address":
- The chosen interface is an internal Apple private interface, and it is
Down. Six of the first seven candidates are down. Nothing consults OperationalStatus.
- Nothing rejects loopback.
lo0 is excluded only because .NET happens to report it with no physical address. On any platform where loopback reports six zero bytes instead, it is a candidate like any other - and on a Wi-Fi-only Linux box, lo sorts ahead of veth*, virbr*, wlan* and tun*, so it could plausibly win. Whether it does is unverified and nobody has checked.
- The exception message is a lie. It says "no active network addresses". The code does not require active; it requires any interface with a MAC, up or down.
- The result cannot be computed offline at all. A machine with no MAC-bearing interface cannot start a local microservice. It throws.
- On Windows,
NetworkInterface.Id is an adapter GUID, so the sort order has no relationship to interface naming, which adapter wins is arbitrary, and installing or removing any virtual adapter - Docker, a VPN, Hyper-V, VMware - can silently change the machine's routing key.
And the part that should bother us most:
- The PII hygiene is half-applied, and the half that leaks is the one that matters. The comment says "hash the macAddr to avoid leaking pii" - and then concatenates
Environment.MachineName in cleartext. Machine names are routinely people's names. This is not hypothetical: adrians-macbook-pro_933f8a0ec0c402d0b902e756f317e3ce appears in a gateway error message captured from a customer's machine in March 2026. Routing keys are written to wizard_master.service_topology, echoed verbatim in BindingNotFoundException text, and pasted into tickets, CI logs, terminal scrollback, and AI agent transcripts. We hashed the MAC, which nobody would have recognized, and shipped the human's name, which everybody does.
We have gone to real trouble - sorting for stability, hashing for privacy, sanitizing delimiters - to construct a value whose contents we then never read. That is the whole problem in one sentence.
The proposal
- Generate an opaque ID once - UUIDv4 is fine and boring, which is the point - at
beam init or on first need.
- Store it in
.beamable/local/config.beam.json. See the next section; this is the one thing that must not be got wrong.
- Read it everywhere. Fourteen call sites currently call the derivation; they all just want "my key".
- Do not fall back to the old derivation when the file is missing. Generate and write. A fallback preserves the exact offline failure mode we are removing.
- Delete the derivation and everything that grew around it. See the cull list.
The key becomes what it always was in effect: a meaningless string that identifies one developer's working copy. Nothing infers anything from it, nothing can leak through it, and it works on a plane.
Where it must live, and the trap
The obvious home is .beamable/config.beam.json. That file is committed. A project's .beamable/.gitignore contains exactly:
So an ID written to the shared config would be checked in and shared by the entire team. Every developer's local service would claim the same routing key against the same realm, the traffic filter could no longer tell them apart, and two teammates running locally would silently fight over one identity. That is strictly worse than what we have today, which at least gives each machine a distinct key.
It has to be .beamable/local/config.beam.json, which is gitignored and already exists for per-developer state.
Note the deliberate semantic change this brings: the key stops being per-machine and becomes per-checkout. That is an improvement - two checkouts of one project on one machine currently collide on a single key - but it is a change, and it wants saying out loud rather than discovering later. A fresh clone gets a new ID; deleting .beamable/local/ regenerates one.
Why this is cheap: there is exactly one live implementation
This is the part that makes the change tractable, and it took tracing to establish rather than assuming.
- Unity never computes the key in the live path. The editor reads
latestManifest.localRoutingKey, which the CLI supplies via beam unity show-manifest (ShowManifestCommand.cs:131). UsamService and UsamRoutingStrategy only ever compare against that value.
- The microservice never computes it. The CLI injects it as the
NAME_PREFIX environment variable when starting a local service (BeamoLocalSystem_HttpMicroservice.cs:168).
- Runtime clients send no routing header at all. Every consumer guards on
CanBuildService<IServiceRoutingResolution>(), and the only registration is in Editor/Server/RegisterDependencies.cs. A shipped build registers nothing, which is correct - deployed services route with an empty routing key.
So all fourteen live call sites are in the CLI, in one process, funnelling through one function. Change the body, everything follows. There is no Unity-side implementation to version-skew against - and that is a stronger guarantee than "the mismatch window is short", because there is no second implementation to disagree in the first place.
The cull list
Every item below was grep-verified across the whole repo on 2026-09-03, excluding obj/. This is not "looks unused"; it is "has no callers".
| Symbol |
Location |
Status |
DefaultServiceRoutingStrategy |
beamable.common + Unity copy of IServiceRoutingStrategy.cs |
registered nowhere; only a test registers the resolution wrapper |
ServiceRoutingStrategyExtensions.GetRoutingKeyMap |
same files |
zero callers |
ServiceRoutingStrategyExtensions.BeamoIdsToServiceNames |
same files |
referenced only by GetRoutingKeyMap; dies with it |
MicroserviceIndividualization |
client/.../Runtime/Server/MicroserviceIndividualization.cs |
[Obsolete], zero callers |
IMicroservicePrefixService |
client/.../Runtime/Server/MicroservicePrefixService.cs |
zero references of any kind |
UsamRoutingStrategy.GetServiceMap() |
Editor/Server/Usam/ServiceRoutingStrategy.cs |
throws NotImplementedException; the interface method, while the editor calls GetMap() |
Two of these deserve individual comment, because they are not merely dead - they are actively misleading, which is the real cost of leaving dead code in a tree people debug in.
DefaultServiceRoutingStrategy is a complete, plausible, entirely unused alternative implementation. It queries Beamo for registrations and filters them against the local key:
/// <summary>
/// The routing key should be the machine name of the host.
/// ...
/// </summary>
public static string DefaultRoutingKey => ServiceRoutingStrategyExtensions.GetDefaultRoutingKeyForMachine();
public async Promise<Dictionary<string, string>> GetServiceMap()
{
var res = await _beamo.PostMicroserviceRegistrations(new MicroserviceRegistrationsQuery(), includeAuthHeader: true);
// ... match reg.routingKey against DefaultRoutingKey ...
}
Anyone reading the codebase to understand how routing works can land here, find a coherent implementation with a doc comment, and reason from it - and be entirely wrong, because the live path is UsamRoutingStrategy in the editor. Note also that the doc comment is inaccurate even about the code it documents: the routing key is not "the machine name of the host", it is the machine name plus an md5 of a MAC.
MicroserviceIndividualization is the fossil of the previous manual scheme - UseServicePrefix / GetServicePrefix, PlayerPrefs-backed under BeamableMicroservicePrefixes.{cid}.{pid}, last live around #2768, marked [Obsolete] at the package-combine in #4093. IMicroservicePrefixService survives beside it as a stub whose own comment reads "psuedo obsolete ... this type exists as a backwards compat signal". A backwards-compat signal with zero references is not a signal; it is litter. If we are keeping it for customer source compatibility, that intent should be an [Obsolete] attribute with a message pointing at the replacement, not a comment. If we are not, delete it.
While in the area: the Unity copy of IServiceRoutingStrategy.cs is byte-identical to the CLI's beamable.common original apart from the generated-file header, so deleting from the source deletes from both. There is no separate Unity-side cleanup to do, and no risk of the two drifting during the change.
On aggressive culling generally
This is the opinionated part, and it is the reason this issue is worth filing separately from the narrow fixes.
The routing-key area has accumulated three generations of mechanism - the PlayerPrefs prefix, the Beamo-querying strategy, the USAM discovery dropdown - and all three are still in the tree, with only the third connected to anything. That is not a tidiness problem. It is a debugging tax, and we have paid it in cash: the recent investigation that produced this issue spent real time reading DefaultServiceRoutingStrategy and chasing MicroservicePrefixService before establishing that neither runs. A customer was blocked while that happened.
The same pattern shows up immediately next door: ServiceUploadUtil.UploadStreamMulti is dead code whose doc comment sits above a different method and describes chunked resumable uploads the live path does not perform (see #4823). Two independent instances in two adjacent files suggests a habit rather than an accident.
My position: when replacing a mechanism, deleting the old one is part of the change, not a follow-up. A dead implementation that still compiles is worse than no implementation, because it answers questions incorrectly and it does so with the authority of being in the repository. Git remembers it; the tree does not need to.
What must not break
- The header contract. Beamo matches
micro_<ServiceName>:<key> via keysForService against ServiceIdentity(name, BASIC, cid, pid).fullName. This change replaces the derivation, not the format. A UUID with hyphens is fine - the existing sanitizer only replaces : and ,, and hyphens were never special.
- The
_healthCheck suffix. DeploymentService.cs:1925 builds GetDefaultRoutingKeyForMachine() + "_healthCheck" for the pre-publish health check. It must keep working. While the area is open, consider making that a structured field rather than string concatenation onto an identity.
Migration is self-healing, not a migration
The key changes exactly once, on first run after upgrade. Bindings registered under the old key are orphaned - and then reaped, because wizard_master.service_topology carries a TTL index (ttl_idx, expireAfter(0)). The window is bounded by that TTL and affects only locally-running services. Deployed services register with an empty routing key and are untouched.
No data migration, no dual-read period, no compatibility shim. The old keys expire on their own.
Release timing
This is safe in a minor. The instinct to hold it for a major is aimed at client/service mismatch, and that risk is absent for the reasons traced above: Unity has no independent implementation, the microservice is injected, runtime clients send nothing. The only conceivable skew is CLI-versus-CLI within a single checkout, and each developer's editor and CLI are pinned together.
The argument for a major is different, and it is about communication rather than compatibility: this changes an observable identifier and shifts its meaning from per-machine to per-checkout. That earns a prominent changelog entry wherever it lands - local routing keys change once, stale bindings expire on their own, and anyone who scripted against the old hostname_hash shape should stop.
Relationship to the neighbouring issues
This should sit beside #4824, not subsume it. They are closely related and genuinely separate:
Neither blocks the other, and they touch at exactly one point: #4824 proposes a free-text routing-key field, which becomes more coherent once keys are opaque generated IDs. A UUID reads as a token to paste; today's hostname_md5hash invites the belief that you can construct one by hand, or that its contents mean something. So this issue strengthens #4824's proposal rather than absorbing it.
Practical argument for keeping them apart, too: #4824 is a small Unity editor fix that could ship in a patch. This is a CLI change plus a set of deletions that wants a deliberate release slot and a changelog entry. Folding the former into the latter would bury a cheap user-visible fix inside a refactor.
The thesis
A microservice routing key is an opaque token. Its entire job is to let the gateway tell "this developer's locally running instance" apart from "that developer's" and from "the deployed one". Nothing consumes its contents. Nothing should.
We do not treat it that way. We derive it, from the machine, at every call site, by inference:
Every defect below is downstream of that one decision. This issue proposes replacing the derivation with a vacuous generated ID, generated once, stored, and never interpreted - and then aggressively deleting the accreted alternatives, because there are more of them than anyone would guess.
What the derivation actually does
Measured on an Apple Silicon Mac, .NET 10, 2026-09-03, with the interfaces sorted exactly as the code sorts them:
Read that carefully, because it is worse than "we pick a MAC address":
Down. Six of the first seven candidates are down. Nothing consultsOperationalStatus.lo0is excluded only because .NET happens to report it with no physical address. On any platform where loopback reports six zero bytes instead, it is a candidate like any other - and on a Wi-Fi-only Linux box,losorts ahead ofveth*,virbr*,wlan*andtun*, so it could plausibly win. Whether it does is unverified and nobody has checked.NetworkInterface.Idis an adapter GUID, so the sort order has no relationship to interface naming, which adapter wins is arbitrary, and installing or removing any virtual adapter - Docker, a VPN, Hyper-V, VMware - can silently change the machine's routing key.And the part that should bother us most:
Environment.MachineNamein cleartext. Machine names are routinely people's names. This is not hypothetical:adrians-macbook-pro_933f8a0ec0c402d0b902e756f317e3ceappears in a gateway error message captured from a customer's machine in March 2026. Routing keys are written towizard_master.service_topology, echoed verbatim inBindingNotFoundExceptiontext, and pasted into tickets, CI logs, terminal scrollback, and AI agent transcripts. We hashed the MAC, which nobody would have recognized, and shipped the human's name, which everybody does.We have gone to real trouble - sorting for stability, hashing for privacy, sanitizing delimiters - to construct a value whose contents we then never read. That is the whole problem in one sentence.
The proposal
beam initor on first need..beamable/local/config.beam.json. See the next section; this is the one thing that must not be got wrong.The key becomes what it always was in effect: a meaningless string that identifies one developer's working copy. Nothing infers anything from it, nothing can leak through it, and it works on a plane.
Where it must live, and the trap
The obvious home is
.beamable/config.beam.json. That file is committed. A project's.beamable/.gitignorecontains exactly:So an ID written to the shared config would be checked in and shared by the entire team. Every developer's local service would claim the same routing key against the same realm, the traffic filter could no longer tell them apart, and two teammates running locally would silently fight over one identity. That is strictly worse than what we have today, which at least gives each machine a distinct key.
It has to be
.beamable/local/config.beam.json, which is gitignored and already exists for per-developer state.Note the deliberate semantic change this brings: the key stops being per-machine and becomes per-checkout. That is an improvement - two checkouts of one project on one machine currently collide on a single key - but it is a change, and it wants saying out loud rather than discovering later. A fresh clone gets a new ID; deleting
.beamable/local/regenerates one.Why this is cheap: there is exactly one live implementation
This is the part that makes the change tractable, and it took tracing to establish rather than assuming.
latestManifest.localRoutingKey, which the CLI supplies viabeam unity show-manifest(ShowManifestCommand.cs:131).UsamServiceandUsamRoutingStrategyonly ever compare against that value.NAME_PREFIXenvironment variable when starting a local service (BeamoLocalSystem_HttpMicroservice.cs:168).CanBuildService<IServiceRoutingResolution>(), and the only registration is inEditor/Server/RegisterDependencies.cs. A shipped build registers nothing, which is correct - deployed services route with an empty routing key.So all fourteen live call sites are in the CLI, in one process, funnelling through one function. Change the body, everything follows. There is no Unity-side implementation to version-skew against - and that is a stronger guarantee than "the mismatch window is short", because there is no second implementation to disagree in the first place.
The cull list
Every item below was grep-verified across the whole repo on 2026-09-03, excluding
obj/. This is not "looks unused"; it is "has no callers".DefaultServiceRoutingStrategybeamable.common+ Unity copy ofIServiceRoutingStrategy.csServiceRoutingStrategyExtensions.GetRoutingKeyMapServiceRoutingStrategyExtensions.BeamoIdsToServiceNamesGetRoutingKeyMap; dies with itMicroserviceIndividualizationclient/.../Runtime/Server/MicroserviceIndividualization.cs[Obsolete], zero callersIMicroservicePrefixServiceclient/.../Runtime/Server/MicroservicePrefixService.csUsamRoutingStrategy.GetServiceMap()Editor/Server/Usam/ServiceRoutingStrategy.csNotImplementedException; the interface method, while the editor callsGetMap()Two of these deserve individual comment, because they are not merely dead - they are actively misleading, which is the real cost of leaving dead code in a tree people debug in.
DefaultServiceRoutingStrategyis a complete, plausible, entirely unused alternative implementation. It queries Beamo for registrations and filters them against the local key:Anyone reading the codebase to understand how routing works can land here, find a coherent implementation with a doc comment, and reason from it - and be entirely wrong, because the live path is
UsamRoutingStrategyin the editor. Note also that the doc comment is inaccurate even about the code it documents: the routing key is not "the machine name of the host", it is the machine name plus an md5 of a MAC.MicroserviceIndividualizationis the fossil of the previous manual scheme -UseServicePrefix/GetServicePrefix, PlayerPrefs-backed underBeamableMicroservicePrefixes.{cid}.{pid}, last live around #2768, marked[Obsolete]at the package-combine in #4093.IMicroservicePrefixServicesurvives beside it as a stub whose own comment reads "psuedo obsolete ... this type exists as a backwards compat signal". A backwards-compat signal with zero references is not a signal; it is litter. If we are keeping it for customer source compatibility, that intent should be an[Obsolete]attribute with a message pointing at the replacement, not a comment. If we are not, delete it.While in the area: the Unity copy of
IServiceRoutingStrategy.csis byte-identical to the CLI'sbeamable.commonoriginal apart from the generated-file header, so deleting from the source deletes from both. There is no separate Unity-side cleanup to do, and no risk of the two drifting during the change.On aggressive culling generally
This is the opinionated part, and it is the reason this issue is worth filing separately from the narrow fixes.
The routing-key area has accumulated three generations of mechanism - the PlayerPrefs prefix, the Beamo-querying strategy, the USAM discovery dropdown - and all three are still in the tree, with only the third connected to anything. That is not a tidiness problem. It is a debugging tax, and we have paid it in cash: the recent investigation that produced this issue spent real time reading
DefaultServiceRoutingStrategyand chasingMicroservicePrefixServicebefore establishing that neither runs. A customer was blocked while that happened.The same pattern shows up immediately next door:
ServiceUploadUtil.UploadStreamMultiis dead code whose doc comment sits above a different method and describes chunked resumable uploads the live path does not perform (see #4823). Two independent instances in two adjacent files suggests a habit rather than an accident.My position: when replacing a mechanism, deleting the old one is part of the change, not a follow-up. A dead implementation that still compiles is worse than no implementation, because it answers questions incorrectly and it does so with the authority of being in the repository. Git remembers it; the tree does not need to.
What must not break
micro_<ServiceName>:<key>viakeysForServiceagainstServiceIdentity(name, BASIC, cid, pid).fullName. This change replaces the derivation, not the format. A UUID with hyphens is fine - the existing sanitizer only replaces:and,, and hyphens were never special._healthChecksuffix.DeploymentService.cs:1925buildsGetDefaultRoutingKeyForMachine() + "_healthCheck"for the pre-publish health check. It must keep working. While the area is open, consider making that a structured field rather than string concatenation onto an identity.Migration is self-healing, not a migration
The key changes exactly once, on first run after upgrade. Bindings registered under the old key are orphaned - and then reaped, because
wizard_master.service_topologycarries a TTL index (ttl_idx,expireAfter(0)). The window is bounded by that TTL and affects only locally-running services. Deployed services register with an empty routing key and are untouched.No data migration, no dual-read period, no compatibility shim. The old keys expire on their own.
Release timing
This is safe in a minor. The instinct to hold it for a major is aimed at client/service mismatch, and that risk is absent for the reasons traced above: Unity has no independent implementation, the microservice is injected, runtime clients send nothing. The only conceivable skew is CLI-versus-CLI within a single checkout, and each developer's editor and CLI are pinned together.
The argument for a major is different, and it is about communication rather than compatibility: this changes an observable identifier and shifts its meaning from per-machine to per-checkout. That earns a prominent changelog entry wherever it lands - local routing keys change once, stale bindings expire on their own, and anyone who scripted against the old
hostname_hashshape should stop.Relationship to the neighbouring issues
This should sit beside #4824, not subsume it. They are closely related and genuinely separate:
Neither blocks the other, and they touch at exactly one point: #4824 proposes a free-text routing-key field, which becomes more coherent once keys are opaque generated IDs. A UUID reads as a token to paste; today's
hostname_md5hashinvites the belief that you can construct one by hand, or that its contents mean something. So this issue strengthens #4824's proposal rather than absorbing it.Practical argument for keeping them apart, too: #4824 is a small Unity editor fix that could ship in a patch. This is a CLI change plus a set of deletions that wants a deliberate release slot and a changelog entry. Folding the former into the latter would bury a cheap user-visible fix inside a refactor.