Metadata-Version: 2.1
Name: pygar-client
Version: 4.19.11
Summary: A Python client for the GAR protocol
Home-page: https://www.trinityriversystems.com/docs/gar_protocol/
Author: Jon Hill
Description-Content-Type: text/markdown
Requires-Dist: msgspec >=0.18
Requires-Dist: websockets >=13

---
top_level: true
description: The GAR WebSocket protocol in full: handshake, subscribing, filtering, binary and JSON encodings, batching, heartbeats.
---

# Generic Active Records Protocol Documentation

The wire protocol every GAR client and server speaks: the handshake, subscriptions and their
filters, the binary and JSON encodings, batching, ownership, failed writes and termination.

## Overview

The GAR protocol is a WebSocket-based messaging system built on top of TCP. It is used for streaming and snapshot-style data delivery and operates over a single bi-directional socket connection. When establishing a connection, clients must specify the subprotocol `"gar-protocol"`:

```
Sec-WebSocket-Protocol: gar-protocol
```

## Encoding

The server supports two formats for message payloads: **binary** and **JSON**.

- **JSON (opcode 0x01)** is recommended for human-readable debugging and platform-agnostic integration.
- **Binary (opcode 0x02)** is compact and efficient but platform-dependent due to endianness, sizing, and alignment.

If the first byte of the initial Introduction message is `{`, then JSON mode is assumed regardless of the WebSocket opcode.

## Binary Format

- Each message begins with a `message_type` enumeration value.
- Immediately followed by a binary-encoded struct corresponding to that type.
- Must conform to platform-specific alignment and byte order.
- Should only be used when both ends are aware of the exact schema and platform.

## JSON Format

JSON messages are structured with a `"message_type"` string and a corresponding `"value"` object.

Example:
```json
{
  "message_type": "Subscribe",
  "value": {
    "subscription_mode": "Snapshot",
    "nagle_interval": 0,
    "name": "S1",
    "key_id": 0,
    "topic_id": 0,
    "class_list": ["Underlier"],
    "key_filter": null,
    "topic_filter": null,
    "exclude_key_filter": null,
    "exclude_topic_filter": null
  }
}
```

This format is more portable and ideal for most clients.

## Framing

Each websocket frame contains a single message - a json {} or binary object.

## Session Lifecycle

1. **Introduction** must always be the first message. It specifies protocol version, heartbeat expectations, and optional settings such as `unique_key_and_record_updates`.
2. Clients may then **Subscribe** to topics and keys or **Publish** messages.
3. Clients must send **Heartbeat** messages periodically to keep the connection alive. If none is received within the specified timeout, the server will disconnect the client.
4. The **first heartbeat** has a 10x grace period to allow for slow client setup.
5. All topics and keys must be explicitly introduced before publishing record messages.
6. IDs `0` are considered **invalid** for both `topic_id` and `key_id`.
7. Enumeration of topics and keys is not globally synchronized between client and server. Each side must track their own ID mappings.
8. A topic name resolves against the connection's working namespace, so one topic has several spellings (relative `AccountSet::name`, qualified `sample::AccountSet::name`). A client may introduce the same topic under a different id for each spelling it uses, and every one of those ids addresses that topic. Pinned by `ipc/topic_two_spellings_test.bash`, for both pygar and jsgar.

### Protocol version

Both sides carry their protocol version in the introduction — `BINARY_VERSION` on a binary
connection, `JSON_VERSION` on a JSON one (`ipc/ipc.ixx`) — and the receiver refuses a
connection whose version differs from its own, with the error `Unsupported version from <peer>:
<version>`. The two constants are monotonically increasing integers with no relation to the
release version; `BINARY_VERSION` ticks whenever the binary layout or the schema text embedded in
the introduction changes, `JSON_VERSION` only when an old JSON client could no longer interoperate
(an added optional field or a new message type does not tick it). JSON clients never parse the
schema text, so a schema-text format change leaves them unaffected.

### Schema on the wire

Every emitted schema surface is `.gar` text: the schema in the introduction, the `"schema"` member
of a gardb snapshot, `trsgar --print-schema`, and the discovery defaults — REST `GET /schema` and the
MCP `schema_source` tool. The `.psi` rendering is available only behind `format=psi` on those two
discovery surfaces.

### Client identity, and listing the users and applications connected

Every connection materializes on its peer as a `g::Client` key named `"<application> <user>
<uuid>"`, where the uuid is minted per connection — a library that opens one connection to download
the schema and a second to subscribe produces two distinct keys with independent lifetimes, and a
process holding two simultaneous connections is two clients. Ownership records
(`active_ownership`, `client_introduced_key_invalidated`) and the `ClientTopicStats` /
`ClientClassStats` telemetry key off this key, so it is what a disconnect releases and what a
stats row names. A proxy reports its own aggregate stats under the identity it presents to its
upstream, suffixed ` control`, so they sit beside the per-downstream-client rows it mirrors
without colliding.

**Listing who is connected.** Because every connection is a key of a class, the live clients of a
server or proxy are just that class's keys — `GET /keys/g::Client` enumerates them, and
`GET /records/g::Client?key=<key>` reads one (`ipc/class_keys_integration_test.bash`,
`ipc/test_active_ownership_atomic_intro.bash`). That is the way to answer "which users or
applications are on this server right now", and to map a username to the applications it connects
with: the answer is live state on the server, not something to reconstruct by grepping proxy logs
for introduction lines.

### Introduction Options

The introduction message supports the following optional fields:

- **`unique_key_and_record_updates`** (bool, default false): If true, only one record update will be sent per key/record even if multiple subscriptions apply. This only affects streaming record updates — snapshot data is still sent for each subscription. Note that `active_subscription_group` will not be reliable when using this option.
- **`disconnect_on_write_failure`** (bool, default false): bail-out-first writers. The server closes the connection on the first client message with a failed write — after reporting it, as a **non-recoverable** `Error` carrying `failed_writes` (delivered even when the `active_ownership` `msg_ref` is 0, which is otherwise log-only). Every write that succeeded stays landed; this is not transactional — a client that needs atomicity batches its writes so each batch is safe under partial failure. Meant for critical processes that would rather restart than continue past a refused write, and for integration tests; subscriptions on the same connection go down with it, so a process that both watches and writes should keep them on separate connections. Through a proxy, a failure detected upstream reaches the client as a relayed `failed_writes` Error and closes the client's connection the same way, but only when the proxy learned of it — i.e. with a nonzero `msg_ref`, since the proxy's own upstream connection does not carry the flag. pygar: `GARClient(..., disconnect_on_write_failure=True)`; jsgar: the `disconnectOnWriteFailure` constructor argument. Both libraries already treat a non-recoverable `Error` as fatal (`exit_code` 1, stop). The REST analogue is `?fail=true` (see [Write-failure replies](../../website/docs/gar_rest_api.md#write-failure-replies)).

## Record Updates

Record updates flow both ways through the same messages: a client writes with them, and a server streams
**post-snapshot updates to a subscriber** with them too — only the initial snapshot (and nagle-coalesced
ticks) travel as [batch updates](#batch-updates). Anything that must ride with every streamed record
therefore has to live on these messages as well as on `batch_update`.

The GAR protocol supports record updates via:

### Binary encoding
- **FixedLengthRecordUpdate**: `record_id` directly followed by the fixed-size record data.
- **VariableLengthRecordUpdate**: serialized `binary_record_update` struct with embedded length field.

### JSON encoding
- Encoded using `json_record_update` with clear field names and value.
- **Non-finite floats.** JSON has no literal for them, so every JSON emitter (REST, the JSON wire protocol, `--print`) spells an infinite `float`/`double` as **`1e999`** / **`-1e999`** — a literal that overflows to ±infinity in every reader (Python, JavaScript, jq, the trs reader itself) — and NaN as **`null`** (a NaN result is absence at the record boundary). On input the trs reader accepts `1e999`, and the words `infinity` / `-Infinity` (case-insensitive), as ±infinity. A `.gar` float literal takes the same overflow spelling — `1e999` in a key initializer or an expression is +∞ — so a schema can say what the wire says (`mkt_feed_test.gar`'s `good_long_leg_no_ask_spread` pins an infinite initializer through the `g::schema` introspection round trip).

Example:
```json
{
  "message_type": "JSONRecordUpdate",
  "value": {
    "record_id": {"key_id": 1, "topic_id": 22},
    "value": 0.3
  }
}
```

**A null value deletes the record.** `"value": null` on a `JSONRecordUpdate` is absence, not a zero, and
is equivalent to sending `DeleteRecord` for that `record_id` — the same meaning null carries in a REST
write, a [batch update](#batch-updates) topic and a [compare-exchange](#compare-exchange) value. On a
fixed-length topic a null therefore removes the record rather than writing the type's zero bytes; to
write a zero, send `"value": 0`.

### Augment bypass (assume_cached_subscriptions_cover)

A subscription with `assume_cached_subscriptions_cover: true` asserts that the **proxy** serving it has
cached subscriptions covering every record it asks for. That proxy then skips this subscription's augment
differencing entirely and serves it — derived topics included — from its cache, removing the poller-thread
set-algebra the snapshot blocks on. The assertion is per subscription: an operator turns it on for the few
heavy subscriptions the cache was provisioned around, and every other subscription on the same proxy keeps
augmenting normally. No proxy config gates it — the assertion is the caller's own, and a wrong one
under-serves only the subscription that made it; a plain (non-proxy) server has no cache to bypass and
ignores the flag. **Fail-open:** anything the cache does not cover is silently absent or null on that
subscription. REST equivalent `?assume-cached=true`, `trsgar --assume-cached`. See
[augment_bypass_spec](../../features/proxy/augment_bypass_spec.md).

### Record tick_time

A subscription with `include_tick_time: true` receives each record's **tick_time**: the owning server's
wall-clock nanoseconds at the record's last write. It advances on **every** write, including one whose
value did not change, so it answers "is the writer alive", not "did the value change". A value-unchanged
write is throttled (no update); a flagged subscription instead receives a **`RecordTick`**
(`record_tick {record_id, tick_time}`) — stamp only, no value — which clients that don't track stamps can
ignore. Proxies forward the flag upstream and relay the origin's stamp unchanged, never their own receipt
time; a relayed tick never triggers recomputation.

- `json_record_update` / `binary_record_update` carry it as `tick_time` (JSON omits the member, binary
  sends 0, when the subscription didn't opt in).
- `batch_update.key_major_update.tick_times` is an array parallel to `topics`; JSON carries a
  `"tick_times"` object beside `"topics"` with the same `<topic_id>` member names. Empty/absent otherwise.

**When a `RecordTick` is sent.** Only once the subscription is streaming, only for keys the connection
has already introduced, and never held in a nagle window. It fires only on a real advance of the stamp:
a relay re-writing an identical record with the same stamp is silent, so a redundant snapshot on a proxy
ticks nothing. A subscription without the flag keeps the throttle and receives nothing for an unchanged
write.

**How the stamp is set.** A write that originates on a server — a client's write, a derived recompute,
a local topic — is stamped with that server's own clock. A relay that carries an upstream stamp stores it
verbatim; a relay that carries no stamp (an unflagged upstream subscription) preserves the stamp the
record already holds, so two upstream subscriptions of which only one is flagged converge on the right
stamp whatever their delivery order. Derived topics computed on a proxy are origin writes there and
carry the proxy's clock.

Off by default — payloads keep their size and shape. pygar keeps the stamps in
`client.tick_time_map[(key_id, topic_id)]` and jsgar in `client.tickTimeMap`; both offer a record-tick
callback (`register_record_tick_handler` / `registerRecordTickHandler`).

### Record diagnostics

A derived-topic record that has no value carries a structured **diagnostic** saying why — a
`derived_diagnostic {reason, missing_inputs[], text}`. `reason` is one of `InputMissing`,
`KeyCastMissed`, `Suppressed` (the *null class*: plain absence with a cause) or
`ComputeError` / `InputError` (the *error class*: the function raised or failed — `text` is the
stripped exception message — or one of its inputs is in error). Errors are poison: an `InputError`
consumer stays in error whatever its optional/default argument policy, and `??` coalesces nulls
only. `missing_inputs` names each absent or erroring input — its argument index and declared
parameter name, the input topic's path and the key holding it — so a client can chase a null or an
error upstream, record by record, to its root cause.

- Every subscription receives error-class diagnostics with no opt-in; the null-class reasons only
  reach subscriptions that set `include_diagnostics: true`.
- The wire carries them as **`RecordDiagnostic`** (`record_diagnostic {record_id, diagnostic}`),
  sent after the batches that introduce the record's key during a snapshot, and streamed on change.
  A diagnostic replaces the record's value: a value→diagnostic transition arrives as the ordinary
  `DeleteRecord` followed by the `RecordDiagnostic`; a value arriving supersedes the diagnostic.
- A proxy computes derived topics itself, so its diagnostics are its own computation's; a client
  that takes derived records from a server (trsgar, pygar, jsgar) applies the relayed diagnostic
  verbatim.

See [null_reasons_spec](../../features/derived_topics/null_reasons_spec.md).

## Density

The `density` field controls how records are grouped in JSON output. The default density is **KeyMajor**.

- **KeyMajor** (default): `{ "key": "key1", "topics": { "topic1": <record>, "topic2": ... } }`
- **TopicMajor**: `{ "topic": "topic1", "keys": { "key1": <record>, "key2": ... } }`
- **RecordMajor**: `{ "key": "key1", "topic": "topic1", "value": <record> }` (one record per object)

## Batch updates

Multiple updates can be sent together in a batch. Initial snapshots after subscribing are always sent in one or more batch.

Records are sent in key-major format. Keys are introduced along with their class or class_list. Key names and classes may be omitted when keys have already been introduced.

Binary format uses the `batch_update` struct.

JSON format is similar, except the records within each "topics" object are JSON objects with topic_id numbers as member names: { "<topic_id>": record_value, ... }

Example:
```
{
  "message_type": "BatchUpdate",
  "value": {
    "default_class": "A",
    "keys": [
      {
        "key_id": 1,
        "name": "key1",
        "topics": {
          "20": 10
        }
      },
      {
        "key_id": 2,
        "name": "key2",
        "class": "B",
        "topics": {
          "21": 20
        }
      },
      {
        "key_id": 3,
        "name": "key3",
        "classes": ["A", "B", "C"],
        "topics": {
          "20": 30,
          "21": 31,
          "22": 32
        }
      }
    ]
  }
}
```

## Subscriptions

A subscription names the classes, keys and topics a client wants and whether it is a one-shot snapshot or streaming; filters, the referencing-class list, exclusions, the class join and the nagle interval are all fields of it. The full reference is [Subscriptions](subscriptions.md).

## Echo

Clients may send an **EchoRequest** message containing a `msg_ref` (message reference number). The server will respond with an **EchoResponse** containing the same `msg_ref`. This can be used to confirm that all prior writes have been processed before continuing.

In proxy chains, the echo propagates end-to-end: the proxy forwards the echo to its upstream server and only responds to the client once the upstream echo response arrives. This guarantees that all prior writes — including record updates and deletes — have been fully processed through the entire chain by the time the client receives the echo response. The REST API uses this mechanism internally, so HTTP POST and DELETE responses also carry this guarantee.

## Compare-exchange

A **CompareExchange** message applies a write only if the record's current value is what the client
expects, so two writers racing on one record cannot silently overwrite each other. It carries a
`record_id`, an inline `msg_ref` correlation id, a `test` (the expected current value) and a `value`
(what to write on a match). The server replies with a **CompareExchangeResult** echoing that
`msg_ref` and carrying `success`, which is true only when the value was applied. On a failure it also
returns the record's **current** value and a `reason`: `CompareMismatch` when that value did not match
`test`, so a client can retry against fresh state without a separate read, or `WriteDenied` /
`DeleteDenied` when the key is [owned by another client](../../website/docs/writer_owned_keys.md), which no retry clears.

**Null means absent, on both sides.** A missing record reads as null, so:

- `test: null` matches a record that is **missing** — this is create-if-absent. (For a topic whose
  type declares a default, a missing record instead reads as that default, so the matching test is
  the default value.)
- `value: null` is an **empty** value, so a successful compare **deletes** the record. This holds for
  every topic type, including fixed-length numeric ones — a null value is never stored as a zero.

Binary clients express the same two things with a zero-length `test` / `value` byte array.

Together those give compare-and-delete: `test` = the value you saw, `value` = null. That makes a
record a **lock-free "consume exactly once" flag** — several readers that all observed *X* each
attempt the delete against *X*, exactly one succeeds, and the losers are told so. Nothing is claimed,
so a reader that dies mid-operation strands nothing. The same operation is available over
[REST](../../website/docs/gar_rest_api.md#url-format) via `atomic-compare-exchange=true`.

Compare-exchange propagates through proxy chains: the proxy forwards it upstream and relays the
result back to the originating client. The REST equivalent forwards the same way, so both surfaces
are atomic through a proxy.

The edges of the null rule, and two conventions:

- A missing record on a *plain* topic (`bool`, `int`, a struct of zeros — no annotated default) is
  matched only by a null or empty `test`, never by `test: false` / `0`: a missing `x` is not equal
  to `false`, the same way `x == false` is false in an expression when `x` is missing.
- An annotated default is what the missing record reads as, so a `bool default true` lock is
  acquired with `test: true` against a key that has never been written — no seed record needed.
- A value written by a successful compare is stored verbatim even when it equals the type default
  on a `trim_default_values` topic, so the result is always observable as a record echo.
- On a fixed-length topic `value: null` deletes; to write a zero, send `value: 0`.
- The inline `msg_ref` is the correlation id for the result and for any error the compare raises;
  the `active_ownership` marker is sent before a compare only when it conveys ownership
  (`client_key_id`, `skip_ownership_checks`, `ownership_action`) — a plain compare sends none.

`pygar/test_gar_client.py` (`test_compare_exchange_*`) pins each of these; `ipc/cas_proxy_test.py`
the proxy path.

## Subscription Status

A SubscriptionStatus message will be sent to delineate the lifetime of the subscription.

- "status": "ProcessingSnapshot" is sent at the start of processing. Topic, Key, and record updates will follow.

- "status": "NeedsContinue" is sent if snapshot_size_limit is hit; see Subscriptions above.

- "status": "Streaming" is sent for streaming subscriptions once the snapshot has been fully processed. Additional messages are live updates.

- "status": "Finished" is sent once the snapshot has been fully processed and the subscription mode is non-streaming. It is also sent after an unsubscribe has been processed.

"Finished" with no "ProcessingSnapshot" before it means the server refused the subscribe; a recoverable Error naming the subscription and the reason precedes it, and the connection stays up. See [A refused subscribe](subscriptions.md#a-refused-subscribe).

## Heartbeats

Clients specify a heartbeat timeout interval within their introduction. If the server does not receive a Heartbeat message within this interval, it sends the client an Error message ("Heartbeat timeout") and closes the connection gracefully — a client that still reads (e.g. one whose sending stalled) receives the error before the close; one that doesn't is dropped after one further timeout interval.

The server also specifies its timeout interval, and sends regular heartbeats. Clients may choose to respect missed heartbeats and disconnect.

Heartbeats are expected to be sent at twice the frequency of the timeout interval. e.g. with timeout of 4 seconds, heartbeats are sent every 2 seconds.

The send cadence is fixed-rate (anchored to the previous scheduled instant, not to when the handler last ran), but a missed-tick backlog never replays. The send loop is single-threaded, so a slow client that backs up the send buffer can stall it for many intervals; rather than firing a burst of catch-up heartbeats onto the already-slow client when it resumes, the sender snaps the next beat forward past the current time, collapsing the backlog to a single heartbeat one interval out.

## Synchronizing without sleep

Client code (and test harnesses) frequently needs to wait until some condition holds — the server is
accepting connections, prior writes have been applied, a subscription is live, a derived value has
settled — before taking the next step. **Do not approximate these waits with a fixed `sleep`.** A
blind delay is simultaneously too short (it races under load) and too long (it wastes time on a fast
path), and it converts a missed event into a silent, intermittent failure. The protocol exposes an
explicit signal for each case; synchronize on the signal and the timing becomes deterministic.

- **Server is ready to accept connections.** Launch the server with `--server-ready-out FILE`; it
  writes the bound `{protocol, url}` endpoints to `FILE` once it is listening. Poll until the file is
  non-empty, then read the URL — never sleep waiting for a port to bind.

- **All prior writes have been processed.** Send an **EchoRequest** and wait for the matching
  **EchoResponse** (see [Echo](#echo)). In a proxy chain the echo is end-to-end, so its response
  guarantees every prior record update/delete has propagated through the whole chain. The REST API
  uses this internally, so an HTTP `POST`/`DELETE` response already carries the guarantee — no
  follow-up wait is needed.

- **A streaming subscription is live.** After subscribing, wait for the
  `"status": "Streaming"` **SubscriptionStatus** message (see [Subscription Status](#subscription-status));
  it is sent once the initial snapshot has been fully processed and means subsequent messages are
  live updates. This matters most for **live-only topics** (`history: none`): such a record is pushed
  only to subscribers that are *already* streaming when it is produced and is never retained, so a
  subscriber still in `ProcessingSnapshot` when the event fires misses it permanently. Trigger the
  event only after the `Streaming` status arrives. The same transition is the signal that a
  *publisher* has delivered: tooling that waits for a publishing process to **exit** is waiting on
  the wrong thing, because a publisher's exit rides the receiver's echo barrier and still —
  correctly — waits for a stalled server to come back. Synchronize on the publisher's `Streaming`
  transition (`trsgar --trs-log FILE` records it), not on its exit.

- **A derived value has settled before you act on it.** Poll the value (over REST, or
  via your subscription stream) until it holds the expected value, rather than sleeping a guessed
  interval.

- **A non-streaming snapshot subscription has completed** — most importantly a **DeleteKeys** /
  **DeleteRecords** delete. Wait for the `"status": "Finished"` SubscriptionStatus before treating the
  result as done or sending `Unsubscribe`: the snapshot (and any delete it performs) is processed
  incrementally, so an `Unsubscribe` issued synchronously after the subscribe cancels the in-flight work.
  The REST API waits for `Finished` for you — an HTTP `DELETE` returns only after the delete has fully
  processed — so this caveat applies to direct WebSocket clients (e.g. browser/Node `subscribe` callers),
  not REST callers.

For ad-hoc polling, use a bounded retry with a small interval and a hard timeout (e.g. check every
100 ms up to a few seconds) so a stuck condition surfaces as a clear timeout rather than a hang. The
only place a fixed delay is defensible is a *negative* check — confirming that something does **not**
happen within a window.

## Termination

To gracefully close a session, clients should send a `Logoff` message. However, clean TCP connection closure is also acceptable.

A WebSocket client that sends a close frame (code 1000 or 1001, or no code — a browser's tab
teardown) gets one back and the server then closes the connection, so the client's socket reaches
CLOSED rather than waiting in CLOSING. A close carrying any other code is an error: it is reported
and the connection dropped without an answer.

A close frame with an **empty body** is close code 1005 ("no status received") — the normal
signature of a browser page dying without a clean close. The server logs it at Info (`Websocket
close frame received (1005 no status)`) and answers it exactly like 1000. A close frame with a
**1-byte body** is malformed (a carried close code is two bytes): the server warns `Malformed
websocket close frame (1-byte payload)` and disconnects. `ipc/websocket_close_frame_test.bash`
sends each shape over a raw socket.

If the server encounters an issue, it may respond with an `Error` message prior to disconnection.
The frames a peer sent before resetting the connection are still delivered ahead of the reset, so
an `Error` a server wrote and then closed on — a rejected subscription, a refused introduction —
reaches the client as that error rather than as a bare "connection closed".

Connection diagnostics are written from the emitting process's point of view, and the connection
code is the same at both ends of a link: a `Disconnecting client on write error` line in a
*publisher's* log is that publisher dropping its own upstream link (its outgoing write queue
overflowed), not evidence that the process is a server.

The server sends `Logoff` when it is shutting down (`trsgar --send-shutdown`), tearing every
subscription down at once. That is an orderly end, but at the socket it is indistinguishable from
the connection breaking, and both stop the client the same way. `GARClient.server_logoff` records
which it was, so a long-running client can exit 0 through a planned restart instead of reporting a
failure on every one — the alternative is a supervisor alert per restart, which buries the real
failures. Read it after the client stops; `register_logoff_handler` is the callback form. jsgar
carries the same flag as `serverLogoff`.

Both libraries write their log lines as `YYYY-MM-DD HH:MM:SS.mmm LEVEL:<name>:<message>` in local
time — the shape `trsexec` and the C++ core use — so a service log carrying several of them reads
as one stream rather than as interleaved timezones and formats.

### The client never reconnects; the application may start it again

A `GARClient` (pygar and jsgar alike) holds one connection per `start()` and never reconnects on
its own: a lost connection, a failed connection attempt, a heartbeat timeout, a server `Logoff`
or a non-recoverable `Error` all stop the client, and the stopped callback
(`register_stopped_handler` / `registerStoppedHandler`) fires once per session with the cause in
`stop_reason` (`stopReason`):

| `stop_reason` | what ended the session | start again? |
|---|---|---|
| `stop` | the application's own `stop()` / `logoff()` | its call |
| `server_logoff` | the server's `Logoff` — a planned restart | once the server is back |
| `disconnect` | the socket closed or broke | yes |
| `heartbeat_timeout` | no server heartbeat within the interval; `exit_code` is 1 | yes |
| `error` | a non-recoverable server `Error` (pygar also: an undecodable message); `exit_code` is 1 | after fixing what the Error reported |
| `connect_failed` | the connection attempt was refused, timed out, or the handshake failed | when the server is expected back |
| `unauthorized` | the handshake was rejected with 401 | no — the credentials are wrong |

Retrying is the application's decision, made by calling `start()` again on the same client,
from the stopped callback included. Each `start()` is a fresh session that begins from the state
of a newly constructed client: every server-assigned and client-assigned id, cached record, tick
time, diagnostic and ownership marker is cleared, so the application re-issues its subscriptions
and key introductions (both are per connection — see
[Session Lifecycle](#session-lifecycle)); registered handlers and callbacks carry over. Nothing survives
from the previous session's teardown: a pygar heartbeat thread or a jsgar send loop and socket
still winding down cannot touch the session that replaced them. One `start()` attempt makes one
connection; an application that wants to wait for a server pacing its own retries with a backoff
of its choosing (`connect_failed` comes back at once from a refused port). pygar's `start()`
blocks for the life of the session and returns after the stopped callback, so each session runs
on its own thread; jsgar's returns at once. The pygar suite's `test_start_again_after_disconnect`
and the `client_restart_test` (jsgar) run a client through a killed server, a refused port, a
restarted server, and a `start()` from inside the stopped callback.

## Writer ownership (active_ownership)

Ownership of writer-owned keys is carried by the `active_ownership` message, sent before the key
introductions, record updates or batches it governs; it sets connection state that stays in force
until the next one, so many writes share one declaration. Its fields:

| Field | Type | Description |
|---|---|---|
| `msg_ref` | `message_reference` | Where recoverable errors from subsequent messages are routed. If 0, errors are logged server-side and not returned. |
| `ownership_action` | `enum` | `NONE` (default) / `Acquire` / `Release`. |
| `skip_ownership_checks` | `bool` | Ignore the current owner, so the key may be acquired, released, or deleted regardless of who holds it. |
| `require_existing` | `bool` | Only write to existing keys — do not create new keys. If `msg_ref` is non-zero, sends recoverable errors for missing keys/classes. |
| `client_key_id` | `key_id` | The *remote* `g::Client` key id the sender will use to refer to this owner — how a proxy attributes ownership to a downstream client. If zero, the connecting client's own connection key is used. |
| `client_key_name` | `string optional` | Trailing variable field; when present, atomically introduces the `g::Client` key named here and binds it to `client_key_id`. |

The schema attributes, the ownership lifecycle, the REST and CLI surfaces and the error texts are on
[writer_owned_keys.md](../../website/docs/writer_owned_keys.md).

## Failed writes

Recoverable write failures do not disconnect. Every failed write from processing one client message
batches into ONE recoverable `Error` message whose `failed_writes` array identifies each failure —
a shape-scoped reason enum (`record_write`/`key_write` union), the addressed ids and names,
`malformed` (server-classified: a request defect vs a server-state condition), per-failure `detail`,
and `origin` (the server or proxy that detected the failure; proxies relay upstream failures
verbatim). Delivery requires a nonzero `msg_ref` on the `active_ownership` marker — with `msg_ref`
0 the failure is logged server-side only. pygar (`register_write_errors_handler`) and jsgar
(`registerWriteErrorsHandler`) deliver the array to a callback as
`(failed_writes, message, msg_ref)`. A connection introduced with `disconnect_on_write_failure`
instead receives the batch as a non-recoverable `Error` (msg_ref 0 included) and is then closed —
see [Introduction Options](#introduction-options). Pinned end-to-end by `ipc/write_errors_test.bash`.

`malformed` is true for the reasons a retry could never fix: `InvalidValue`, `UnknownTopic` and
`TopicNotWritable` on a record write, `UnknownClass` on a key write. `TopicNotInClass` is
deliberately *state*, not malformed — a class downgrade running concurrently can legitimately race
a correct writer. `key_id` is 0 when the write addressed the key by name. Value-level failures —
an unknown class or topic *name*, a value that does not parse — are per-write reports; an unmapped
binary *id*, a name-vs-id mismatch and a framing error stay fatal, because ids are protocol state
and a desynchronized id map poisons everything after it. The `Error`'s text is composed from the
structured fields, in the shape `KeyNotFound key 'x' topic 'y' from 'backend': <detail>`. A
`topic`-typed value naming a topic the schema lacks is refused on every surface (a client write
reports malformed `InvalidValue`; a subscribe naming it fails naming the topic) — it is never
quietly stored as the null topic.

**The plain error callback cannot tell the two apart.** pygar's `register_error_handler` and jsgar's
`registerErrorHandler` are text-only conveniences: their wrappers pass `message` and drop the rest of
the `Error`, including the `recoverable` flag that says whether the connection survives. Both clients
read that flag internally to decide whether to keep going, so the information reaches the library and
stops at the wrapper. A client that must distinguish "this write was rejected" from "this connection
is finished" registers for the raw message instead — `register_handler("Error", …)` /
`registerHandler('Error', …)`, whose callback receives the whole `Error` — or, when the interest is
specifically which writes failed, uses the write-errors callback above.

## Key teardown is per class

All key teardown is delivered as `KeyUpdate` messages carrying `deleted_class` — one message per
class. When a server deletes a key entirely, it sends one `deleted_class` KeyUpdate per class the
receiver was shown (each with an empty remaining `class_list`), followed by
`KeyIdRetired` — a connection-scoped bookkeeping message meaning only "the sender's
introduction of this key id is gone; drop your mapping for it", never a data teardown. There is
no whole-key teardown broadcast: a subscriber's view of a key ends when its last subscribed class
is deleted. (Distributed servers can each hold a *subset* of a key's classes, so "all classes
gone" on one server is not global truth — per-class messages are the only view-independent form.)

`DeleteKeyCommand` is the client→server *command* requesting whole-key deletion at the receiving
server; servers never broadcast it.

A key can also be **downgraded**: its derived class removed while named base classes — and the
records on topics those bases still cover — are kept. On the wire this is the same message,
`key_update{deleted_class: Derived, class_list: [bases]}`, with the retained bases as the surviving
list; no protocol version ticks for it. A receiver that predates downgrades applies it as a full
delete of the class, dropping the retained bases. pygar's `register_deleted_class_handler` and
jsgar's `registerDeletedClassHandler` are the client surface for every `deleted_class` update,
whole-key deletes and downgrades alike, and receive the remaining class list with each.

## Example Session (Client Perspective)

```json
Sent: {"message_type": "Introduction", "value": { "version": 650269, "heartbeat_timeout_interval": 3000, "user": "jonh" }}
Received: {"message_type": "Introduction", "value": { "version": 650269, "heartbeat_timeout_interval": 3000, "user": "jserver" }}
Sent: {"message_type": "Subscribe", "value": { "subscription_mode": "Streaming", ... }}
Received: {"message_type": "ProcessingSnapshot", "value": { "name": "S1" }}
Received: {"message_type": "TopicIntroduction", "value": { "topic_id": 18, "name": "top_bid_price" }}
...
Received: {"message_type": "SnapshotComplete", "value": { "name": "S1" }}
Sent: {"message_type": "Heartbeat", "value": { "u_milliseconds":, 1745425692890 }
Received: {"message_type": "Heartbeat", "value": { "u_milliseconds":, 1745425693895 }
...
Sent: {"message_type": "Logoff"}
```

## Auditing Topics (Control Port)

When running in audit mode (`--ws-control-port <port>`), GAR will emit internal topic traffic for monitoring

## g Schema

 - [g.gar](../../website/docs/g.gar)

## Protocol Schema

- [proto.gar](../../website/docs/g/proto.gar)

## Related

- [af_unix_transport.md](../../website/docs/af_unix_transport.md) — local connections silently use AF_UNIX sockets
  rather than TCP loopback, and why a URL may resolve to `unix:`.
- [gar_pipelining.md](../../website/docs/gar_pipelining.md) — streaming records between endpoints in a shell pipeline,
  and the failure modes to watch for.

## Deployment Schema

- [deployment.gar](../../website/docs/g/deployment.gar)

## Features in this area

- [client libraries](../../features/client_libraries/client_libraries.md) — Connecting to GAR from Python (pygar) and JavaScript (jsgar) over WebSocket.
- [subscription class join](../../features/subscription_class_join/subscription_class_join.md) — Joining a subscription's key filter against another class's key set.
- [key name list subscription](../../features/key_name_list_subscription/key_name_list_subscription.md) — Making a subscription with a finite list of key names snapshot by direct lookup instead of scanning the class.
- [rpc](../../features/rpc/rpc.md) — Calling a named GAR function and getting a structured reply.

## Internals
<!-- private -->

The implementation record — every private section this page used to carry — is [protocol_internals.md](protocol_internals.md).
