# alexa-bridge plan ## Correction on starting assumptions `alexa-bridge/internal/alexa` and `alexa-bridge/internal/directive` do not exist anywhere in this repo (checked working tree, `git log --all`, all branches, `go.work`). This plan therefore covers the **whole service from scratch** — signature validation and request/response types, directive routing, and the two pieces originally called out (entity resolution, generic Controller) — but stays lightest on the parts that were assumed already scaffolded and goes deep on entity resolution, the Controller/action mapping, and mTLS, per the original ask. Everything below was grounded by reading `proto/ha/v1/*.proto`, `ha-gateway/go.mod`, `ai-gateway/internal/adapters/secondary/hagateway/client.go`, `discord-bot/internal/adapters/secondary/gateway/client.go`, `ai-gateway/internal/config/config.go`, `ai-gateway/internal/core/domain/light_cache.go`, both services' `cmd/*/main.go`, and `~/repo/homelab/manifests/home-services/{certs,ai-gateway,discord-bot}.yaml`. ## Package layout Follows this repo's hexagonal convention (`core` has no adapter dependencies; ports are the only boundary) while keeping the four package names already chosen (`alexa`, `directive`, `entities`, `haclient`) rather than renaming them to `adapters/primary/...` etc. Mapping: `alexa` and the HTTP entrypoint play the primary-adapter role, `haclient`/`entities` play the secondary-adapter role, `directive` plays the app/orchestration role. ```text alexa-bridge/ ├── go.mod # module gitea.nik4nao.com/nik/home-services/alexa-bridge ├── .env.example ├── README.md ├── cmd/bridge/main.go # config, logger, telemetry, wiring, http.Server lifecycle └── internal/ ├── config/config.go # env loading (see mTLS section for TLS-specific fields) ├── logger/logger.go # mirror existing slog setup (New(format, level)) ├── telemetry/telemetry.go # mirror existing OTEL setup (no-op when OTEL_ENDPOINT empty) ├── core/ │ ├── domain/ │ │ └── entity.go # Entity{EntityID, FriendlyName, Domain string} │ └── ports/driven/ │ ├── controller.go # Controller interface (the one requested) │ └── entities.go # EntityResolver interface ├── alexa/ # Alexa Custom Skill protocol edge │ ├── types.go # Request/Response envelope, Session, Context, Intent, Slot │ ├── signature.go # ValidateSignature: cert-chain fetch+cache, RSA-SHA1 verify, timestamp check │ ├── handler.go # http.Handler: verify → decode → directive.Router.Dispatch → encode │ └── *_test.go ├── directive/ # intent → action orchestration ("app" layer) │ ├── router.go # Router{resolver driven.EntityResolver, controller driven.Controller} │ ├── intents.go # per-intent handlers, slot → entityID/action/params │ └── *_test.go ├── entities/ # EntityService client + cache + refresh (focus area 1) │ ├── client.go # gRPC wrapper: FetchAll(ctx) ([]domain.Entity, error) │ ├── resolver.go # in-memory Resolver implementing driven.EntityResolver │ ├── refresher.go # startup fetch + ticker-based periodic refresh │ └── *_test.go └── haclient/ # ha-gateway RPC client, implements driven.Controller (focus area 2) ├── client.go # mTLS dial, holds Light/Switch/Climate service clients ├── controller.go # ExecuteAction: domain dispatch + action → RPC + param coercion ├── params.go # typed param extraction helpers └── *_test.go ``` ## 1. Entity resolution (`internal/entities`) ### Which RPC to use — a decision, not a given The prompt assumed `EntityService` is the right client. Checked both options: - `EntityService.ListStates` (generic, any domain) returns `EntityState{entity_id, state, attributes map, ...}` — **no dedicated `friendly_name` field**. Confirmed by reading `ha-gateway/internal/app/{light,switch,climate}.go`: each extracts `s.Attributes["friendly_name"].(string)` itself, i.e. `EntityService` passes through HA's raw attributes map and friendly_name only exists as a string key inside it. - `LightService.ListLights` / `SwitchService.ListSwitches` / `ClimateService.ListClimates` each return a typed entity message with a dedicated `friendly_name` field, but that means three client stubs instead of one, and only cover those three domains structurally. **Recommendation: use `EntityService.ListStates`, called once per domain** (`domain: "light"`, `"switch"`, `"climate"`), reading `attributes["friendly_name"]` with a fallback to the entity_id suffix when absent/empty. This matches what was originally asked for (a single `EntityService` client), and loses nothing functionally — resolution only needs entity_id + friendly_name + a way to know which domain a name belongs to (see Controller section — domain dispatch there reads it straight off the entity_id prefix, e.g. `light.living_room` → `light`, so no extra typed fields like `supported_color_modes` are needed here). `ListStatesRequest.domain` is a single string, not repeated, hence three calls (issued concurrently) rather than one unfiltered call — this also avoids pulling every sensor/automation/etc. in the HA install into the lookup. ```go // internal/entities/client.go type Client struct { entityClient hav1.EntityServiceClient domains []string // {"light", "switch", "climate"} — extend if remote/other domains get in scope, see Open Questions } func (c *Client) FetchAll(ctx context.Context) ([]domain.Entity, error) { // fan out one ListStates(domain: d) call per configured domain, concurrently; // for each EntityState, entity := domain.Entity{ // EntityID: s.GetEntityId(), // FriendlyName: firstNonEmpty(s.GetAttributes()["friendly_name"], s.GetEntityId()), // Domain: d, // } } ``` ### Resolver + refresh - `Resolver` holds a `map[string]domain.Entity` keyed by normalized (lowercased, trimmed) friendly name, swapped atomically on each refresh (`atomic.Pointer[map[string]domain.Entity]` or `sync.RWMutex`) so an in-flight Alexa request never observes a half-built map. - **Startup**: `FetchAll` is called once, synchronously, before the HTTP server starts accepting traffic — fail loudly (`os.Exit(1)`) on error, mirroring how `haClient`/`aiClient` setup failures are handled in `ai-gateway/cmd/gateway/main.go` and `discord-bot/cmd/bot/main.go`. Without an initial entity list nothing can resolve, so there's no useful degraded mode. - **Periodic refresh**: a background goroutine on a `time.Ticker` (`ENTITY_REFRESH_INTERVAL`, suggest default `5m` — HA's entity/name set changes rarely) calls `FetchAll` again and replaces the resolver's map. Unlike startup, a *periodic* refresh failure only logs and keeps serving the last-known-good map — going down because HA was briefly unreachable would be worse than serving slightly stale entity names. - This is a new caching shape, not reused code: `ai-gateway`'s `LightCache` (`ai-gateway/internal/core/domain/light_cache.go`) is lazy/pull-based (refreshes on `Get()` if stale, no background goroutine) since ai-gateway can tolerate a slow first request. The explicit "on startup plus periodic refresh" ask is closer to a push-based background loop, so don't reuse `LightCache` as-is. ### Open question — name matching is exact-match only in this design `Resolver.Resolve(friendlyName string)` does an exact match on the normalized string. Alexa's ASR won't always produce a string that matches HA's `friendly_name` verbatim (e.g. user says "living room light", HA has it named "Living Room Lamp"). Options, not resolved here: 1. **Ship exact-match for v1** (simplest, what this plan assumes) and accept some resolution misses as a known limitation. 2. Add fuzzy matching (substring / Levenshtein) client-side in the resolver. 3. Use Alexa's own slot entity resolution — define the `Device` slot against a custom slot type and keep it in sync via the `SetDynamicEntities` directive, so Alexa's own NLU does the fuzzy matching against the same entity list before the request ever reaches alexa-bridge. Best UX, most infra (interaction-model + skill-console work, not just backend code). Recommend (1) now, flag (3) as the real long-term fix, and revisit once real usage shows how often exact-match actually misses. ## 2. Controller (`internal/haclient`, `internal/core/ports/driven`) ```go // internal/core/ports/driven/controller.go package driven type Controller interface { ExecuteAction(ctx context.Context, entityID, action string, params map[string]any) error } ``` `haclient.Client` implements it. Dispatch is two-level: entity domain (parsed off `entityID`'s prefix up to the first `.`, e.g. `light.living_room` → `light` — no separate lookup needed, this is exactly what HA/`ha-gateway` entity IDs already encode) selects which typed gRPC client to use, then `action` selects the RPC within that domain. ### Action → RPC table (exact types, from `proto/ha/v1/*.proto`) | Domain | action | RPC | Request construction | |---|---|---|---| | `light` | `turn_on` | `LightServiceClient.TurnOn` | `&hav1.TurnOnRequest{EntityId: entityID, BrightnessPct: optUint32(params,"brightness_pct"), ColorTempKelvin: optUint32(params,"color_temp_kelvin"), RgbColor: optRGB(params,"rgb_color"), Transition: optUint32(params,"transition")}` | | `light` | `turn_off` | `LightServiceClient.TurnOff` | `&hav1.TurnOffRequest{EntityId: entityID, Transition: optUint32(params,"transition")}` | | `light` | `toggle` | `LightServiceClient.Toggle` | `&hav1.ToggleRequest{EntityId: entityID}` | | `light` | `set_brightness` | `LightServiceClient.TurnOn` | `&hav1.TurnOnRequest{EntityId: entityID, BrightnessPct: reqUint32(params,"brightness_pct")}` — reuses `TurnOn`; there's no separate brightness RPC, but `TurnOnRequest.brightness_pct` is `optional`, so a request with only that field set both sets brightness and turns the light on, matching HA's own `light.turn_on` semantics. (This resolves the exact gap the original prompt guessed might be missing — it isn't.) | | `light` | `set_color_temp` | `LightServiceClient.TurnOn` | `&hav1.TurnOnRequest{EntityId: entityID, ColorTempKelvin: reqUint32(params,"color_temp_kelvin")}` | | `light` | `set_color` | `LightServiceClient.TurnOn` | `&hav1.TurnOnRequest{EntityId: entityID, RgbColor: reqRGB(params,"rgb_color")}` | | `switch` | `turn_on` | `SwitchServiceClient.TurnOn` | `&hav1.SwitchRequest{EntityId: entityID}` | | `switch` | `turn_off` | `SwitchServiceClient.TurnOff` | `&hav1.SwitchRequest{EntityId: entityID}` | | `switch` | `toggle` | `SwitchServiceClient.Toggle` | `&hav1.SwitchRequest{EntityId: entityID}` | | `climate` | `turn_on` | `ClimateServiceClient.TurnOn` | `&hav1.ClimateRequest{EntityId: entityID}` | | `climate` | `turn_off` | `ClimateServiceClient.TurnOff` | `&hav1.ClimateRequest{EntityId: entityID}` | | `climate` | `set_hvac_mode` | `ClimateServiceClient.SetHVACMode` | `&hav1.SetHVACModeRequest{EntityId: entityID, HvacMode: reqString(params,"hvac_mode")}` | | `climate` | `increase_temperature` | `ClimateServiceClient.IncreaseTemperature` | `&hav1.ClimateRequest{EntityId: entityID}` | | `climate` | `decrease_temperature` | `ClimateServiceClient.DecreaseTemperature` | `&hav1.ClimateRequest{EntityId: entityID}` | | `climate` | `set_temperature` | `ClimateServiceClient.SetTemperature` | `&hav1.SetTemperatureRequest{EntityId: entityID, TargetTemperature: reqFloat64(params,"target_temperature")}` — **implemented** (see "Decisions on open questions" #1); single-setpoint only, no `target_temp_high`/`target_temp_low` | All response messages (`LightResponse`, `SwitchResponse`, `ClimateResponse`) just wrap `EntityState`; `ExecuteAction`'s `error`-only signature means the returned state is simply discarded (dropped, not needed by the interface as specified). ### Param coercion (`internal/haclient/params.go`) `params map[string]any` values will, in practice, almost always be `string` — they come from Alexa slot values (`req.Body.Intent.Slots["Brightness"].Value`), which are always strings even for `AMAZON.NUMBER` slots (e.g. `"80"`). Coercion helpers should therefore parse from `string` primarily, with a `float64`/`int` fallback only so unit tests (or a hypothetical future non-Alexa caller) can pass typed values directly — don't over-build for JSON-number decoding that won't actually occur on the real Alexa → directive → haclient path: ```go func optUint32(params map[string]any, key string) *uint32 // nil if key absent; parse error → nil + logged, not fatal func reqUint32(params map[string]any, key string) (uint32, error) func reqFloat64(params map[string]any, key string) (float64, error) // backs set_temperature, see Decisions #1 func reqString(params map[string]any, key string) (string, error) func reqRGB(params map[string]any, key string) (*hav1.RGBColor, error) // expects map[string]any{"r":..,"g":..,"b":..} ``` `params` is built in `internal/directive/intents.go`, one handler per Alexa intent, e.g.: ```go func (r *Router) handleSetBrightness(ctx context.Context, req alexa.Request) (alexa.Response, error) { entity, ok := r.resolver.Resolve(req.Body.Intent.Slots["Device"].Value) if !ok { return notFoundResponse(req.Body.Intent.Slots["Device"].Value), nil } err := r.controller.ExecuteAction(ctx, entity.EntityID, "set_brightness", map[string]any{ "brightness_pct": req.Body.Intent.Slots["Brightness"].Value, }) ... } ``` ## 3. mTLS (`internal/haclient/client.go`, `internal/config`) Mirrors `ai-gateway/internal/adapters/secondary/hagateway/client.go`'s `New` + `loadTransportCredentials` **verbatim** (client cert from `tls.crt`/`tls.key`, root CA from `ca.crt`, `ServerName` set to ha-gateway's cert CN, `MinVersion: tls.VersionTLS13`, insecure fallback via `credentials/insecure` when `tlsDir == ""`): ```go func New(ctx context.Context, addr, tlsDir, serverName string, log *slog.Logger) (*Client, error) { transportCreds := insecure.NewCredentials() if tlsDir != "" { creds, err := loadTransportCredentials(tlsDir, serverName) // identical to ai-gateway's version ... } conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(transportCreds), grpc.WithStatsHandler(otelgrpc.NewClientHandler())) ... return &Client{ conn: conn, lightClient: hav1.NewLightServiceClient(conn), switchClient: hav1.NewSwitchServiceClient(conn), climateClient: hav1.NewClimateServiceClient(conn), entityClient: hav1.NewEntityServiceClient(conn), // for internal/entities }, nil } ``` ### Config — one deliberate deviation ```go type Config struct { ... HAGatewayAddr string // default "ha-gateway.home-services.svc.cluster.local:50051" HAGatewayServerName string // default "ha-gateway.home-services.svc.cluster.local" TLSDir string // default "/tls" — see note below } ``` `ai-gateway` and `discord-bot` both default `TLS_DIR` to **empty** (mTLS opt-in, enabled only by an explicit env var in the k8s manifest). Since `alexa-bridge` is this repo's one internet-facing service, default `TLS_DIR` to `/tls` instead — mTLS to ha-gateway should be on unless someone deliberately unsets it for local dev, not on only if someone remembers to set it. Still fully overridable (empty string keeps the existing insecure-fallback behavior for local plaintext dev, matching `TLSDir string // default ""` + `validateTLSDir` in the other two services' `config.go`, copied as-is here). ### homelab-repo Certificate (separate repo, separate change — not made here) Modeled on `discord-bot-tls` (`~/repo/homelab/manifests/home-services/certs.yaml`) since, like discord-bot, alexa-bridge only *dials* ha-gateway over mTLS — it doesn't itself serve mTLS gRPC, so no `server auth` usage or `dnsNames` needed: ```yaml apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: alexa-bridge-tls namespace: home-services spec: secretName: alexa-bridge-tls issuerRef: name: internal-ca-issuer kind: ClusterIssuer commonName: alexa-bridge usages: - client auth - digital signature - key encipherment ``` Note on naming: the prompt suggested `alexa-bridge-client-tls`; existing certs all follow `-tls` regardless of client/server/dual usage (`ha-gateway-tls` is server-only, `discord-bot-tls` is client-only, `ai-gateway-tls` is both — none disambiguate in the name). Defaulting to `alexa-bridge-tls` for consistency; flagging in case there was a specific reason for wanting the longer name. Mount: a `tls` volume from secret `alexa-bridge-tls` at `/tls`, `readOnly: true` — same shape as `discord-bot.yaml`'s volume block. ## Open questions **Status: all four resolved — see "Decisions on open questions" below, which is now the source of truth for what actually got built.** Kept as originally written here for the reasoning trail; where the two sections disagree, Decisions wins. 1. ~~**`climate.set_temperature` has no backing RPC.**~~ **Resolved — implemented, see Decisions #1.** `ClimateService` only exposed `IncreaseTemperature`/`DecreaseTemperature` (relative step) and `SetHVACMode` — there is no "set target temperature to X°" RPC, even though that's a very natural Alexa ask ("set the thermostat to 72"). This is a real proto/`ha-gateway` gap, not a client-side workaround opportunity — the underlying `ha-gateway` `internal/app/climate.go` only computes `next := *TargetTemperature + direction*step`, it has no absolute-set path either. Options: (a) add a `SetTemperature(entity_id, target)` RPC to `ClimateService` in `ha-gateway` first (out of scope for alexa-bridge alone), (b) approximate it client-side in alexa-bridge by calling `IncreaseTemperature`/`DecreaseTemperature` in a loop toward the target (fragile — depends on knowing the current temperature and step size, race-prone), or (c) omit `set_temperature` from alexa-bridge's v1 intent set and only support hvac-mode/step-based climate control. Recommend (a), scoped as a small separate `ha-gateway` change, with (c) as the fallback if that's not wanted right now. 2. **Resolved — drafted (not applied), see Decisions #2.** alexa-bridge's own public HTTPS listener is a separate TLS concern from the mTLS covered above. Alexa's servers call *into* alexa-bridge over the internet and require a publicly trusted certificate — the `internal-ca-issuer` used for `alexa-bridge-tls` above won't satisfy that (Alexa doesn't trust this homelab's internal CA). The homelab repo already has the pattern for this: `manifests/gitea/gitea-public-ingress.yaml` pairs a `letsencrypt-prod` `ClusterIssuer` `Certificate` with a Traefik `IngressRoute` for a public hostname (`gitea.nik4nao.com`). alexa-bridge would need the equivalent — its own `Certificate` + `IngressRoute` (e.g. `alexa-bridge.nik4nao.com`) — as a homelab-repo change. Out of scope for this plan (which only covers the ha-gateway mTLS leg, as asked), but called out since alexa-bridge can't function without it and it's easy to lose track of as a "separate repo" task. 3. **Resolved — confirmed out of scope, see Decisions #3.** Remote/SwitchBot entities are excluded from entity resolution and the Controller's domain table. `RemoteService.SendCommand` is keyed by a SwitchBot `device_id`, not an HA `entity_id`, and there's no evidence these show up in `EntityService.ListStates` the same way light/switch/climate entities do (they're relayed from SwitchBot Cloud, not HA's own entity registry). Treating IR/remote control as out of scope for v1 Alexa support; would need separate design if wanted later. 4. **Resolved — implemented, see Decisions #4.** Skill ID verification: Alexa best practice (and Amazon's certification checklist) also expects verifying `request.context.System.application.applicationId` against a configured skill ID, on top of signature validation, to reject requests replayed from a different skill using the same endpoint. Not explicitly asked for, but cheap to add in `internal/alexa` — flagging so it's a conscious inclusion/exclusion rather than an oversight. ## Implementation steps 1. **Scaffold the module**: `alexa-bridge/go.mod` (module `gitea.nik4nao.com/nik/home-services/alexa-bridge`, matching the other four's `go 1.26` + `replace .../gen => ../gen`), add `./alexa-bridge` to root `go.work`. Add matching `COPY` lines (manifest-only + full-source, per `CLAUDE.md`'s CI note) to the other four Dockerfiles, and an `alexa-bridge` entry to `.gitea/workflows/ci.yaml`'s `changes` job, `test` job's vet/test loop, and a new `build-alexa-bridge` job. 2. **`internal/core/domain` + `internal/core/ports/driven`**: `Entity`, `Controller`, `EntityResolver` — no dependencies, straightforward. 3. **`internal/haclient`**: mTLS client + `Controller` implementation (section 2/3 above) — can be built and unit-tested (hand-written fakes for the generated gRPC clients, per this repo's no-testify convention) independently of the HTTP/Alexa side. 4. **`internal/entities`**: `EntityService` client + resolver + refresher (section 1 above), same independence/testability as step 3. 5. **`internal/alexa`**: request/response types, signature validation (cert-chain fetch+cache, RSA-SHA1 verify, timestamp tolerance ~150s, optionally skill-ID check per open question 4), HTTP handler wiring the above together. 6. **`internal/directive`**: intent handlers per the action table in section 2, wired to `driven.EntityResolver` and `driven.Controller`. 7. **`cmd/bridge/main.go` + `internal/config`**: env loading (including the `TLS_DIR` default deviation from section 3), logger/telemetry setup mirroring the other services, startup sequencing (blocking entity fetch before serving, per section 1), graceful shutdown. 8. **`Dockerfile` + `README.md`**: mirror `discord-bot`'s two-stage build shape (this one needs no `ffmpeg`/GPU equivalent, so a plain `distroless`/`scratch` final stage should work, unlike `discord-bot`/`tts-gateway`). 9. **homelab repo** (separate change, needs explicit confirmation before touching that repo): `alexa-bridge-tls` `Certificate` (section 3), `alexa-bridge.yaml` Deployment/Service, and the public ingress + `letsencrypt-prod` `Certificate` from open question 2. ## Decisions on open questions Resolved before implementation started; this section is the source of truth for what actually got built, superseding the "Open questions" section above where they conflict. 1. **`climate.set_temperature` → option (a), a real RPC.** Adding `ClimateService.SetTemperature(SetTemperatureRequest) returns (ClimateResponse)` to `proto/ha/v1/climate.proto`, reusing `ClimateResponse` like every other RPC on this service rather than inventing a new response type. `ha-gateway/internal/app/climate.go` gets a `SetTemperature` method that calls Home Assistant's `climate.set_temperature` service with the caller-supplied absolute target directly — `IncreaseTemperature`/`DecreaseTemperature` keep their existing read-current-state-then-step logic unchanged; they now sit alongside `SetTemperature` rather than being replaced by it. Checked the live HA instance (`mcp__home-assistant__GetLiveContext`): exactly one climate entity ("Air Conditioner"), with a single `temperature` attribute and no `target_temp_high`/`target_temp_low` — range-mode (`ClimateEntityFeature.TARGET_TEMPERATURE_RANGE`) is not in use anywhere in this HA instance, and `ha-gateway`'s existing `haStateToClimate` mapping doesn't parse those attributes either. **Scope: single-setpoint only. Range-mode climate entities are explicitly out of scope** — not half-built, not silently broken if one ever appears (it would just report no target temperature, same as any other unmapped attribute today), but genuinely unhandled. Revisit if a range-mode entity is ever added to this HA instance. alexa-bridge's action table gains `climate`/`set_temperature` → `ClimateServiceClient.SetTemperature`, and `internal/haclient/params.go` gains a `reqFloat64` helper alongside `reqUint32`/`reqString`/`reqRGB`. `internal/directive/intents.go` gains a `SetTemperatureIntent` handler reading a `Temperature` (`AMAZON.NUMBER`) slot, same shape as `SetBrightnessIntent`. 2. **Public HTTPS ingress for alexa-bridge — in scope.** Drafted (not applied) an `IngressRoute` + cert-manager `Certificate` for `alexa-bridge.nik4nao.com` against `letsencrypt-prod`, mirroring `manifests/gitea/gitea-public-ingress.yaml`'s shape exactly, as a separate homelab manifest file from `alexa-bridge-tls` (the `internal-ca-issuer` client cert for the mTLS leg to ha-gateway) — different issuer, different trust domain, different purpose; not to be confused or merged in naming. 3. **Remote/SwitchBot entities — out of scope for v1.** `RemoteService` is keyed by a SwitchBot `device_id`, not an HA `entity_id`, and doesn't appear in `EntityService.ListStates`, so it doesn't fit this service's entity_id-keyed `Controller`/`EntityResolver` design. Not implemented; would need its own resolution + dispatch path if ever added. 4. **Skill ID verification — included.** `internal/alexa`'s request validation checks the decoded envelope's application ID against a configured `ALEXA_SKILL_ID` env var, rejecting on mismatch alongside the signature and timestamp checks. Alexa populates `context.System.application.applicationId` on essentially all real requests (Custom Skill requests always carry a `context` object); `session.application.applicationId` is checked too as a fallback for the (now-legacy, but still-valid-per-spec) case of a request that carries a `session` object without a top-level `context` — `internal/alexa/types.go` decodes both and the check prefers `context` when present. 5. **Entity name matching — exact match only for v1** (already noted inline in the Entity resolution section above; recorded here for completeness since it's a resolved question, not a live one). Normalized as lowercased + trimmed, no fuzzy/substring matching. Alexa's `SetDynamicEntities` directive (option 3 in that section) is **not** wired up — both left as explicitly deferred future work rather than partially implemented.