PulseKit uses one long-lived msgtrans connection for native telemetry and application-control
requests. biz_type belongs to the Pulse application protocol and is written into byte 3 of the
fixed 16-byte msgtrans header. It is not an event kind and must not be inferred from JSON content.
This file is the canonical Pulse application wire specification. “Must”, “must not”, “should” and “may” are normative. Client and server models intentionally remain independent; compatibility is proved against this document and wire fixtures rather than by sharing Kotlin classes.
The msgtrans header carries four independent concepts. Implementations must not overload one field with the meaning of another:
| Concern | Wire location | Owner |
|---|---|---|
| Request, response or lossy notification | packet_type |
msgtrans |
| Pulse operation and processing contract | biz_type |
Pulse |
| Correlation of a response to a request | message_id |
msgtrans |
| None, zstd or zlib | compression |
msgtrans |
biz_type is an unsigned byte (0..255). Pulse value 0 is invalid even though msgtrans accepts
it as its application-neutral default. Pulse payloads are UTF-8 JSON before optional compression.
The receiver first validates and decompresses the msgtrans packet, then dispatches the resulting
plaintext payload by biz_type.
biz_type |
Name | Packet flow | Payload | Success data |
|---|---|---|---|---|
0 |
Reserved | — | — | — |
1 |
CLIENT_CONNECT |
Client Request → Server Response | App, package, install/device identifier, SDK/version and supported capabilities | Connection ID, server time, configuration revision and heartbeat interval |
2 |
CLIENT_HEARTBEAT |
Client Request → Server Response | Connection ID, client time and PulseKit version | Acceptance, server time, latest configuration revision and next heartbeat interval |
3 |
CLIENT_CONFIG_PULL |
Client Request → Server Response | Current configuration revision and SDK capabilities | Sampling, audit, collection and endpoint policy |
4 |
CLIENT_CRASH_BATCH_UPLOAD |
Client Request → Server Response | One or more high-priority crash reports plus attachment metadata | true after durable acceptance into the crash queue |
5 |
CLIENT_EVENT_BATCH_UPLOAD |
Client Request → Server Response | One identity envelope and an ordered event array | true after the batch is durably accepted into the server queue |
6 |
CLIENT_APP_UPDATE_CHECK |
Client Request → Server Response | Platform, App ID, package name and monotonic build number | Update decision object; failures fail open to “no update” |
7 |
BIDIRECTIONAL_CONTROL_RPC |
Bidirectional Request → Response | Stable route string plus route-specific arguments | Route-specific result |
This table is the canonical ABI beginning with Pulse protocol version 2. The unreleased beta
mapping used different values and is intentionally incompatible. These version-2 values are never
renumbered or reused. A response echoes its request's message ID and biz type, so it does not need
a separate biz type. New values must be added to
pulse.core.PulseBizType and mirrored by the server's independent wire decoder.
There are seven allocated business types, plus reserved value 0. CLIENT_CONNECT,
CLIENT_HEARTBEAT, CLIENT_EVENT_BATCH_UPLOAD and CLIENT_APP_UPDATE_CHECK are implemented today. The other assigned
operations must return code=501 until their payload and handler are implemented.
Msgtrans is bidirectional. Direction is specified by this table rather than encoded into a numeric
range: BIDIRECTIONAL_CONTROL_RPC can originate from either peer, while the other allocated types originate from
the SDK. A long-lived connection may carry requests in both directions at the same time; response
completion is independent from request-handler execution, so a reverse request cannot deadlock the
read loop. Until a control route is implemented, the SDK answers it with a structured non-zero
response; it never silently returns an empty payload.
BIDIRECTIONAL_CONTROL_RPC follows the same idea as PrivChat's generic RPC packet: new operational commands add
a stable payload route instead of consuming a new biz_type. Initial route namespaces are
config.*, telemetry.* and audit.*. A route must be allowlisted by the SDK; the server cannot
use this channel to invoke arbitrary native methods or collect arbitrary data.
- Every operation in the table above uses msgtrans
Request, including telemetry upload and heartbeat. Receipt of bytes is not acceptance; only the matching application response confirms the operation. - The receiver must send exactly one
Responsefor every syntactically completeRequest, even when the operation fails. The response must reuse bothmessage_idandbiz_type. - A sender must register the request as in-flight before it becomes writable. Timeout, cancellation, response and connection close race to one terminal completion and remove it once.
OneWayis allowed only after a future specification explicitly marks a notification as lossy. None of the seven currently allocated Pulse types may be sent asOneWay.- A
Responsenever enters the business request handler. It completes the matching in-flight request. An unmatched or duplicate response is a protocol fault and must not be interpreted as a new business operation. - After
CLIENT_CONNECTsucceeds, receiving an unknownbiz_typemust producecode=404and an allocated but unavailable type must producecode=501. Before it succeeds, the first-frame rule takes precedence and a first frame whose type is notCLIENT_CONNECTcloses the connection.
A numeric range does not encode direction. Direction is metadata in the message registry because
responses already reuse the request type and BIDIRECTIONAL_CONTROL_RPC is intentionally bidirectional.
The intended native connection sequence is:
- Establish one msgtrans connection.
- Send
CLIENT_CONNECTas the first frame. It binds this live connection to the App ID, allowed package, installation/device identity, build and SDK capabilities. It is SDK registration, not application-user login;userIdremains optional and only comes from the host application's explicit identify call. - Reconcile configuration with
CLIENT_CONFIG_PULLwhen the connect response reports a newer revision. - Upload durable telemetry and crash batches. Requests may be in flight concurrently up to the configured per-connection bound.
- Send
CLIENT_HEARTBEATat the server-provided interval while idle so NAT state and the server's connection registry remain current. - Either peer may issue
BIDIRECTIONAL_CONTROL_RPC. Reading responses and running inbound handlers must progress independently, so a handler may make a reverse request without deadlocking the connection.
The SDK performs CLIENT_CONNECT immediately after transport connection. At the transport boundary,
the server requires the first inbound frame to be a msgtrans Request with biz_type=1. Any other
first frame is a protocol violation: the server closes that connection immediately, sends no
application response and does not dispatch the payload. A rejected or malformed CLIENT_CONNECT
receives its normal error response; no other operation is accepted until connect succeeds. The
accepted identity is immutable for the lifetime of a connection. A batch whose App, package,
device, installation, platform or declared PulseKit version differs from the connected identity is
rejected with code=403.
Carries protocol version, App ID, runtime package, installation/device ID, platform/device type, app version/build, SDK version and a capability list. Success returns a connection ID, server time, configuration revision and heartbeat interval. Package policy is evaluated here so an App ID may allow one or more explicitly configured packages without treating the package name as the App ID.
App ID is public identification. A future authenticated form may add keyId, timestamp, nonce,
algorithm and signature fields. The AppSecret is never transmitted. The signature covers a
canonical representation of the complete connect identity plus timestamp and nonce; the server
checks allowed clock skew, nonce replay, key status and package policy before accepting the client.
A fixed secret in a distributed mobile binary raises the cost of casual forgery but is extractable;
stronger deployments should combine this proof with Apple App Attest or a server-issued installation
credential.
Carries the server-issued connection ID, client time and PulseKit version. Success returns
accepted=true, server time, the latest configuration revision and the next heartbeat interval.
The SDK schedules the next request from that interval (currently 60 seconds). Each accepted
heartbeat refreshes a 150-second Redis presence TTL and updates durable PostgreSQL connection
history. A mismatched connection ID or SDK version is rejected with code=403. This application
heartbeat maintains routability and policy reconciliation; lower-level socket liveness remains a
msgtrans/neton-io responsibility.
Will carry the client's current revision and supported capabilities. Success will return a complete versioned policy snapshot covering sampling, enabled monitors, audit policy, batching, compression and endpoints. Applying a snapshot must be atomic.
Will carry durable high-priority crash records, build identity and attachment metadata. It is separate from general events because crash ingestion has different size limits, retention, symbolication, attachment handling and queue priority.
Carries one identity and an ordered, non-empty event array. batchId is stable for the lifetime of
the durable outbox row and must survive retries. event.id is the database idempotency key. The
identity belongs to the whole batch and must not be repeated in every event.
Event kind values such as Analytics, Network, Performance, Runtime and Breadcrumb do not
receive separate biz_type values. Sensitive values such as credentials, authorization headers and
URL query/fragment data must be removed before the event reaches the outbox.
Success is {"code":0,"msg":null,"data":true} and means the server has durably accepted the batch
into its queue. Decode success, socket receipt or an in-memory handoff is insufficient.
Carries platform, App ID/AppKey, runtime package name and monotonic build number. Version ordering
uses the platform build number (CFBundleVersion on iOS), not lexical comparison of display
versions. A successful data object returns action (none, optional or forced), latest
version/build, release notes and download URL. Any failure must fail open to “no update” in the SDK;
Pulse telemetry must not prevent the host app from launching.
Carries a stable route and route-specific arguments:
{"route":"telemetry.flush","args":{},"schemaVersion":1}The normal Pulse response envelope wraps the route-specific result. Routes use lower-case dotted
names and are append-only contracts. Initial namespaces are config.*, telemetry.* and
audit.*. Each SDK build must explicitly allowlist supported routes and validate their arguments.
There is no route for arbitrary Objective-C selector invocation, arbitrary file reads, arbitrary
network requests or permission requests. Unsupported routes return code=404 without closing the
connection.
- Values are unsigned bytes and are permanent after assignment. Append only; never renumber or reuse a retired value.
biz_typeidentifies the processing and delivery contract, not each telemetry event kind. Analytics, sessions, network observations, performance and permission/API audit events remain records insideCLIENT_EVENT_BATCH_UPLOAD.- Crash upload is separate because it needs higher delivery priority, larger limits, symbolication, attachments and a different server queue. Legacy crash events inside event batches remain valid.
- Transport keepalive is a msgtrans concern.
CLIENT_HEARTBEATis the application heartbeat used to keep the connected client routable and reconcile configuration revisions. - Responses reuse the request's
biz_typeandmessage_id; response-only type numbers are not allocated. - A new
biz_typeis justified only by a different direction, delivery guarantee, priority, size limit, authorization boundary or server processing pipeline. A new JSON shape by itself is not sufficient; use an event name orBIDIRECTIONAL_CONTROL_RPCroute when those semantics remain the same. - Reserve the next unused value only when its contract is reviewed. Update the canonical table, client constant, independent server constant and conformance tests in the same change.
- Removing a feature retires its number permanently. Receivers may continue decoding it for old clients, but the value must never acquire a different meaning.
- Experimental operations use
BIDIRECTIONAL_CONTROL_RPCroutes. They must not consume permanent type numbers until their distinct delivery or processing contract is proven.
Every Pulse Request receives exactly one msgtrans Response, using the same message_id and
biz_type. Its JSON payload always has this application envelope:
{"code": 0, "msg": null, "data": true}code = 0 means success. data contains the typed result and may be null. A non-zero code is an
application failure and msg explains it; it is still a valid protocol response and does not close
the connection.
| Code | Meaning | Default sender action |
|---|---|---|
0 |
Accepted | Complete the request; remove a durable outbox row only when its typed data also confirms acceptance |
400 |
Malformed payload or invalid direction/packet usage | Do not retry unchanged bytes |
401 |
Registration/authentication missing or invalid | Refresh credentials/register before retrying |
403 |
App, package or policy rejected | Do not retry until server policy changes |
404 |
Unknown biz_type or control route |
Do not retry unchanged request |
409 |
Session/config revision conflict | Register or pull configuration, then retry once |
429 |
Bounded queue or in-flight capacity exhausted | Retry with jittered exponential backoff |
500 |
Internal request failure | Retry durable operations with backoff |
501 |
Permanently allocated operation is not implemented by this peer | Do not retry until peer capability/version changes |
503 |
Durable processing queue unavailable | Retry durable operations with backoff |
Transport timeout, cancellation and disconnect have no application code because no valid response was received. Durable operations remain queued and retry. The client must cap attempts per time window and add jitter so reconnecting devices do not create a retry storm.
During the current beta migration, the update-check response also repeats its update fields at the
JSON root so already-built SDKs can continue to parse it. New code must read data; the root-level
copy is compatibility-only and can be removed after those builds are retired.
The application layer serializes the request body, selects the registered constant and calls the request API. It must inspect the Pulse response rather than treating any returned bytes as success:
val bytes = connection.request(
payload = encodedBatch,
bizType = PulseBizType.CLIENT_EVENT_BATCH_UPLOAD,
compression = Compression.Zstd,
)
val response = decodePulseResponse<Boolean>(bytes)
if (response.code == 0 && response.data == true) outbox.ack(batchId)Business code must not pass numeric literals such as bizType = 1; it uses the named registry
constant. The transport must not inspect Pulse JSON or choose a biz_type from payload contents.
The receiver dispatches only after msgtrans has validated the complete frame and decompressed its payload. Handler failures are converted to the common response envelope. They must not escape and silently abandon the request:
connection.onRequest { payload, bizType ->
when (bizType) {
PulseBizType.CLIENT_EVENT_BATCH_UPLOAD -> handleEventBatch(payload)
PulseBizType.CLIENT_APP_UPDATE_CHECK -> handleUpdateCheck(payload)
else -> encodeResponse(code = 404, msg = "unsupported pulse biz_type=$bizType")
}
}The read path completes inbound Responses before scheduling business handlers. Business handlers must not mutate connection state from another thread; cross-thread work returns through the connection/reactor dispatch entry.
One upload contains multiple events. The client preserves their queue order and compresses the
whole encoded batch once, rather than compressing individual events. The msgtrans header's
compression byte is authoritative:
| Value | Encoding |
|---|---|
0 |
none |
1 |
zstd |
2 |
zlib |
Payloads smaller than compressionMinBytes remain uncompressed. Larger event batches use the
configured codec (zstd by default). A sender must never label uncompressed bytes as zstd or zlib.
A receiver rejects unsupported encodings, invalid compressed streams, and output expanding beyond
16 MiB. msgtrans-kotlin compresses after the complete payload is built and normalizes inbound
packets back to plaintext before dispatch.
Delivery is at least once. The device first commits an encoded batch to a persistent ordered outbox, then sends the oldest due batch. It deletes that row only after the matching msgtrans response arrives. Disconnects, timeouts and process termination leave the row for retry. Event IDs make replay idempotent at the database.
The server responds with {code:0,data:true} only after it has durably moved the accepted batch into Redis. Redis consumers
must claim work into a processing state and acknowledge it after the database transaction commits;
removing an item before persistence leaves a crash window and does not satisfy this contract.
The mobile outbox is an SQLite table ordered by an autoincrement sequence. It is bounded by byte size, event count and age, and records attempt count and next-attempt time for exponential backoff. MMKV remains suitable for small configuration values, but it is not the event queue.
Before an outbox row is created, the client selects the largest FIFO event prefix whose complete
encoded envelope (identity, batch id and events) is no larger than batchMaxBytes. Remaining events
form later rows without reordering. An individual event larger than that limit can never be sent;
the client drops it, increments oversizedEventsDropped, and continues with later events so one
bad observation cannot permanently block the queue.
PulseKit never requests a platform permission. Runtime monitoring records which module called a permission or sensitive-system API and, where the existing call supplies a completion callback, observes the resulting state. It must not create a permission prompt, change the application's decision, or add a permission declaration to the host bundle.