// src/backend-contract/backend.ts import type { AdvertisementObservation, OwnerScanOptions } from './advertisement' import type { FeatureRegistry } from './capabilities' import { attachmentRecordsEqual, type AdapterStateSnapshot, type AdapterStateWatch, type AttachmentRecord, type BackendIdentity, type BackendRuntimeMetadata } from './identity' import type { CharacteristicPath, ConnectionPath, DatabasePath, DescriptorPath, GattDatabase } from './gatt' import type { NotificationValue } from './gatt' import type { BackendOperationDispatch, OperationOptions, OperationTerminalRecord, PublicOperationOptions, ReadRequest, ReadResult, SubscribeRequest, WriteRequest, WriteResult } from './operations' import type { ConnectionMaximumWriteLengthMeasurement, ConnectionMaximumWriteLengthRequest, ConnectionPhyObservation, ConnectionPhyRequest, ConnectionPriorityRequest, ConnectionWriteReadinessWatch, EffectiveMtuMeasurement, EffectiveMtuRequest, MtuNegotiation, ReadRssiRequest, ReadPhyRequest, RequestPhyRequest, RequestPriorityRequest, RequestMtuRequest, RssiMeasurement } from './connection-controls' import { contractError } from './errors' import type { CleanupRecord } from './errors' import type { ApplicableVersionAxes, AttachmentId, BackendCompatibilityOffer, ClientId, ConnectionId, GenerationId, LeaseId, ManagerId, PeerId, ResourceCount, ScanShareToken, ScanSessionId, SubscriptionId, SerializableRecord } from './primitives' import { applicableVersionAxesEqual, assertCoreVersionsAccepted, snapshotApplicableVersionAxes } from './primitives' import { serializableRecordsEqual, snapshotSerializableRecord } from './serializable' import type { BoundedAsyncStream } from './streams' import type { ManagerRestorationCapability } from './restoration' import type { PeerReference } from './peer-reference' import type { SecurityBackend } from './security' import type { DiagnosticTraceDocument } from '../diagnostics/trace-format' import type { NormalizedScanQuery } from './scan-query' import type { ScanPlan } from './scan-planning' export type OwnerMode = 'owning' | 'borrowing' export type ManagerState = 'new' | 'ready' | 'destroying' | 'destroyed' | 'failed' export type ConnectionState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'lost' export interface ResourceCounters { readonly activeScanControllers: ResourceCount readonly scanConsumers: ResourceCount readonly chooserSessions: ResourceCount readonly connectionLeases: ResourceCount readonly physicalLinks: ResourceCount readonly databaseSnapshots: ResourceCount readonly physicalCccdEnablements: ResourceCount readonly subscriptionConsumers: ResourceCount readonly queuedOperations: ResourceCount readonly dispatchedOperations: ResourceCount readonly retainedByteBuffers: ResourceCount readonly restorationRecords: ResourceCount readonly orphanedIpcOwners: ResourceCount } export interface AdapterBackend { currentState(): Promise> watchState(): Promise> } export type PeerSource = | 'scan-observed' | 'app-reference' | 'system-connected' | 'system-bonded' | 'origin-authorized' | 'restored' | 'backend-cache' export interface BlePeerState { readonly reachability: 'reachable' | 'unreachable' | 'unknown' readonly connection: 'connected' | 'disconnected' | 'unknown' readonly bond: 'bonded' | 'not-bonded' | 'unknown' | 'unsupported' readonly lastSeenAtMonotonicMs: number | null } export interface BackendPeerQuery extends PublicOperationOptions { readonly sources?: readonly PeerSource[] readonly services?: readonly string[] readonly references?: readonly PeerReference[] readonly includeUnavailable?: boolean } export interface BackendPeerRecord { readonly reference: PeerReference readonly peerId: PeerId readonly name: string | null readonly rssi: number | null readonly source: PeerSource readonly state: BlePeerState readonly clockScope?: string } export interface PeerDirectoryBackend { resolve(reference: PeerReference, options: BackendPeerQuery): Promise | null> known(options: BackendPeerQuery): Promise[]> connected(options: BackendPeerQuery): Promise[]> bonded(options: BackendPeerQuery): Promise[]> authorized(options: BackendPeerQuery): Promise[]> restored(options: BackendPeerQuery): Promise[]> } export interface ScanLease { readonly scanSessionId: ScanSessionId readonly leaseId: LeaseId readonly shareToken: ScanShareToken | null readonly observations: BoundedAsyncStream> stop(): Promise } export interface ScannerBackend { plan?(query: NormalizedScanQuery): ScanPlan start( options: OwnerScanOptions, clientId: ClientId ): Promise> join( sharedLeaseId: LeaseId, shareToken: ScanShareToken, clientId: ClientId ): Promise> } export interface ConnectionLease { readonly leaseId: LeaseId readonly connection: BackendConnection release(): Promise } export interface BackendConnection { readonly attachment: AttachmentRecord readonly attachmentId: AttachmentId readonly peerId: PeerId readonly connectionId: ConnectionId readonly connectionGeneration: GenerationId<'connection-generation', string> readonly state: ConnectionState disconnect(): Promise } export type ConnectionIntent = 'direct' | 'when-available' /** * A canonical out-of-band radio address accepted by the optional `peer:address-targeting` * capability. Only public/static addresses are expressible; resolvable private addresses * must re-enter through a durable `PeerReference`. */ export interface PeerAddressDescriptor { readonly address: string readonly addressType: 'public' | 'random' } export type BlePhy = 'le-1m' | 'le-2m' | 'le-coded' export interface ConnectionOptions extends PublicOperationOptions { readonly intent?: ConnectionIntent readonly transport?: 'le' | 'auto' readonly preferredPhy?: readonly BlePhy[] } export interface ConnectionBackend { connect( peerId: PeerId, clientId: ClientId, options: ConnectionOptions ): Promise> /** * Optional `peer:address-targeting` seam: mints a connectable peer handle for a canonical * radio address known out of band, without requiring a prior scan observation. Backends * that do not register the capability leave this undefined and callers fail closed. */ peerFromAddress?(descriptor: PeerAddressDescriptor): PeerId readRssi?( connection: BackendConnection, request: ReadRssiRequest ): BackendOperationDispatch> requestMtu?( connection: BackendConnection, request: RequestMtuRequest ): BackendOperationDispatch> effectiveMtu?( connection: BackendConnection, request: EffectiveMtuRequest ): BackendOperationDispatch> requestPriority?( connection: BackendConnection, request: RequestPriorityRequest ): BackendOperationDispatch> readPhy?( connection: BackendConnection, request: ReadPhyRequest ): BackendOperationDispatch> requestPhy?( connection: BackendConnection, request: RequestPhyRequest ): BackendOperationDispatch> writeWithoutResponseReadiness?( connection: BackendConnection, options?: PublicOperationOptions ): Promise> maximumWriteLength?( connection: BackendConnection, request: ConnectionMaximumWriteLengthRequest ): BackendOperationDispatch> } export interface GattBackend { discover( connection: BackendConnection, options: PublicOperationOptions ): Promise> read< Connection extends string, Database extends string, Service extends string, Characteristic extends string, Operation extends string >( path: CharacteristicPath, request: ReadRequest ): BackendOperationDispatch> write< Connection extends string, Database extends string, Service extends string, Characteristic extends string, Operation extends string >( path: CharacteristicPath, request: WriteRequest ): BackendOperationDispatch> readDescriptor< Connection extends string, Database extends string, Service extends string, Characteristic extends string, Descriptor extends string, Operation extends string >( path: DescriptorPath, request: ReadRequest ): BackendOperationDispatch> writeDescriptor< Connection extends string, Database extends string, Service extends string, Characteristic extends string, Descriptor extends string, Operation extends string >( path: DescriptorPath, request: WriteRequest ): BackendOperationDispatch> subscribe< Connection extends string, Database extends string, Service extends string, Characteristic extends string, Operation extends string >( path: CharacteristicPath, request: SubscribeRequest ): BackendOperationDispatch> unsubscribe< Connection extends string, Database extends string, Service extends string, Characteristic extends string, Operation extends string >( subscription: BackendSubscription, operation: OperationOptions ): BackendOperationDispatch> } export interface BackendSubscription< Attachment extends string, Connection extends string, Database extends string, Service extends string, Characteristic extends string > { readonly subscriptionId: SubscriptionId readonly path: CharacteristicPath readonly terminal: OperationTerminalRecord readonly notifications: BoundedAsyncStream } export interface BackendEventBase { readonly attachment: AttachmentRecord readonly attachmentId: AttachmentId readonly ingressOrdinal: number } export interface BackendDatabaseChangedEvent extends BackendEventBase { readonly kind: 'database-changed' readonly database: DatabasePath } /** A loss is scoped to the exact attachment, connection generation, and owner lease. */ export interface BackendConnectionLostEvent extends BackendEventBase { readonly kind: 'connection-lost' readonly connection: ConnectionPath } export interface BackendGenericEvent extends BackendEventBase { readonly kind: 'adapter-state' | 'backend-restarted' | 'diagnostic' } /** Normalized scan delivery, separate from a stream's bounded overflow accounting record. */ export interface BackendScanResultEvent extends BackendEventBase { readonly kind: 'scan-result' readonly observation: AdvertisementObservation } export interface BackendScanOverflowEvent extends BackendEventBase { readonly kind: 'scan-overflow' readonly scanSessionId: ScanSessionId readonly policy: import('./streams').OverflowPolicy readonly droppedItems: ResourceCount readonly droppedBytes: ResourceCount readonly replacedItems: ResourceCount } export type BackendDisconnectReason = 'local' | 'peer' | 'adapter' | 'backend-restart' const backendDisconnectReasons: readonly BackendDisconnectReason[] = Object.freeze([ 'local', 'peer', 'adapter', 'backend-restart' ]) const connectionStates: readonly ConnectionState[] = Object.freeze([ 'connecting', 'connected', 'disconnecting', 'disconnected', 'lost' ]) export interface BackendConnectionStateChangedEvent extends BackendEventBase { readonly kind: 'connection-state-changed' readonly connection: ConnectionPath readonly previous: ConnectionState readonly current: ConnectionState readonly reason: BackendDisconnectReason | null } export interface BackendDisconnectedEvent extends BackendEventBase { readonly kind: 'disconnected' readonly connection: ConnectionPath readonly reason: BackendDisconnectReason } export interface BackendCharacteristicValueChangedEvent extends BackendEventBase { readonly kind: 'characteristic-value-changed' readonly path: CharacteristicPath readonly value: NotificationValue } export interface BackendNotificationOverflowEvent extends BackendEventBase { readonly kind: 'notification-overflow' readonly subscriptionId: SubscriptionId readonly policy: import('./streams').OverflowPolicy readonly droppedItems: ResourceCount readonly droppedBytes: ResourceCount readonly replacedItems: ResourceCount } export interface BackendMtuChangedEvent extends BackendEventBase { readonly kind: 'mtu-changed' readonly connection: ConnectionPath readonly mtu: number readonly maximumWriteLength: number } export interface BackendBondSecurityEvent extends BackendEventBase { readonly kind: 'bond-security-changed' readonly peerId: PeerId readonly bond: 'none' | 'bonding' | 'bonded' | 'failed' | 'unavailable' readonly security: 'unencrypted' | 'encrypted' | 'authenticated' | 'unavailable' } export interface BackendPhyChangedEvent extends BackendEventBase { readonly kind: 'phy-changed' readonly connection: ConnectionPath readonly txPhy: '1m' | '2m' | 'coded' | 'unavailable' readonly rxPhy: '1m' | '2m' | 'coded' | 'unavailable' } export interface BackendPermissionStateChangedEvent extends BackendEventBase { readonly kind: 'permission-state-changed' readonly state: AdapterStateSnapshot } export interface BackendRestorationEvent extends BackendEventBase { readonly kind: 'restoration-received' readonly record: SerializableRecord } export interface BackendRestartingEvent extends BackendEventBase { readonly kind: 'backend-restarting' readonly reason: string } export interface BackendDiagnosticEvent extends BackendEventBase { readonly kind: 'diagnostic-warning' readonly code: string readonly message: string readonly detail: SerializableRecord } /** Open extension lane: namespaced event kinds retain typed serializable payloads without central-union edits. */ export interface BackendExtensionEvent extends BackendEventBase { readonly kind: `extension:${string}` readonly payload: SerializableRecord } export type BackendEvent = | BackendDatabaseChangedEvent | BackendConnectionLostEvent | BackendGenericEvent | BackendScanResultEvent | BackendScanOverflowEvent | BackendConnectionStateChangedEvent | BackendDisconnectedEvent | BackendCharacteristicValueChangedEvent | BackendNotificationOverflowEvent | BackendMtuChangedEvent | BackendBondSecurityEvent | BackendPhyChangedEvent | BackendPermissionStateChangedEvent | BackendRestorationEvent | BackendRestartingEvent | BackendDiagnosticEvent | BackendExtensionEvent export function assertBackendEvent(event: BackendEvent): void { if (!Number.isSafeInteger(event.ingressOrdinal) || event.ingressOrdinal < 0) { throw contractError('protocol.malformed', 'core', 'backend.assert-event.ingress-ordinal') } if (event.attachment.attachmentId !== event.attachmentId) { throw contractError('protocol.malformed', 'core', 'backend.assert-event.attachment-id') } if ( event.kind === 'database-changed' && (event.database.attachmentId !== event.attachmentId || !attachmentRecordsEqual(event.database.attachment, event.attachment)) ) { throw contractError('protocol.violation', 'core', 'backend.assert-event.database-attachment') } if ( (event.kind === 'connection-lost' || event.kind === 'connection-state-changed' || event.kind === 'disconnected') && (event.connection.attachmentId !== event.attachmentId || !attachmentRecordsEqual(event.connection.attachment, event.attachment)) ) { throw contractError('protocol.violation', 'core', 'backend.assert-event.connection-attachment') } if ( event.kind === 'connection-state-changed' && (!connectionStates.includes(event.previous) || !connectionStates.includes(event.current) || (event.reason !== null && !backendDisconnectReasons.includes(event.reason)) || (event.current === 'disconnected' || event.current === 'lost') === (event.reason === null)) ) { throw contractError('protocol.malformed', 'core', 'backend.assert-event.connection-transition-reason') } if (event.kind === 'disconnected' && !backendDisconnectReasons.includes(event.reason)) { throw contractError('protocol.malformed', 'core', 'backend.assert-event.disconnect-reason') } } export interface BleCentralBackend> { readonly identity: Identity readonly adapter: AdapterBackend readonly scanner: ScannerBackend readonly connections: ConnectionBackend readonly gatt: GattBackend readonly security?: SecurityBackend readonly peers?: PeerDirectoryBackend readonly features: FeatureRegistry readonly traceDocument?: () => DiagnosticTraceDocument attach(request: BackendAttachmentRequest): Promise> events(): BoundedAsyncStream> resourceCounters(): ResourceCounters destroy(): Promise } interface AttachedBackendAuthentication> { readonly backend: BleCentralBackend readonly attachment: AttachmentRecord readonly identity: BackendIdentityAuthenticationClaim } interface BackendIdentityAuthenticationClaim { readonly registeredBackendId: string readonly registeredPlatformId: string readonly attachment: AttachmentRecord readonly versions: ApplicableVersionAxes readonly runtime: BackendRuntimeMetadata } const authenticatedAttachedBackends = new WeakMap< AttachedBackend>, AttachedBackendAuthentication> >() /** Opaque result of the one manager-neutral backend attachment handshake. */ export abstract class AttachedBackend> { private readonly authenticatedReceiptMarker = true protected constructor() { if (!this.authenticatedReceiptMarker) { throw contractError('ownership.denied', 'core', 'backend.attach-receipt-construction') } } abstract readonly backend: BleCentralBackend abstract readonly attachment: BackendAttachment protected hasAuthenticatedReceiptMarker(): boolean { return this.authenticatedReceiptMarker } } class IssuedAttachedBackend< Attachment extends string, Identity extends BackendIdentity > extends AttachedBackend { constructor( readonly backend: BleCentralBackend, readonly attachment: BackendAttachment ) { super() if (!this.hasAuthenticatedReceiptMarker()) { throw contractError('ownership.denied', 'core', 'backend.attach-receipt-issuance') } authenticatedAttachedBackends.set(this, { backend, attachment: snapshotAttachmentRecord(attachment.attachment), identity: snapshotBackendIdentityClaim(attachment.identity) }) Object.freeze(this) } } /** Negotiates one backend attachment before logical manager admission. */ export async function attachBackend>( backend: BleCentralBackend, coreCompatibility: BackendCompatibilityOffer ): Promise> { const attachment = await backend.attach({ coreCompatibility }) assertAttachmentMatchesBackend(backend, attachment) assertCoreVersionsAccepted(attachment.identity.versions, coreCompatibility) return new IssuedAttachedBackend(backend, attachment) } /** Rejects mismatched backend/identity tuples before any manager can use the binding. */ export function assertAttachedBackend>( attachedBackend: AttachedBackend ): void { const authentication = authenticatedAttachedBackends.get(attachedBackend) if (authentication === undefined) { throw contractError('ownership.denied', 'core', 'backend.assert-attached-backend.receipt') } if ( authentication.backend !== attachedBackend.backend || !completeAttachmentRecordsEqual(authentication.attachment, attachedBackend.attachment.attachment) || !backendIdentityClaimsEqual(authentication.identity, attachedBackend.attachment.identity) || !backendIdentityClaimsEqual(authentication.identity, attachedBackend.backend.identity) ) { throw contractError('protocol.violation', 'core', 'backend.assert-attached-backend.authentication') } assertAttachmentMatchesBackend(attachedBackend.backend, attachedBackend.attachment) } function assertAttachmentMatchesBackend>( backend: BleCentralBackend, attachment: BackendAttachment ): void { if ( !completeAttachmentRecordsEqual(attachment.attachment, attachment.identity.attachment) || !completeAttachmentRecordsEqual(attachment.attachment, backend.identity.attachment) ) { throw contractError('protocol.violation', 'core', 'backend.assert-attached-backend') } } function snapshotBackendIdentityClaim( identity: BackendIdentity ): BackendIdentityAuthenticationClaim { return Object.freeze({ registeredBackendId: identity.registeredBackendId, registeredPlatformId: identity.registeredPlatformId, attachment: snapshotAttachmentRecord(identity.attachment), versions: snapshotApplicableVersionAxes(identity.versions), runtime: Object.freeze({ hostKind: identity.runtime.hostKind, implementationVersion: identity.runtime.implementationVersion, diagnostics: snapshotSerializableRecord(identity.runtime.diagnostics).value }) }) } function backendIdentityClaimsEqual( expected: BackendIdentityAuthenticationClaim, actual: BackendIdentity ): boolean { return ( expected.registeredBackendId === actual.registeredBackendId && expected.registeredPlatformId === actual.registeredPlatformId && completeAttachmentRecordsEqual(expected.attachment, actual.attachment) && applicableVersionAxesEqual(expected.versions, actual.versions) && expected.runtime.hostKind === actual.runtime.hostKind && expected.runtime.implementationVersion === actual.runtime.implementationVersion && serializableRecordsEqual(expected.runtime.diagnostics, snapshotSerializableRecord(actual.runtime.diagnostics).value) ) } function completeAttachmentRecordsEqual( left: AttachmentRecord, right: AttachmentRecord ): boolean { return ( attachmentRecordsEqual(left, right) && left.adapter.displayName === right.adapter.displayName && left.adapter.state.availability === right.adapter.state.availability && left.adapter.state.authorization === right.adapter.state.authorization && left.adapter.state.power === right.adapter.state.power && left.adapter.state.backendGeneration === right.adapter.state.backendGeneration && left.adapter.state.updatedAt === right.adapter.state.updatedAt && left.adapter.state.safeReason === right.adapter.state.safeReason && stringArraysEqual(left.adapter.limitations, right.adapter.limitations) ) } function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { if (left.length !== right.length) { return false } for (let index = 0; index < left.length; index += 1) { if (left[index] !== right[index]) { return false } } return true } function snapshotAttachmentRecord( attachment: AttachmentRecord ): AttachmentRecord { return Object.freeze({ attachmentId: attachment.attachmentId, backendInstanceId: attachment.backendInstanceId, backendGeneration: attachment.backendGeneration, adapter: Object.freeze({ adapterId: attachment.adapter.adapterId, displayName: attachment.adapter.displayName, state: Object.freeze({ availability: attachment.adapter.state.availability, authorization: attachment.adapter.state.authorization, power: attachment.adapter.state.power, backendGeneration: attachment.adapter.state.backendGeneration, updatedAt: attachment.adapter.state.updatedAt, safeReason: attachment.adapter.state.safeReason }), adapterGeneration: attachment.adapter.adapterGeneration, limitations: Object.freeze([...attachment.adapter.limitations]) }) }) } export interface ManagerConstructionBase> { readonly attachedBackend: AttachedBackend readonly clientId: ClientId readonly managerId: ManagerId /** Present only when this provider owns a bound native restoration authority. */ readonly restoration?: ManagerRestorationCapability } export interface BackendAttachmentRequest { readonly coreCompatibility: BackendCompatibilityOffer } export interface BackendAttachment> { readonly attachment: AttachmentRecord readonly identity: Identity } export interface OwningManagerConstruction> extends ManagerConstructionBase { readonly ownerMode: 'owning' } export interface BorrowingManagerConstruction> extends ManagerConstructionBase { readonly ownerMode: 'borrowing' } export type ManagerConstruction> = | OwningManagerConstruction | BorrowingManagerConstruction