Skip to main content

Testing Dapr Services with Signadot Sandboxes

Sandboxes isolate microservices by routing each request to the right version, so you deploy only what you changed and everything else comes from the shared baseline. Dapr is different. The routing key that makes this work travels inside an mTLS gRPC message between sidecars, where no proxy can read it.

This tutorial deploys a small order app to your Kubernetes cluster, then changes one service at a time and watches requests reach your copy while everything else keeps using the shared baseline. It covers service invocation and pub/sub, with no duplicated brokers, no per-developer clusters, and Dapr's own sidecar-to-sidecar mTLS intact throughout.

Estimated time: 20 to 25 minutes once the prerequisites below are in place. Installing them on a fresh machine takes longer.

Prerequisites
  • Docker, kubectl, minikube, Helm and Python 3.12 on your machine.

  • A Kubernetes cluster. These are the settings this was tested on, not a proven minimum:

    minikube start --cpus=6 --memory=6144
  • Dapr 1.18 with mTLS, in dapr-system. The chart enables mTLS by default; the flag below makes this tutorial's dependency on it explicit.

    helm repo add dapr https://dapr.github.io/helm-charts/ && helm repo update
    helm upgrade --install dapr dapr/dapr --version 1.18.3 \
    --namespace dapr-system --create-namespace \
    --set global.mtls.enabled=true --wait
  • The Signadot CLI, authenticated with your API key.

  • The Signadot Operator in the signadot namespace. In the Dashboard, go to Clusters > Connect Cluster and name the cluster; it hands you a helm install with your token filled in. Add --namespace signadot --create-namespace to that command, which omits both. The chart is not pinned, so you get the current release: this tutorial was run on operator 1.3.2 and again on 1.4.0.

The operator starts eight deployments at once, which can briefly saturate a laptop. If kubectl answers with a TLS handshake timeout right after that install, give it a minute; it clears on its own. Wait for the operator before going further:

kubectl -n signadot wait --for=condition=Available deploy --all --timeout=300s

Two limits worth knowing before you go further. This pattern is single-namespace: every call it emits is /v1.0/invoke/<app-id>/method with no namespace qualifier. And it covers HTTP service invocation and Redis Streams pub/sub, not actors, workflows, state or other brokers. Behavior and tradeoffs has the rest.

Check the name your cluster is registered under, which you will pass to --cluster:

signadot cluster list

Then get the code:

git clone https://github.com/signadot/examples.git
cd examples/dapr-tutorial

Full source: signadot/examples/dapr-tutorial

What you will deploy

Three services, one Redis, into a namespace you name:

  • frontend, an order console. A browser reaches it over ordinary HTTP, so Signadot routes this hop for you with no application code involved.
  • checkout, which prices an order and publishes a CloudEvent. The frontend reaches it by Dapr app ID, so this is the hop your application has to resolve itself, using the small adapter in app/signadot/.
  • order-processor, which subscribes to the orders topic and records the effect. Every copy of it receives every message and decides for itself whether to act, which Signadot calls selective consumption.
  • redis, the pub/sub broker and a shared ledger, with AOF persistence on a PVC.

Creating a sandbox gives you a second copy of one of those services, called a fork, running beside the original.

Three sandboxes and one RouteGroup, which lets one key select several sandboxes at once, give you five routing contexts to compare:

Contextfrontendcheckoutorder-processor
baseline, no keybaselinebaseline, full pricebaseline, standard
frontend sandboxforkbaselinebaseline
checkout sandboxbaselinefork, 10% offbaseline
processor sandboxbaselinebaselinefork, priority
combined RouteGroupforkforkfork

Signadot gives you the frontend column for free. The next section is why the other two columns cannot work the same way.

How Dapr changes the isolation problem

For an ordinary HTTP service, something in the request path reads the baggage header and picks a destination. That something might be DevMesh, Istio, or a Gateway API implementation. That is how the frontend column above works, with no application code involved.

Dapr never puts the key anywhere that path can see, which is why the checkout and order-processor columns need something else.

A Dapr call takes three hops. The app calls its own sidecar over loopback. That sidecar calls the callee's sidecar over mTLS gRPC on port 50002. The callee's sidecar calls the callee app over loopback. Your headers ride inside the InternalInvokeRequest protobuf, and the proto comment says so plainly: metadata "holds caller's HTTP headers or gRPC metadata". So the two loopback hops never reach the pod network, and the one hop that does carries your key inside an encrypted message body.

The decision has to move one step earlier, into the caller. Before invoking, the caller asks the Routes API which sandbox owns this routing key, then invokes that workload's own Dapr app ID.

  1. A request arrives carrying baggage: sd-routing-key=<key>. Signadot issues that key when you create a sandbox, and puts it on the request when you open the sandbox's preview URL. You can also set the header yourself, or use the browser extension.
  2. The caller reads the key and asks the Routes API which sandbox claims it for the workload it is about to call.
  3. The caller resolves that answer to a Dapr app ID and invokes it natively.
  4. The fork runs its own daprd with that unique app ID, so the sidecar-to-sidecar hop is an ordinary Dapr call with its own SPIFFE identity.
  5. For pub/sub nothing selects a destination. Every consumer group receives every message, and each worker asks the same Routes API whether it owns the routing context before acting.

The highlighted hop at the top is the only one Signadot selects for you, and it does that the way it does for any HTTP service. Every hop below it the application has to route itself, because the key is sealed inside the sidecar-to-sidecar message: the caller resolves the destination before invoking, and each worker decides for itself whether it owns an event.

The result is an invariant: exactly one version of each workload acts on a given request, and no hop gives up mTLS to achieve it.

Deploy the demo

Run everything below from the examples/dapr-tutorial directory you cloned into.

scripts/tutorial.py is this example's own helper, not a Signadot tool. It exists because a Dapr fork needs a sidecar built from values that only exist on your running cluster, which section 6 explains. Under the hood it calls kubectl and signadot sandbox apply for you.

Its flags need a word before you use them. --context is your kubeconfig context; --cluster is the Signadot cluster name you checked earlier, and a wrong one sends API calls elsewhere. --namespace is the Kubernetes namespace it creates and works in. --name prefixes the Signadot sandboxes and RouteGroup it creates, and has to be unused in your account, at most 20 characters. All flags go before the subcommand.

The commands below use dapr-demo for both. Keep that to follow along, or substitute your own name consistently, because every later command repeats it.

Build the application image and load it into Minikube:

docker build -t dapr-signadot-tutorial:local app
minikube image load dapr-signadot-tutorial:local

Use a new tag whenever you rebuild, or IfNotPresent will quietly reuse the old code.

Check the cluster before anything is created. doctor is read-only and inspects both control planes and your account access:

python3 scripts/tutorial.py \
--context minikube --cluster minikube \
--name dapr-demo --namespace dapr-demo doctor

It prints a JSON report of what it found and ends with a single verdict line, OK: doctor found no blocking problem.... If a check fails instead, nothing has been created yet and the message names the check.

Now deploy:

python3 scripts/tutorial.py \
--context minikube --cluster minikube \
--name dapr-demo --namespace dapr-demo \
--image dapr-signadot-tutorial:local up

up creates the namespace, Redis, the Dapr component and resiliency policy, the three baselines, and finally the sandboxes and RouteGroup. It finishes by printing phase: ready and the preview URLs. If it stops earlier, the phase it reached tells you where, and down cleans up whatever it did create.

Test the sandboxes

up already created the three sandboxes and the RouteGroup, so this section is about seeing what they do. Start by asking for their preview URLs:

python3 scripts/tutorial.py \
--context minikube --cluster minikube \
--name dapr-demo --namespace dapr-demo status

That prints the whole deployment state as JSON. The four URLs you want are the previewEndpoints under sandboxes and routegroup, and each looks like this:

https://console--dapr-demo-checkout.preview.signadot.com

Each sandbox and the RouteGroup publishes one preview endpoint. The specs name that endpoint console, which is why every URL starts with console-- even for the checkout and processor sandboxes: the endpoint always points at the order page, and only the routing key changes.

Open one in a browser. Preview URLs are protected, so the first one sends you to Signadot to sign in; after that the rest open directly. Signadot then injects that context's routing key into every request the page makes, so the console reports "routingKeySetBy": "Signadot preview endpoint" and locks its context selector to that one context.

Leave the console's default item, set the quantity to 3, and place the order. It is priced at $12.00 a unit, so the baseline total is $36.00 and a 10% checkout discount makes it $32.40. The order that comes back names which copy of each service handled it, its total, and whether fulfillment was standard or priority. Do that on each of the four preview URLs.

The remaining row needs a request with no routing key at all, so reach the frontend directly:

kubectl -n dapr-demo port-forward svc/frontend 8080:8080
# then open http://localhost:8080

That command holds the terminal open, so run it in its own and press Ctrl-C when you are done.

Compare what you get:

How you opened itfrontendcheckoutorder-processorTotal for 3
port-forward, no keybaselinebaselinebaseline, standard$36.00
…-frontend previewforkbaselinebaseline, standard$36.00
…-checkout previewbaselineforkbaseline, standard$32.40
…-processor previewbaselinebaselinefork, priority$36.00
…-combined previewforkforkfork, priority$32.40
A port-forward always shows you the baseline frontend

Loopback is not intercepted by DevMesh, so a port-forward reaches the baseline frontend no matter which routing key you send. That is exactly what you want for the baseline row, and it does not affect the other three columns. It does mean a port-forward can never show you a forked frontend, so use the preview URLs for that.

The …-checkout row is the whole tutorial in one number. The frontend you are looking at is the baseline. Its call to checkout crossed an encrypted sidecar hop that no proxy can inspect, and still reached the forked version, which applied its discount. Every other request on the cluster carried on reaching the baseline.

A live verifier checks the same routing behavior on its own:

python3 scripts/verify.py \
--context minikube --namespace dapr-demo --name dapr-demo \
--output acceptance.json

It streams [verify] … progress lines while it works and finishes with a single verdict:

PASS: 9 completed checks; evidence: acceptance.json

A failed assertion exits nonzero and leaves acceptance.json behind with the check that failed. It needs no virtualenv, using only the standard library.

What a PASS does and does not cover

The verifier places its own orders over two paths, a port-forward and an in-cluster Pod, and sets the routing key as an explicit baggage header. It confirms the non-owning worker group records nothing for any of them. It does not replay the orders you placed in the browser or open the preview URLs, so a PASS covers the routing, not the hosted preview path.

How the code works

You have now watched the discount reach a forked checkout across an encrypted hop. This section is the how. Everything below is already running in what you deployed, so these are excerpts to read rather than steps to carry out, and they need no edits. The reusable pieces live in app/signadot/ and contain no pricing, fulfillment or Redis logic.

1. Resolve the destination before invoking

The frontend has to call checkout, and it cannot know which version until the request arrives. Wrap your Dapr client once at startup, and it works that out per call:

app/frontend/main.py
# once, at startup
integration = SignadotDaprClient(routes, dapr, lambda: read_config(settings)[1],
max_age=settings.routes_max_age,
refresh_seconds=settings.routes_refresh)

# and then per request, in the handler
response = await integration.invoke(Workload("Deployment", settings.namespace, name), path,
headers=request_context(request), verb=verb, json=order)

The third argument is the registry callback: it returns the current map of baseline workloads to Dapr app IDs, re-read on every call so a sandbox appearing or going away needs no restart.

When a request arrives, invoke validates its routing context, asks the Routes API who owns the key, turns that into a native Dapr app ID, and calls it through your local sidecar. You pass the baseline workload, so nothing in your handler needs to know a sandbox exists.

It forwards baggage, tracestate and traceparent, and nothing else. An incoming dapr-app-id header cannot override the destination it just resolved.

2. Learn which routing keys are claimed

Both the caller and the workers need to know which keys currently belong to live sandboxes. That state lives in the Signadot Routes API, which the operator runs in your cluster at http://routeserver.signadot.svc:7778. That is the default this example uses; set ROUTESERVER_ADDR to override it.

That name only resolves inside the cluster, so ask a Pod rather than your own terminal. The application image ships Python and no curl, so ask Python for it:

kubectl -n dapr-demo exec deploy/checkout -c app -- python3 -c \
'import urllib.request;print(urllib.request.urlopen("http://routeserver.signadot.svc:7778/api/v1/workloads/routing-rules").read().decode())'
# {"routingRules":[{"baseline":{"kind":"Deployment","name":"checkout","namespace":"dapr-demo"},
# "destinationSandbox":{"name":"dapr-demo-checkout"},
# "mappings":[{"destinations":[{"host":"dapr-demo-checkout-dep-checkout-...svc","port":50002}],
# "workloadPort":50002}],"routingKey":"nz6lm10guxppb"}]}

Fetch the whole document rather than using the server-side baselineName or baselineNamespace filters. You need every key in the map anyway: that is what separates "this sandbox does not fork my workload" from "I cannot see the map", and those two cases have to be handled differently.

Why the filters are worth avoiding: a version history

On operator 1.3.2 both filters returned incomplete results in our measurements; on 1.4.0, in a single namespace with distinct baseline names, both returned complete results. Fetching everything costs one small request and behaves the same on either.

3. The routing contract, in four cases

One policy, shared by the invocation and subscription paths:

The request carriesResolves toWhy
no routing keybaseline, with no Routes API callnothing to select
a key that forks this workloadthat sandbox's app IDthe ordinary case, and the point of the tutorial
a key that is in the map but forks something elsebaselinethe sandbox is real and simply does not change this workload
a key the map has never carriederror: HTTP 503, or RETRY on a subscriberthe map may be incomplete, and guessing baseline silently cancels someone's sandbox

The last row is the one that matters, the key the map has never carried. An incomplete map and a deleted key are indistinguishable from the client, and only one of them is safe to assume.

Why this example refuses instead of falling back

An earlier revision treated any unowned key as baseline. When the Routes API dropped rules that existed, a checkout sandbox's discount silently stopped applying while the page still reported success. Failing loud costs you a 503 on a deleted key; failing open costs you a sandbox that quietly stops applying.

4. Carry the key onto the event when you publish

Nothing propagates the routing context for you. When checkout publishes, it copies its own incoming routing headers into the CloudEvent envelope, so the key that arrived over HTTP leaves on the message:

app/checkout/main.py
headers = request_context(request)
key = routing_key(headers)
...
event = await integration.publish(settings.pubsub, settings.topic,
event_id=order_id, source=settings.app_id,
event_type="order.created", data=record, headers=headers)

headers is what carries the routing context onto the message. key is the same value pulled out as a string, and it goes into the order record as routing_key, which is how the console and the verifier can later show you which context produced a given order.

publish builds the event through cloud_event(), which puts baggage, tracestate and traceparent at the top level of the envelope, beside id and source, rather than inside data. That is the same place the subscriber reads them from, and keeping them out of data means your business payload stays yours.

5. Gate the subscriber before business validation

Every worker receives every message, so each one has to answer the same question on delivery: is this mine? Three of the four answers are ways to decline, each needing a different reply to Dapr:

app/order_processor/main.py
key = None
try:
key = validate_cloud_event(event, headers)
except (ValueError, RoutingError) as exc:
return self.reply("drop", "DROP", event, key, reason=str(exc))
try:
owns = self.guard.owns(key)
except RoutingError as exc:
return self.reply("retry", "RETRY", event, key, reason=str(exc))
if not owns:
return self.reply("skip", "SUCCESS", event, key, reason="different workload owner")

A malformed event is a DROP, because retrying will not fix it. An undecidable one is a RETRY, which is why owns() raises instead of returning False when it cannot see a usable map. A decidable "not mine" is a SUCCESS: acknowledge it and do nothing, so the message does not sit in the queue waiting for a worker that already declined it. The fourth answer is the one with no early return: fall through all three and this worker owns the message, so it reads the business payload and records the order.

Notice where this sits. Ownership is settled before the business schema is read. Do it the other way round and a sandbox's incompatible payload lands in the baseline worker's dead-letter queue, which means your test pages somebody else.

Every app ID gets its own consumer group through consumerID: "{appID}", and inbound retries are bounded at 3 with a dead-letter topic, so a refused key cannot become a poison pill.

6. The fork's sidecar, and why the spec is generated

Two Deployments cannot share a Dapr app ID. The Dapr operator rebuilds the <app-id>-dapr Service with a full PUT, so the last writer wins. Worse, deleting the sandbox can take that Service with it, which breaks invocation to the baseline. Each fork therefore gets a unique app ID, and it cannot get one by simply renaming dapr.io/app-id and letting the Dapr injector do the rest.

Why renaming the app ID and letting the injector do the rest does not work

The Dapr operator's reconciler would then create a second selecting Service for the fork, <new-app-id>-dapr, and the sandbox's customization validation rejects the fork, with InvalidCustomization: ... would cause cloned Pods to match the selector for baseline service. We saw this on operator 1.3.2 and again on 1.4.0. Adding routing.signadot.com/ignore=true to that Service does not get you past it.

Instead the fork is patched to dapr.io/enabled: "false", which skips both the injector and the Dapr Service reconciler, and is given an explicit daprd container cloned from the running injector's own configuration: current image, control-plane addresses, trust anchors, projected identity token. Those values only exist at runtime on your cluster, which is why the sandbox spec is generated rather than checked in.

You do not run this yourself. It is what up does for each sandbox, through the standard signadot sandbox apply. The spec it generated is left behind at .state/dapr-demo/manifests/dapr-demo-checkout-sandbox.json if you want to read one.

One more piece: a Dapr caller resolves app ID X by looking for a Service called X-dapr, and nothing has created one for the fork's new app ID. So each invoked fork gets a selector-free ExternalName alias named after its app ID.

What that alias looks like
dapr-demo-checkout-dapr        (created here, ExternalName, port 50002)
-> dapr-demo-checkout-dep-checkout-<hash>.dapr-demo.svc.cluster.local (created by Signadot)

It carries routing.signadot.com/ignore: "true" so DevMesh leaves it alone, and it forwards no traffic itself: Dapr uses it only to discover where the fork's sidecar lives. The frontend fork needs none of this, because nothing invokes it by app ID.

A real run's complete output, covering every Deployment, Service, Component, Resiliency policy, sandbox spec and RouteGroup, is committed at k8s/generated-example/ so you can read it without deploying. It is a record rather than something to apply: the trust anchor you will see in those sandbox specs is the public root CA of the cluster that produced them, and the generator reads yours from your own injector.

Behavior and tradeoffs

  • The integration cost sits with the application. Each caller needs an adapter around its outbound calls. Each forked workload that is invoked by app ID needs a cloned sidecar, a unique app ID, a DNS alias, and port 50002 on the baseline Service. You can generate the manifest work. You cannot generate the adapter, because Dapr gives you no name-resolution seam to hide it behind.

  • Every consumer group receives every message. Selective consumption is what makes pub/sub isolation work, and it means broker load grows with the number of sandboxed consumers. That is fine for integration testing. Size for it if you have a high-volume topic and many sandboxes at once.

  • An unclaimed routing key fails rather than falling back. This is the opposite of the usual advice, and it is deliberate. Failing loud costs you a 503 on a deleted key. Failing open costs you a sandbox that quietly stops applying while the page still reports success. There is no flag for the older behavior; if you want it, you are choosing to write that fallback yourself.

  • The broker connection is yours to secure. Dapr's mTLS covers the sidecar-to-sidecar hop, not the link from a sidecar to your message broker. This example's Redis runs plaintext because it is a demo broker on your own cluster. Configure TLS on the component the way you would for any Dapr deployment; nothing here changes that.

  • A fork keeps the Dapr Configuration it was cloned with. Because the fork's Pod is annotated dapr.io/enabled: "false", the Dapr operator skips it when resolving an app's Configuration, so later changes to that Configuration do not reach a running fork. Recreate the sandbox to pick them up, and scope ACLs and resiliency policies to the fork's app ID.

  • Route handover is not atomic. An event already in flight when a route changes has no ordering guarantee. Setup and the verifier both wait for the expected routing map before they publish, and your tests should do the same.

  • It is single-namespace. Every call this example emits is /v1.0/invoke/<app-id>/method with no namespace qualifier, and Dapr resolves an unqualified app ID in the caller's namespace. The registry refuses a multi-namespace configuration rather than emitting a call that cannot express it.

  • Traffic Manager routes are not implemented. If that operator component is handling routing for your own workload, this client refuses rather than guessing. A Traffic Manager mapping on another team's workload is ignored, and its key still counts as claimed.

  • This covers HTTP invocation and Redis Streams pub/sub. Actors, workflows, state, bindings and other brokers are out of scope here.

Adapt this to your stack

The reusable layer is app/signadot/. It uses plain HTTP to the sidecar and no Dapr SDK, so it ports to any language with an HTTP client and JSON. The checklist below is the shape of the work; the example's README has the import surface and the registry document it expects.

  1. Add the platform layer to your shared code: a Routes API client with a polling cache, an app-ID registry, an invocation wrapper, and a subscription guard.

  2. Publish port 50002 on the Services of any Dapr app that is invoked by app ID.

  3. Set environment variables on your Deployments:

    ROUTESERVER_ADDR
    BASELINE_NAME BASELINE_NAMESPACE
    ROUTES_REFRESH_SECONDS ROUTES_MAX_AGE_SECONDS

    SIGNADOT_SANDBOX_NAME is injected by the operator. The example also names its peers with FRONTEND_BASELINE_NAME, CHECKOUT_BASELINE_NAME and PROCESSOR_BASELINE_NAME so that no app ID is inferred from a sandbox name.

  4. Give each fork a unique app ID and a cloned sidecar, plus the <app-id>-dapr alias. Build these from the live injected Pod spec. You can template the shape, but the sidecar image, trust anchors and control-plane addresses have to be read from the cluster at the time you apply.

  5. Keep using your SDK if you have one. Resolve the app ID first, then pass it to the SDK's invoke call. No SDK forwards inbound headers automatically in any language, so you write that step either way. For pub/sub, put the key inside a CloudEvent you build yourself, because SDK publish metadata is component metadata rather than message headers.

Cleanup

python3 scripts/tutorial.py \
--context minikube --cluster minikube \
--name dapr-demo --namespace dapr-demo down

down deletes the RouteGroup and sandboxes, then the namespace including the Redis volume. It refuses to touch resources it does not own, so Dapr, the Signadot operator and anything else on the cluster are left alone. To remove those too, delete the cluster:

minikube delete
If a fork's Deployment gets recreated: reconcile

When the Signadot operator recreates a fork's Deployment, which can happen after a sandbox update, the ExternalName aliases point at Services that no longer exist. reconcile re-reads the live sandboxes and RouteGroup and repairs those aliases and the routing-key ConfigMap. It creates and deletes nothing else, and needs up to have run first. On a healthy namespace it is a no-op, so it is safe to run whenever you are unsure.

python3 scripts/tutorial.py \
--context minikube --cluster minikube \
--name dapr-demo --namespace dapr-demo reconcile

Frequently asked questions

Why can't DevMesh route Dapr service invocation like it routes ordinary HTTP?

Because there is no header to route on. Dapr carries the caller's headers inside the InternalInvokeRequest protobuf, which the proto comment states outright, and sends that over mTLS gRPC on port 50002. The two loopback hops on either side never touch the pod network. Turning mTLS off does not help, because the key is still inside a binary message body rather than a transport header.

Could a custom Dapr name-resolution plugin do this instead?

No. getRemoteApp receives a bare app ID, and resolutions are cached on namespace/id/port. Two requests to the same app ID carrying different routing keys therefore resolve to the same cache entry, by construction, so a resolver has no seam to route per request even if it could see the key.

Does the sandboxed workload lose Dapr mTLS or ACLs?

No. The fork runs its own daprd with its own app ID, so it presents its own SPIFFE identity and the handshake succeeds natively. The features that ride that sidecar apply as usual: mTLS, ACLs, resiliency policies, middleware and metrics. This is why the tutorial does not use an HTTPEndpoint, which would bypass the callee's sidecar entirely. One qualification: a fork keeps the Dapr Configuration it was cloned with rather than picking up later changes, because its Pod is not annotated for Dapr. Behavior and tradeoffs has the detail.

What happens to a routing key that no sandbox claims?

It is refused, not served from the baseline. A key that is real but forks some other workload selects the baseline, which is the right answer. A key the Routes API map never carried at all raises an error, which is HTTP 503 for a caller and RETRY for a subscriber. From the client, an incomplete map and a deleted key look identical, and only one of them is safe to guess.

Do all sandboxes receive every message on the topic?

Yes, and that is deliberate. Each app ID gets its own Redis consumer group through consumerID: "{appID}", so the baseline worker and every sandboxed worker receive their own copy of every message. Each one then decides whether it owns the routing context. Broker load therefore scales with the number of sandboxed consumers.

Does this work with the Dapr SDKs, or only plain HTTP?

It works with the SDKs. No Dapr SDK forwards inbound headers automatically in any language, so you write the forwarding step regardless. Resolve the app ID first, then pass the resolved ID to your SDK's invoke call. For pub/sub, put the routing key inside a CloudEvent you build yourself, because SDK publish metadata is component metadata rather than message headers.

Can I use this across namespaces?

Not as written. Every call this example emits is /v1.0/invoke/<app-id>/method with no namespace qualifier, and Dapr resolves an unqualified app ID in the caller's namespace. The registry refuses a configuration that names workloads in more than one namespace rather than emitting a call that cannot express it.

See also