Commit 5349970d authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

docs: interface contracts and architecture documentation after location api implementation

parent 3e817af7
Loading
Loading
Loading
Loading
+1 −1
Changes for decisions/0038-device-targeted-control-path-resolution.md: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -9,7 +9,7 @@ status: "accepted"

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

**Status:** accepted
**Status:** accepted — the boolean return type is superseded by [ADR-0040](/decisions/0040-target-serveability-is-a-tri-state.md); everything else stands
**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)

+110 −0
Changes for decisions/0040-target-serveability-is-a-tri-state.md: 110 added lines, 0 removed lines.
Original line number Diff line number Diff line
---
type: Decision Record
title: "ADR-0040: can_serve_target answers a tri-state, not a boolean"
description: "The device-targeted control-path probe returns can_serve | unknown_subscriber | unsupported_identifier, because a boolean cannot carry the distinction between 404 and 422 that the synchronous query contract requires. Supersedes the boolean return in ADR-0038."
tags: [adr]
timestamp: 2026-08-31T00:00:00Z
status: "accepted"
---

# ADR-0040: `can_serve_target` answers a tri-state, not a boolean

**Status:** accepted
**Date:** 2026-08-31
**Affects:** [srm/interface-contract.md](/srm/interface-contract.md),
[platform/architecture.md](/platform/architecture.md), SRM, TF-SDK; supersedes the return type
decided in [ADR-0038](/decisions/0038-device-targeted-control-path-resolution.md)

## Context

[ADR-0038](/decisions/0038-device-targeted-control-path-resolution.md) gave SRM a probe for
choosing among candidate control paths when a request identifies a subscriber rather than a place.
It specified a boolean answer: *"the first true wins"*, and *"an adapter that does not implement it
counts as able to serve"*.

The same record's step 4 then asks for an outcome a boolean cannot produce:

> **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.

[srm/interface-contract.md](/srm/interface-contract.md) §B states the same split as a table, and
§E.2 attaches different caller-visible outcomes to each arm. So the loop must know **why** every
candidate declined, and `false` does not say why.

The consequence is not cosmetic. §E.2 constrains `404` specifically:

> `404` is never a fallback: when the adapter cannot tell an unknown subscriber apart from one it
> merely failed to locate, the answer is `422` with `.../unable-to-locate`, so a `404` never
> becomes a way to test whether a number is on this network.

A boolean forces one of two wrong behaviours: collapse both reasons to `404`, which turns the API
into a subscriber-enumeration oracle, or collapse both to `422`, which makes
`IDENTIFIER_NOT_FOUND` unreachable on the CAMARA surface.

Options considered:

- **(a) Keep the boolean and infer the reason** from which identifier forms the request carried
  versus what the topology advertises. Rejected: identifier support is adapter-specific and not
  stored in `srm_db` (ADR-0004), so SRM would be guessing at knowledge it deliberately does not
  hold.
- **(b) Keep the boolean and add a second adapter method** reporting supported identifier forms.
  Two round trips per candidate, two methods to keep consistent, and the answer is still a static
  capability rather than a statement about this request.
- **(c, chosen) Widen the return type** so one call carries both the decision and its reason.

## Decision

`can_serve_target` returns `TargetServeability`, one of `can_serve`, `unknown_subscriber`, or
`unsupported_identifier`. The default for an adapter that does not implement the method remains
`can_serve`, so the method stays optional and additive exactly as ADR-0038 intended.

**Why three values, not two.** A boolean cannot carry *why* a candidate declined, and the
synchronous query contract needs to: `unknown_subscriber` means the backend positively reports no
such subscriber here; `unsupported_identifier` means it cannot act on any identifier form
supplied. `404` requires positive evidence from a backend that understood the identifier — never
reached by elimination — which is exactly what a boolean cannot express.

**`unknown_subscriber` is a claim, not a guess.** An adapter returns it only when the backend
positively reports no such subscriber. When it cannot distinguish that from a failure to locate,
it answers `can_serve` and lets the query fail with `TargetNotLocatable`, which §E.2 maps to
`.../unable-to-locate`.

The current resolution algorithm — how per-candidate answers combine into one outcome — is
[srm/interface-contract.md](/srm/interface-contract.md) §B step 4; this record states the decision
and the reasoning, not the mechanics, to keep the two from drifting apart.

Everything else in ADR-0038 stands: candidate enumeration, `is_default` then `priority` ordering,
no probe when there is exactly one candidate, the loop and the choice staying in SRM, and the
outcome recorded in `capability_instance.control_path_binding_id`.

## Consequences

What becomes easier:

- The 404/422 split required by §E.2 is expressible, and the subscriber-enumeration hazard is
  closed structurally rather than by convention.
- One probe call carries both the decision and its reason, so a multi-NEF deployment costs the
  same as it would have with a boolean.

What becomes harder:

- Vendor adapters must decide which of two refusals applies, rather than returning `false`. The
  safe answer (`can_serve`, then fail at query time) is available and is the correct one whenever
  the backend cannot distinguish the cases.
- The enum is a wire-level contract: its three values are what TF-SDK adapters implement against,
  so adding a fourth is a contract change, not an implementation detail.

Deferred or excluded:

- **Async capabilities do not use the distinction.** Activate, update, and deactivate fail
  `failed_before_start` whichever reason applies; the tri-state exists for the synchronous query
  path. It is returned uniformly regardless, so no caller has to branch on capability family.
- **Per-identifier granularity.** The answer is about the request as a whole, not per identifier.
  A request supplying a phone number and an IPv4 address gets one answer, not two.

Revisit trigger:

- A capability family whose refusals do not fit these three arms — most likely a verification-style
  API (Number Verification), which §E.2 already excludes from the synchronous query contract until
  separately specified.
+110 −0
Changes for decisions/0041-tfsdk-owns-adapter-construction.md: 110 added lines, 0 removed lines.
Original line number Diff line number Diff line
---
type: Decision Record
title: "ADR-0041: TF-SDK builds adapters behind a factory; SRM resolves control paths but never enumerates adapter kinds"
description: "SRM's ControlPathRegistry maps control_path_ref to an adapter instance, and obtains those instances from a TF-SDK-provided factory keyed by adapter kind, so adding a backend is a TF-SDK release plus configuration rather than a change to SRM."
tags: [adr, tf-sdk, srm]
timestamp: 2026-08-31T00:00:00Z
status: "accepted"
---

# ADR-0041: TF-SDK builds adapters behind a factory; SRM resolves control paths but never enumerates adapter kinds

**Status:** accepted
**Date:** 2026-08-31
**Affects:** [srm/persistence-model.md](/srm/persistence-model.md), [srm/architecture.md](/srm/architecture.md), [platform/architecture.md](/platform/architecture.md), SRM, TF-SDK; composes with [ADR-0004](/decisions/0004-capability-surface-topology.md), [ADR-0024](/decisions/0024-tfsdk-in-process-library.md), [ADR-0027](/decisions/0027-tfsdk-generic-capability-adapter-interface.md)

## Context

[ADR-0004](/decisions/0004-capability-surface-topology.md) keeps adapter endpoints and credentials
out of `srm_db`: the database stores only a `control_path_ref`, and
[srm/persistence-model.md](/srm/persistence-model.md) §C states the runtime chain as
`control_path_ref → ControlPathRegistry → adapter_class → infrastructure config → backend API`.

It does not say **who owns the `adapter_class` step**. Implementing the registry made the gap
concrete: the natural first cut is a conditional inside SRM —

```python
if entry.adapter == "oai-nef":
    return OaiNefAdapter(...)
raise UnknownAdapterKind(entry.adapter)
```

which makes every new backend an SRM code change, in the one component that
[ADR-0027](/decisions/0027-tfsdk-generic-capability-adapter-interface.md) explicitly designed to
need no change when a domain is added:

> adding a new domain … requires no new base class and **no change to SRM's adapter-dispatch
> code** — only a new `capability_kind` value and a new adapter implementation behind the existing
> interface.

An SRM-side conditional also puts vendor construction knowledge — which config keys an adapter
needs, how to resolve a `secret://` reference for it — in the component that has neither.

Options considered:

- **(a) SRM enumerates adapter kinds.** Simplest to write; contradicts ADR-0027 and makes ADR-0004's
  "adapters can be re-pointed by changing the registry, not the data" false at the code level,
  since re-pointing to a *new kind* would require an SRM release.
- **(b) Entry-point / plugin discovery.** Adapters register themselves at import time. Avoids the
  conditional, but makes the available set implicit and load-order dependent, and TF-SDK is a
  single in-process library (ADR-0024) rather than an open plugin ecosystem.
- **(c, chosen) A factory interface owned by TF-SDK.** SRM asks for an adapter by kind and config;
  TF-SDK decides what to build.

## Decision

The runtime chain splits along the component boundary:

| Concern | Owner |
|---|---|
| Which adapter instance serves a `control_path_ref` | **SRM** — `ControlPathRegistry` |
| How to build an adapter of a given kind from its config | **TF-SDK** — adapter factory |
| Ordering, probing, and choosing among candidates | **SRM** (ADR-0038, ADR-0040) |
| Talking to the backend | **TF-SDK** — the adapter (ADR-0027) |

TF-SDK exposes a factory taking an adapter **kind** and its **configuration**, and returning an
instance of the generic execution interface. SRM holds no list of adapter kinds and no vendor
construction logic; an unknown kind is TF-SDK's error to raise.

Configuration remains outside the database (ADR-0004): each control path is configured with its
`control_path_ref`, its adapter kind, and a config map whose secret values are `secret://`
references. SRM passes that map through without interpreting it.

**Consequence for deployment.** Adding a backend — a second NEF, a direct 5G core integration, a
GMLC — is a TF-SDK release plus a configuration entry. No SRM code changes, which is what ADR-0004
and ADR-0027 both assumed but neither made structural.

**No adapter, no service.** When control paths are configured and no TF-SDK factory is installed,
SRM fails at startup rather than resolving to nothing. A missing adapter is a deployment fault, not
a per-request refusal.

## Consequences

What becomes easier:

- SRM's adapter-dispatch code is a dictionary lookup that never grows, satisfying ADR-0027's
  promise literally rather than aspirationally.
- Vendor-specific construction — required config keys, secret resolution, connection pooling —
  stays inside TF-SDK with the vendor knowledge it depends on.
- Test doubles substitute at the registry, so SRM's query and lifecycle paths are testable with no
  TF-SDK present and no fake shipped in SRM.

What becomes harder:

- The adapter-kind string becomes a two-sided contract: configuration names a kind that TF-SDK must
  recognise, and a typo surfaces at startup rather than at import.
- TF-SDK must expose the factory alongside the adapters, so a vendor adapter is not usable by
  merely existing as a class.

Deferred or excluded:

- **`secret://` resolution.** Who dereferences a secret reference, and against what backing store,
  is not decided here. The config map carries references either way.
- **A mock adapter.** It belongs with the interface it implements, so TF-SDK ships one; SRM does
  not carry a fake. The MockAdapter validation flows in
  [srm/architecture.md](/srm/architecture.md) and the FM delivery spec are served by that.

Revisit trigger:

- A deployment needing adapters from outside TF-SDK — a customer-supplied or third-party adapter —
  at which point plugin discovery (option b) becomes worth its cost.
+2 −0
Changes for decisions/index.md: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -42,3 +42,5 @@ the reasoning lives here. Never renumber; supersede instead (see the record-deci
* [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
* [ADR-0039](0039-source-spec-stated-by-publisher.md) - source_spec is stated by the publishing component (OEG from CAMARA, FM from OPG.04) on network capability commands; SRM never derives it from the envelope source and never synthesizes a family, API, or version
* [ADR-0040](0040-target-serveability-is-a-tri-state.md) - can_serve_target answers can_serve | unknown_subscriber | unsupported_identifier; a boolean cannot carry the 404/422 split §E.2 requires, and 404 needs positive evidence (supersedes ADR-0038's boolean)
* [ADR-0041](0041-tfsdk-owns-adapter-construction.md) - TF-SDK builds adapters behind a factory keyed by adapter kind; SRM resolves control_path_ref to an instance but never enumerates adapter kinds
+2 −0
Changes for oeg/architecture.md: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -264,6 +264,8 @@ These questions are not resolved by the current architecture text. Keep them vis

- **F1. Catalog cache:** In-process TTL vs another transient cache for `event.srm.catalog.updated` invalidation? *Recommendation:* start with in-process + short TTL; move to a shared transient cache only when load requires.
- **F2. Consent / User Identity Token:** Where does the User Identity Token enter — header vs body? *Recommendation:* follow the CAMARA security profile plus OPG.02 privacy guidance; store opaque reference in `operations.metadata` JSONB, delegate validation to SRM.
- **F3. Location Retrieval's identifier-echo fallback is an unresolved guess:** when SRM's `srm_resolved_identifier` is absent, OEG echoes the first identifier the request supplied in a fixed order (`ipv4`, `phone_number`, `ipv6`, `network_access_id`) — the mapper's own docstring calls this a guess that may not match the identifier the network actually used ([oeg/interface-contract.md](/oeg/interface-contract.md) §N.4). No decision exists for why this order, or whether guessing is acceptable at all for a CAMARA field the consumer is expected to trust.
- **F4. `app_provider_id`/`tenant_id` are hardcoded `"placeholder"`:** no JWT-claim extraction exists yet, so every request — including Location Retrieval — is tagged with a fake provider id. For Location Retrieval specifically this also means `device` cannot be omitted the way the vendored CAMARA spec allows when the subject is derivable from the token, so the published API contract is stricter than intended until this is resolved ([oeg/interface-contract.md](/oeg/interface-contract.md) §N.4).

## G. Functional Requirements

Loading