<!-- docs/ADR/2026-07-4.0-backend-contract.md -->

# ADR: Unified BLE 4.0 backend contract

**Status:** Accepted design baseline
**Date:** 2026-07-25
**Supersedes:** the deleted `BlePort`, host-port parity, and optional-native fallback decisions

## Decision

All first-party and third-party implementations conform to one versioned,
host-neutral `BleCentralBackend` contract. The contract has a provider/factory,
identity, adapter, scanner, connection, GATT, feature-registry, bounded-event,
resource-counter, and deterministic-destroy component. It imports no browser,
mobile, desktop, Node, or operating-system type at the contract boundary.

The production shape is checked in
[`backend.ts`](../../src/backend-contract/backend.ts),
[`identity.ts`](../../src/backend-contract/identity.ts), and
[`gatt.ts`](../../src/backend-contract/gatt.ts). Semantics remains the
normative behavioral authority.

## Contract shape and version skew

`BackendProvider` reports loadability, lists adapters, requires a stable
host-scoped adapter selection, and creates one backend bound to that adapter.
Loadability, adapter presence, adapter power, and authorization are different
states. The low-level provider never silently substitutes a different adapter:
a named selection that is absent or stale fails `adapter.unavailable`.

Choosing a *default* adapter when the caller names none is an ergonomics
concern that belongs one layer up, not in the provider. The Node convenience
factories (`createBluezBleManager` and its siblings) select the first adapter,
ordered deterministically by id, so the common single-adapter machine needs no
configuration and a multi-adapter host still picks the same controller every
run; a caller targets a specific controller by passing `adapterId`. This
supersedes the earlier stance that ambiguous selection must fail: forcing every
caller to name an adapter is poor ergonomics for the common case. The
`adapter.selection-required` / `adapter.ambiguous` codes remain in the taxonomy
for a provider that genuinely cannot pick, but the first-party host factories do
not push that choice onto the caller.

`BleCentralBackend` owns these required components:

```text
identity + negotiated axes
adapter + scanner + connections + gatt + feature registry
`BoundedAsyncStream<BackendEvent>` descriptor + resource counters + idempotent destroy
```

Identity carries registered backend/platform IDs, implementation/runtime data,
attachment ID, runtime backend instance, backend generation, adapter identity
and generation, and every applicable negotiated axis. Contract, capability,
event, and trace axes are always independent. Native and IPC axes are present
only at their own boundary. Each range is negotiated before admission; no
overlap, malformed record, unknown required field/event, or unsupported
downgrade fails `protocol.incompatible` before radio work.

## Lifecycle, ownership, and operation invariants

- The backend/provider owns one physical adapter and its physical scan
  controller. A manager owns only logical leases and resources it creates.
- A scan has explicit filters, duplicate/merge/timestamp/delivery policy,
  deadline, abort signal, capacities, and overflow handling. It closes ingress
  before stop resolution and never emits after that resolution.
- A connection is one attachment-scoped peer/link generation with explicit
  leases. Different peers may progress concurrently; same-peer work is
  serialized. A non-final lease release cannot stop a physical link.
- GATT discovery creates a database generation. Paths include attachment,
  owner lease, connection, database, and duplicate occurrence identity. Every
  GATT path validates them before native dispatch. Services Changed, disconnect,
  reset, restart, or destroy invalidate them before any backend call. Backend
  path resolution never uses a global numeric handle registry.
- The core supplies ownership-checked opaque backend correlations; backends
  return terminal acknowledgement and preserve cleanup ownership until native
  work is acknowledged or quarantined. A late callback is validated against
  attachment, generation, and correlation, then released without public state
  change.
- Every inbound stream is bounded and every cleanup is idempotent. Counters for
  scans, leases, links, database snapshots, CCCD enablements, subscriptions,
  operations, retained bytes, restoration records, and IPC orphans never go
  negative; an underflow is a protocol failure and reset condition.
- Backend events use a `BoundedAsyncStream<BackendEvent>` descriptor that
  declares capacity, byte quota, overflow policy, counters, and terminal state.
  An unbounded listener registration is prohibited.

Error mapping occurs at the backend edge. It returns stable dotted codes and
safe platform detail; it never maps a failure to empty data, a cancellation to
busy, or unsupported functionality to nominal success.

## Runtime feature truth

The backend's feature registry is mandatory and is governed by the
[capability ADR](2026-07-4.0-capability-registry.md). A backend may expose an
unavailable or limited feature only with structured reason, limits, evidence,
and applicable TCK profile. It cannot claim a capability from implementation
name, host family, mock, cached result, or unrelated platform receipt.

## Security and boundary rules

Backend implementations validate untrusted native, browser, D-Bus, add-on, and
IPC records before they enter core state. They copy or explicitly transfer bytes
at every boundary, enforce declared byte/queue/subscription limits, and redact
sensitive platform detail. A backend must fail closed on native loading,
permission, protocol, or capability failure. Main-process-only ownership and
mobile native protocol details are governed by the boundary ADR.

## Export and native-artifact impact

Only the explicit `backend-sdk` subpath may expose backend-authoring contracts;
the public root does not load a backend as an import side effect. Native
adapters and desktop add-ons are selected by explicit host factories, package
controlled, ABI/version checked, and removed from admission on mismatch. Their
artifact presence never changes lifecycle, stream, error, generation, or
runtime-capability truth.

## Rejected alternatives

- Per-host manager contracts, `BlePort`, or casts to optional backend methods:
  rejected because policy, error, and ownership semantics would diverge.
- A static host matrix, mock fallback, injected cached radio result, or Noble
  fallback: rejected because none establishes current capability or live proof.
- One closed platform union: rejected because third-party and future backends
  need independently registered identity and version negotiation.

## Consequences and gates

`DeterministicTestBackend` is the first complete contract implementation, not a
support claim. The TCK covers provider/identity negotiation, adapter state,
scan arbitration, connections, duplicate GATT paths, bytes, cancellation races,
bounded streams, errors, lifecycle, and zero resource counters. Every
first-party backend and third-party profile runs the TCK selected by its
registered features; a skipped requirement is valid only when the feature is
absent or explicitly limited.

The old port contracts and fallbacks are deleted only after conforming backend
implementations, public scenarios, artifact checks, and consumer cutovers
pass. No compatibility adapter survives the cutover.

## Related decisions

- [Public API ADR](2026-07-4.0-public-api.md)
- [Capability registry ADR](2026-07-4.0-capability-registry.md)
- [Boundary ADR](2026-07-4.0-boundary.md)
- [Open-source governance ADR](2026-07-4.0-open-source-governance.md)
