Commit 8caf4367 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: Add ADR-0038 to define control-path resolution for device-targeted...

feat: Add ADR-0038 to define control-path resolution for device-targeted capabilities via adapter probing
parent 86acb110
Loading
Loading
Loading
Loading
+139 −0
Changes for decisions/0038-device-targeted-control-path-resolution.md: 139 added lines, 0 removed lines.
Original line number Diff line number Diff line
---
type: Decision Record
title: "ADR-0038: Control-path resolution for device-targeted capabilities is SRM's, via an adapter probe"
description: "When a request identifies a subscriber rather than a place, SRM selects the control_path_binding by probing candidate adapters with a new can_serve_target method; the loop and the choice stay in SRM, the per-backend answer comes from TF-SDK."
tags: [adr]
timestamp: 2026-08-26T00:00:00Z
status: "accepted"
---

# ADR-0038: Control-path resolution for device-targeted capabilities is SRM's, via an adapter probe

**Status:** accepted
**Date:** 2026-08-26
**Affects:** [srm/interface-contract.md](/srm/interface-contract.md), [platform/architecture.md](/platform/architecture.md), extends [ADR-0027](/decisions/0027-tfsdk-generic-capability-adapter-interface.md)

## Context

Every SRM flow except one starts from topology. A deploy request names `edgeCloudZoneId`, so
placement is a real choice inside a known zone. Requests for **device-targeted capabilities** —
`qod_session`, `traffic_influence`, `location_context`, `device_status` — carry no zone and no
domain, because the App Provider supplies a subscriber identifier, not a place. OEG never sets
`zone_id`/`domain_id` on them and cannot: it holds a phone number.

Those requests land in the network-capability pin rule for "neither pin present", which
[srm/interface-contract.md](/srm/interface-contract.md) §B inherited verbatim from deploy §B.2:
*"SRM places that instance using capability surface + `placement_constraints` hints."*
`placement_constraints` is a field on `DeployTargetV1` only; `NetworkCapabilityPayloadV1` has no
such field. The inherited rule therefore referenced an input that does not exist, leaving
"capability surface" — *any domain offering this capability* — as the whole rule. With one
network domain that resolves correctly by accident; with two it is undefined.

A `kind='network'` domain **is a NEF instance**
([srm/persistence-cross-standards-view.md](/srm/persistence-cross-standards-view.md)), and
`capability.domain_id` is NOT NULL, so two NEFs surface as two `capability` rows of the same
`kind` with one binding each — not as one capability with two bindings. The ambiguity is at the
capability level, and `control_path_binding.priority`/`is_default` (which tie-break siblings under
one capability) do not reach it.

Options considered for choosing among candidates:

- **(a) Assert exactly one.** Declare more than one binding a configuration error. Documents
  today's behaviour and costs nothing, but has no answer the day a second NEF exists.
- **(b) A static selector** on the binding — number ranges, IP blocks. Explicit and cheap, but
  wrong for the motivating case: a 4G/5G split is not a fixed property of a subscriber. The same
  device registers on 5G in coverage and 4G in a basement, so any stored rule is wrong part of the
  time.
- **(c) A subscriber registry in `srm_db`**, later synced from HSS/UDM. Rejected: it duplicates the
  system that already holds the answer, goes stale as devices move, makes `srm_db` a personal-data
  store at subscriber scale, and its useful form depends entirely on a sync that does not exist.
  If a per-subscriber lookup is ever required, querying the UDM live is strictly better than
  holding a copy.
- **(d) Probe candidates and take the first that can serve.** Correct while devices move, needs no
  stored subscriber data, and with two NEFs costs at most one extra call — a lookup miss, which
  returns in milliseconds against a query that otherwise pages the device over the air.
- **(e) Delegate the selection to TF-SDK**, on the reasoning that vendor knowledge lives there.

## Decision

Option (d), with the loop in SRM. For any device-targeted capability with no `zone_id`/`domain_id`:

1. **Candidates** are the active `control_path_binding`s under every active `capability` whose
   `kind` matches and whose `supported_actions` covers the action, excluding any whose adapter
   cannot act on an identifier the request actually supplied (identifier support is
   adapter-specific: a 3GPP-conformant NEF resolves a `gpsi` itself, a partial one may require a
   UE address).
2. **Exactly one candidate** — use it, with no probe. Single-NEF deployments pay nothing.
3. **More than one** — call `can_serve_target(request)` on each candidate's adapter in `is_default`
   then ascending `priority` order; the first true wins.
4. **None** — the target is not reachable here. Async capabilities fail `failed_before_start`;
   synchronous queries return `404` when no candidate holds the subscriber, and `422`
   `.../unsupported-identifier` when none accepts any identifier form supplied.

`can_serve_target` joins the `CapabilityExecutionAdapter` interface (ADR-0027). It is **optional**:
an adapter that does not implement it counts as able to serve, so the method is additive and no
existing adapter breaks. SRM records the chosen binding in
`capability_instance.control_path_binding_id`.

The rule is one rule for all four capabilities, not one per API family.

### Why the loop is SRM's and not TF-SDK's

Option (e) was rejected for four reasons, of which the last is decisive.

- **An adapter cannot reach another backend.** `control_path_ref → ControlPathRegistry → concrete
  adapter + connection config` (ADR-0004): each adapter instance is constructed with one backend's
  endpoint and credentials. The 4G adapter has no address for the 5G NEF. A TF-SDK-side loop would
  have to live above the adapters, in the registry — which SRM drives.
- **SRM writes down the outcome.** `capability_instance.control_path_binding_id` is SRM's
  inventory. A delegated choice would have SRM recording a decision it was told about, with the
  audit trail depending on the adapter reporting it.
- **The ordering data is in `srm_db`.** `priority`, `is_default`, `state`, `supported_actions` are
  columns on `control_path_binding`. TF-SDK is an in-process library (ADR-0024) with no database
  and no business reading SRM's.
- **The two concerns have different scope.** "Is this subscriber mine?" is about one backend and is
  vendor-specific — adapter knowledge. "Which backend, in what order, and what if none?" is about
  all backends, needs data only SRM holds, and produces rows and status codes only SRM can write.
  Delegating would place a cross-backend decision inside a per-backend component.

`can_serve_target` is the seam: the knowledge is exposed where it lives, the decision stays where
the data and the consequences are.

## Consequences

What becomes easier:

- A second network domain offering the same capability stops being undefined behaviour. Adding one
  is a topology change, not a code change.
- The rule is uniform across `qod_session`, `traffic_influence`, `location_context` and
  `device_status`, so no API family invents its own convention.
- No subscriber data enters `srm_db`, and no sync with HSS/UDM becomes a prerequisite.
- Correct while devices move between cores, which any stored mapping would not be.

What becomes harder:

- The adapter interface grows a method, and each vendor adapter must decide whether it can answer
  it meaningfully. Adapters that cannot are counted as able to serve, which is safe with one
  candidate and arbitrary with several — so a multi-NEF deployment requires adapters that
  implement it.
- With more than one candidate, the winning backend is contacted twice: once to probe, once to
  act. Probing in parallel and caching inside the adapter both remain available; neither is
  mandated here.

Deferred or excluded:

- **Per-subscriber routing.** If a deployment ever needs it, the answer is a live UDM query, not a
  stored table. Not designed here.
- **Multi-SIM and multi-PDU-session resolution.** Which device or session a matching target
  resolves to is backend behaviour; TF-SDK exposes whatever each vendor allows and OOP does not
  normalize it. The contract fixes only the caller-visible outcome
  ([srm/interface-contract.md](/srm/interface-contract.md) §B). That is a separate question from
  which control path is used and is not decided by this record.
- **Deploy placement is unchanged.** This record concerns device-targeted capabilities only;
  `placement_constraints` and deploy §B.2 keep their existing meaning.

Revisit trigger:

- A second network domain offering the same device-targeted capability is registered — at that
  point the probe path leaves theory, and adapters returning the default "can serve" must be
  audited before the deployment is trusted.
+1 −0
Changes for decisions/index.md: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -40,3 +40,4 @@ the reasoning lives here. Never renumber; supersede instead (see the record-deci
* [ADR-0035](0035-qod-session-service-specification.md) - a QoD session references one platform-seeded service specification held in OEG config; profile names stay unvalidated, and QoS profiles are a catalog-layer concern (resolves F2)
* [ADR-0036](0036-srm-owns-network-capability-expiry.md) - SRM owns expiry of async network capabilities and reports it via event.srm.operation.status metadata (qos_status_info); OEG runs no timer and maps it to CAMARA DURATION_EXPIRED
* [ADR-0037](0037-canonical-target-ports.md) - target.device.ports / target.application_server.ports join srm.params/v1's canonical target descriptor, generic across every capability with a device/application-server target rather than allowlisted per capability_type
* [ADR-0038](0038-device-targeted-control-path-resolution.md) - device-targeted capabilities resolve their control path by probing candidate adapters with can_serve_target; the loop and the choice stay in SRM, the per-backend answer comes from TF-SDK
+2 −1
Changes for platform/architecture.md: 2 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -686,7 +686,7 @@ These decisions are **binding** — they have been made and should not be reopen
| **Compute** | SRM commands → platform-specific deploy/scale/terminate calls | Kubernetes (via OCM), i2Edge, aerOS |

**Key design patterns (ADR-0027):**
- **One generic capability-execution interface:** SRM depends on a single `CapabilityExecutionAdapter` interface — `activate_capability` / `update_capability` / `deactivate_capability` / `query_capability` / `get_capability_runtime_state`, plus optional discovery methods (`get_zones`, `get_domains`, `get_capabilities`, `get_control_path_bindings`) — used uniformly across compute, network, trust, data, federation, and custom domains. Capability-specific behavior is selected by a `capability_kind` string (e.g. `deploy_workload`, `qod_session`, `traffic_influence`) carried in the normalized request, not by a distinct Python type per capability. This avoids a combinatorial explosion of base classes as TF-SDK grows from two domains to five (see Future Architecture below).
- **One generic capability-execution interface:** SRM depends on a single `CapabilityExecutionAdapter` interface — `activate_capability` / `update_capability` / `deactivate_capability` / `query_capability` / `get_capability_runtime_state` / `can_serve_target`, plus optional discovery methods (`get_zones`, `get_domains`, `get_capabilities`, `get_control_path_bindings`) — used uniformly across compute, network, trust, data, federation, and custom domains. Capability-specific behavior is selected by a `capability_kind` string (e.g. `deploy_workload`, `qod_session`, `traffic_influence`) carried in the normalized request, not by a distinct Python type per capability. This avoids a combinatorial explosion of base classes as TF-SDK grows from two domains to five (see Future Architecture below).
- **Adapter/executor resolution via `control_path_ref`:** SRM does not read an `adapter_type` column from the database to pick an adapter. Placement resolves a `control_path_binding` for the matched capability, and the binding's `control_path_ref` is resolved through the runtime ControlPathRegistry to the concrete adapter and its infrastructure connection config (ADR-0004). The database stores only the stable `control_path_ref` string, never adapter config or secrets.
- **Capability gating:** If an adapter doesn't support a requested `capability_kind`/action, it fails fast with a typed error — not deep inside vendor-specific code.
- **Internal adapter organization is not prescribed by the contract:** how a given adapter shares code across vendors internally (e.g. common HTTP client, auth, circuit breaker) is an implementation detail behind `CapabilityExecutionAdapter`, not part of the SRM-facing interface.
@@ -1129,6 +1129,7 @@ CapabilityExecutionAdapter ← one interface, all domains
  deactivate_capability(request)
  query_capability(request)
  get_capability_runtime_state(request)
  can_serve_target(request)         ← "is this subscriber reachable through me?"

control_path_ref → ControlPathRegistry → concrete adapter + connection config   ← ADR-0004
```
+46 −7
Changes for srm/interface-contract.md: 46 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -193,8 +193,47 @@ always see the canonical nesting; they never read the command-top-level field di

**Pin rules.** Same as deploy §B.2, except that `domain_id` here resolves to a domain with
`kind=network` rather than `kind=compute` — the subject fixes the kind, not the field name
(ADR-0031): zone-only, both (domain must belong to zone), neither (SRM selects), domain-only →
invalid (`failed_before_start`, §A); null and omitted are equivalent.
(ADR-0031): zone-only, both (domain must belong to zone), domain-only → invalid
(`failed_before_start`, §A); null and omitted are equivalent. **Neither pin present** resolves by
target probing below; deploy §B.2's `placement_constraints` clause does not apply, as
`NetworkCapabilityPayloadV1` carries no such field.

**Resolving the control path from the target.** A CAMARA-originated request carries no pin — the
caller supplies a subscriber identifier, not a place. This rule covers every device-targeted
capability (`qod_session`, `traffic_influence`, `location_context`, `device_status`):

1. **Candidates** are the active `control_path_binding`s under every active `capability` whose
   `kind` matches the request and whose `supported_actions` covers the action. Two NEFs are two
   `domain` rows, so they yield two capabilities of the same kind — one candidate each.
2. **Exactly one candidate** — use it. No probe; single-NEF deployments pay nothing.
3. **More than one** — SRM calls `can_serve_target(request)` on each candidate's adapter in
   `is_default` then ascending `priority` order, and uses the first that answers true. An adapter
   that does not implement `can_serve_target` counts as true, so existing adapters keep working.
4. **None answers true** — the target is not reachable here: async capabilities fail
   `failed_before_start`; sync queries return `404` (§E.2).

The adapter answers only for its own control path; **SRM makes the choice** and records it in
`capability_instance.control_path_binding_id`. Selection is never delegated to TF-SDK.

**Which identifiers a control path can act on is adapter-specific.** `target.device` may carry any
of `phone_number`, `ipv4`, `ipv6`, `network_access_id`; a backend need not accept all of them (a
3GPP-conformant NEF resolves a `gpsi` itself, a partial one may require a UE address). A candidate
that cannot act on any identifier the request supplied is not a candidate. This is distinct from
step 4 above and produces a different answer:

| No candidate because… | Async | Sync (§E.2) |
|---|---|---|
| none accepts any supplied identifier form | `failed_before_start` | `422` `.../unsupported-identifier` |
| none has this subscriber | `failed_before_start` | `404` |

**A target that matches more than one device or session.** A phone number may reach several
devices (multi-SIM) and one device may hold several PDU sessions. Resolving that is backend
behaviour: TF-SDK exposes whatever each vendor allows, and OOP does not normalize it. The platform
contract fixes only what the caller sees — the backend resolves to one and that result is
returned, or it refuses because it cannot disambiguate, which is
`.../unsupported-identifier` (sync) / `failed_before_start` (async). A result carries no marker
that the target was ambiguous. Per-deployment behaviour is the operator's to document, as CAMARA
requires of the API provider.

**Session-holder rule.** Activate always creates a **new** `service_instance` as the session
holder (Persistence §A.1), using the caller-supplied `service_instance_id` as its primary key
@@ -679,9 +718,9 @@ consume `event.srm.*`. They never connect to `srm_db` directly.
   command's caller-supplied `service_instance_id` as its primary key (ADR-0033)
   (`state=creating`, `ref` set to that id), selects zone/domain when not pinned;
   commits; publishes `event.srm.operation.status` (`accepted`).
4. SRM resolves the `control_path_binding`, loads the network adapter, calls
   `activate_capability(request)` with `capability_kind` set to the network capability type
   (e.g. `qod_session`, `traffic_influence`) (ADR-0027).
4. SRM resolves the `control_path_binding` (§B, "Resolving the control path from the target"),
   loads the network adapter, calls `activate_capability(request)` with `capability_kind` set to
   the network capability type (e.g. `qod_session`, `traffic_influence`) (ADR-0027).
5. On success: creates `capability_instance` (`external_id` = backend id; `external_ref` =
   backend realization ref; `kind` selected for the capability type; `parameters_snapshot` /
   `result_summary` canonical); sets instance `active`. The holder's identity is already final
@@ -694,8 +733,8 @@ consume `event.srm.*`. They never connect to `srm_db` directly.
1. Caller `POST /internal/network-queries/location` (or `/device-status`) over Internal HTTP.
   No DataBus and no `operation_id`: the HTTP request is the whole transaction.
2. SRM validates the body against §E.2.
3. SRM resolves the `control_path_binding` for the queried capability and loads the network
   adapter.
3. SRM resolves the `control_path_binding` for the queried capability (§B, "Resolving the
   control path from the target") and loads the network adapter.
4. SRM calls `query_capability(request)` with `capability_kind` set to the query's capability
   type (`location_context`, `device_status`) (ADR-0027).
5. On success SRM returns the canonical result directly as `200` (§E.2 gives the location