Template Resolution
When onboarding provisions a device, it sends only a templateId — never the
template body. The provider resolves that ID to a concrete provider template
on demand by calling core-management over the canonical NATS+MessagePack wire.
This page covers the wire, the cache semantics, and how transport faults
surface to the provisioning request.
Released in mqttprovider 1.38.x alongside the canonical-wire migration. Pre-1.38 builds embedded the template payload in the provision request; that path is removed.
Why passthrough
The provision request is the hot path: every device that boots into onboarding hits it. Sending the full template payload on every request meant:
- Wire amplification — large templates ride along even when nothing changed, blowing out NATS message size budgets.
- Cache inversion — onboarding became responsible for caching templates it doesn't own, with stale-window semantics that didn't match core-management's truth.
- Authoring lag — template edits in core-management didn't take effect until onboarding's cache turned over.
templateId passthrough flips this: onboarding forwards an opaque identifier;
the provider — which sits closest to the device and already has a NATS
connection — resolves it against core-management. Templates are cached at the
edge that actually consumes them.
Wire shape
onboarding ──provision(templateId)──▶ provider
│
▼
ITemplateClient
│
┌───────────────┴────────────────┐
│ cache hit (≤ 60 s) │
│ → return cached template │
├────────────────────────────────┤
│ cache miss / negative-expired │
│ → NATS request (5 s budget) │
▼ ▼
cache and return BusApiResult.Failure
→ ProvisionResult.Failed
Subject
bt.coremgmt.providerTemplates.get
Built via BeaconTower.Bus.Contracts.Common.BusApiSubjects.GetProviderTemplate
— treat the literal string as illustrative; the constant is the contract.
Request / response contracts
[MessagePackObject]
public sealed record GetProviderTemplateRequest(
[property: Key(0)] string TemplateId);
[MessagePackObject]
public sealed record GetProviderTemplateResponse(
[property: Key(0)] string TemplateId,
[property: Key(1)] string ProviderType,
[property: Key(2)] byte[] Body); // opaque MessagePack-encoded template
The reply is wrapped in BusApiResult<GetProviderTemplateResponse> (see
Canonical wire reply envelope).
404 surfaces as BusApiResult.Failure with code template.not-found; treat
this as a permanent classification (no retry — the template has not been
authored).
Cache semantics — NatsTemplateClient
The default ITemplateClient implementation, NatsTemplateClient, is a
process-wide singleton that ships with the SDK. It maintains two caches keyed
by templateId:
| Cache | TTL | Trigger |
|---|---|---|
| Positive | 60 s | Successful resolve |
| Negative | 60 s | template.not-found reply |
Entries are evicted lazily on read. There is no background sweep — a stale-but-unused entry simply lingers until the next miss.
The TTL is intentionally short. Template authoring in core-management is rare but visible: a 60-second worst-case propagation matches operator expectations without forcing a synchronous round-trip on every provision.
Negative caching
Negative entries exist to absorb the common authoring-lag case: a tenant references a template ID that exists in their config but hasn't been published to core-management yet. Without negative caching, a misconfigured fleet would hammer the bus with hundreds of identical 404s per second.
If you need to invalidate a negative entry sooner than 60 s (e.g., the template has just been authored and you want a hot retry), restart the provider. There is no public flush API by design — caches must be safe under concurrent load and a flush hook is a foot-gun.
Timeouts and fast-fail
Each resolve call carries a 5 s application timeout and sets
RequestNoResponders = true on the underlying NATS request. The combination
gives you two distinct failure modes:
| Condition | Surface | Retry classification |
|---|---|---|
| No core-management consumer registered | Immediate NoResponders | Transient — operator action |
| Consumer registered, slow reply | 5 s timeout | Transient — retry next provision |
| Consumer registered, error reply | BusApiResult.Failure with code | Code-dependent (see below) |
RequestNoResponders=true is what stops the provider from sitting on a 5 s
timeout when core-management is entirely down — you get a fast NoResponders
and can fail the provision request with a clear transport.no-responders
classification.
Transport-fault mapping
NatsTemplateClient maps NATS / Bus.Contracts errors onto a closed set of
provider-side error codes. Adapters and provisioning callers should not invent
their own codes — match these exactly:
| Source | Provider-side code | Retry? |
|---|---|---|
NatsNoRespondersException | transport.no-responders | Yes (transient) |
| Application timeout (5 s) | transport.timeout | Yes (transient) |
Generic NatsException | transport.error | Yes (transient) |
| MessagePack deserialization failure | transport.malformed-reply | No (contract violation — log loudly) |
BusApiResult.Failure { error = "template.not-found" } | template.not-found | No (permanent) |
BusApiResult.Failure { error = "*" } (other) | template.{error} (passthrough) | Code-dependent |
The retry policy is the caller's responsibility: IProvisioningManager
classifies transport.* as transient and surfaces them as
ProvisionResult.Failed("transport.*") with the bus-api consumer returning
ProviderUnavailable. template.not-found is permanent and the device row is
not created.
Replacing ITemplateClient
The SDK registers NatsTemplateClient as the default implementation. Tests
and aggregator-side providers that resolve templates through a different
control plane can replace it after AddBeaconTowerProvider:
builder.Services.AddBeaconTowerProvider<MyAdapter>(builder, options =>
{
options.ProviderId = "my-provider";
});
builder.Services.Replace(
ServiceDescriptor.Singleton<ITemplateClient, FakeTemplateClient>());
Implementations must:
- Be thread-safe — the SDK calls in from BusApi consumer threads.
- Honour the 5 s timeout budget — provision callers cannot extend it.
- Return the same closed error-code set above so retry classification remains consistent across adapters.
Provisioning options
Two options under the Provisioning configuration section govern template
resolution. They are documented in full at
Configuration › Provisioning; the defaults are
sized for the canonical-wire profile:
| Option | Default | Effect |
|---|---|---|
TemplateCacheTtlSeconds | 60 | Positive + negative cache TTL. Lower it only if you accept the extra core-management traffic. |
TemplateResolveTimeoutSeconds | 5 | Per-call application timeout. Must be ≤ the bus-api consumer's overall budget. |
There is no separate negative-cache TTL knob — operationally the two windows are always equal so cache pressure stays predictable.
Related
- Canonical wire migration — high-level architecture
- Reference › NATS bus subjects — full subject map
- Reference ›
ITemplateClient— public surface - Configuration › Bus API — keyed
btbusconnection wiring - Error Handling › transport.* — caller-side classification