diff --git a/.claude/skills/uts-to-kotlin/references/objects-mapping.md b/.claude/skills/uts-to-kotlin/references/objects-mapping.md
index cbe977f0c..e27e789e8 100644
--- a/.claude/skills/uts-to-kotlin/references/objects-mapping.md
+++ b/.claude/skills/uts-to-kotlin/references/objects-mapping.md
@@ -27,8 +27,9 @@ doubt, that IDL is the source of truth; this doc is the applied version of it fo
11. [Message / operation types (`PublicAPI::ObjectMessage` →)](#11-messages)
12. [Errors & error codes](#12-errors)
13. [Internal-graph types (unit specs) — important caveat](#13-internal-graph)
-14. [Worked example](#14-worked-example)
-15. [Quick symbol index](#15-symbol-index)
+14. [Integration-test helpers — REST fixture provisioning](#14-integration-helpers)
+15. [Worked example](#15-worked-example)
+16. [Quick symbol index](#16-symbol-index)
---
@@ -554,9 +555,43 @@ wire `action` / `semantics` are integer enum codes — the builders emit the cod
> is implemented. (`buildPublicObjectMessage` does *not* depend on this — the message/operation layer is
> implemented, so those tests can run now.)
+(For the **integration** tier's REST fixture helper — `provision_objects_via_rest` — see §14.)
+
+---
+
+## 14. Integration-test helpers — REST fixture provisioning (`standard_test_pool.md` → integration `helpers.kt`)
+
+Some objects **integration** specs (tier `integration/standard`) seed object state over REST *before* the
+realtime client connects, via the spec's `## REST Fixture Provisioning` helper `provision_objects_via_rest`.
+Its ably-java translation lives in
+`uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/helpers.kt` (package
+`io.ably.lib.uts.integration.standard.liveobjects`) — **call it; don't hand-roll the REST request or payload
+JSON.** (Currently only `objects/integration/RTPO15` uses it.) Unlike the unit helpers (§13), this needs no
+reflection and no `:liveobjects` dependency — it compiles and runs against `:java`'s public `AblyRest`.
+
+| Spec helper / operation shape | integration `helpers.kt` |
+|---|---|
+| `provision_objects_via_rest(api_key, channel_name, operations)` | `provisionObjectsViaRest(apiKey, channelName, operations: List): List` (POSTs the op(s); returns created/updated `objectIds`) |
+| op `{ mapSet: { key, value }, objectId/path }` | `mapSetOp(key, value, objectId = …, path = …, id = …)` |
+| op `{ mapRemove: { key }, objectId/path }` | `mapRemoveOp(key, objectId = …, path = …, id = …)` |
+| op `{ mapCreate: { semantics: 0, entries }, [objectId/path] }` | `mapCreateOp(entries: Map, semantics = 0, objectId = …, path = …, id = …)` |
+| op `{ counterCreate: { count }, [objectId/path] }` | `counterCreateOp(count, objectId = …, path = …, id = …)` |
+| op `{ counterInc: { number }, objectId/path }` | `counterIncOp(number, objectId = …, path = …, id = …)` |
+| value `{ string }` / `{ number }` / `{ boolean }` / `{ bytes }` / `{ objectId }` | `valueString` / `valueNumber` / `valueBoolean` / `valueBytes` / `valueObjectId` (each → `JsonObject`; `valueString` / `valueBytes` take an optional `encoding`) |
+
+> **V2 REST format.** These builders follow the LiveObjects **V2** objects REST API (the OpenAPI is the
+> source of truth), **not** the literal `standard_test_pool.md` pseudocode — which still showed the legacy
+> `POST …/objects` + `{ messages: [...] }` envelope. V2: `POST /channels/{channel}/object` (**singular**),
+> body is a single operation **or** a bare array (no `messages` wrapper), each op named by its payload key
+> (`mapSet` / `mapRemove` / `mapCreate` / `counterInc` / `counterCreate`) with an `objectId`/`path` target
+> (and optional idempotency `id`). The spec helper is being aligned upstream (ably/specification#497).
+>
+> The REST call hits the live sandbox today; the realtime client it provisions for only *observes* the data
+> once the SDK's OBJECT_SYNC + `RealtimeObject.get()` land.
+
---
-## 14. Worked example
+## 15. Worked example
Spec pseudocode (public-API style):
@@ -597,7 +632,7 @@ wrapped in `LiveMapValue.of`; `at(...)` followed by `asLiveCounter()` before cou
---
-## 15. Quick symbol index
+## 16. Quick symbol index
| ably-js / spec symbol | ably-java |
|---|---|
diff --git a/uts/build.gradle.kts b/uts/build.gradle.kts
index 5286e59eb..4eca7f9c4 100644
--- a/uts/build.gradle.kts
+++ b/uts/build.gradle.kts
@@ -11,6 +11,8 @@ dependencies {
// helpers reach the internal wire/message classes (e.g. for build_public_object_message) by reflection.
testRuntimeOnly(project(":liveobjects"))
testImplementation(kotlin("test"))
+ // @ParameterizedTest / @ValueSource — version managed by the junit-bom on the test classpath.
+ testImplementation("org.junit.jupiter:junit-jupiter-params")
testImplementation(libs.mockk)
testImplementation(libs.coroutine.core)
testImplementation(libs.coroutine.test)
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/deviations.md b/uts/src/test/kotlin/io/ably/lib/uts/deviations.md
index d0cc49408..8e5a3dad3 100644
--- a/uts/src/test/kotlin/io/ably/lib/uts/deviations.md
+++ b/uts/src/test/kotlin/io/ably/lib/uts/deviations.md
@@ -49,3 +49,204 @@ Deviations from the Ably spec identified during UTS test translation. Each entry
**Workaround in tests:** The spec-correct assertion (`assertEquals(42L, msgSerial)`) is gated behind `RUN_DEVIATIONS`. A regression guard assertion (`assertEquals(0L, msgSerial)`) runs by default to catch any unintentional change to the SDK's actual behaviour.
**Tests affected:**
- `RTN16f - recover option initializes msgSerial from recoveryKey` (RTN16f/recover-initializes-msgserial-0) — `assertEquals(42L, ...)` gated; `assertEquals(0L, ...)` added as regression guard.
+
+---
+
+## RTINS12d / RTINS14d / RTINS16c — wrong-type Instance operation throws IllegalStateException, not ErrorInfo 92007
+
+**Spec points:** RTINS12d, RTINS14d, RTINS16c
+**What the spec requires:** Calling a type-specific operation on an `Instance` wrapping the wrong type fails with `ErrorInfo` code `92007` — `set`/`remove` on a non-LiveMap, `increment`/`decrement` on a non-LiveCounter, `subscribe` on a primitive.
+**What the SDK does:** ably-java implements the typed-SDK variant (RTTS): those operations do not exist on the base `Instance` or on the mismatched typed view, so the wrong operation cannot be called at all. The type check happens at the `as*` cast, which fails fast with a plain `IllegalStateException` ("Not a LiveMap/LiveCounter instance") carrying no Ably error code (`DefaultInstance`, RTTS9d). There is no `92007` `AblyException` / `ErrorInfo`.
+**Workaround in tests:** Assert `assertFailsWith { … }` on the relevant `asLiveMap()` / `asLiveCounter()` cast instead of an `ErrorInfo` code `92007`.
+**Tests affected (InstanceTest.kt):**
+- `RTINS12d - set on non-LiveMap throws` (RTINS12d/set-non-map-throws-0)
+- `RTINS14d - increment on non-LiveCounter throws` (RTINS14d/increment-non-counter-throws-0)
+- `RTINS16c - subscribe on primitive throws` (RTINS16c/subscribe-primitive-throws-0)
+
+---
+
+## RTINS4d / RTINS9c — `value()` on a LiveMap / `size()` on a LiveCounter are not expressible
+
+**Spec points:** RTINS4d, RTINS9c
+**What the spec requires:** The polymorphic `Instance#value()` returns `null` for a LiveMap, and `Instance#size()` returns `null` for a non-LiveMap.
+**What the SDK does:** Under the typed-SDK variant (RTTS10) these accessors are partitioned: `value()` exists only on `LiveCounterInstance` / primitive instances, and `size()` only on `LiveMapInstance`. A `LiveMapInstance` has no `value()` and a `LiveCounterInstance` has no `size()`, so the "wrong-type returns null" assertions cannot be written (and the cast to the other view would throw — see above).
+**Workaround in tests:** The expressible half of each test is translated (counter `value()`, map `size()`); the not-expressible sub-assertion is dropped with an inline note.
+**Tests affected (InstanceTest.kt):**
+- `RTINS4 - value returns counter number or primitive` (RTINS4/value-counter-0) — map `value() == null` omitted.
+- `RTINS9 - size returns non-tombstoned count` (RTINS9/size-0) — counter `size() == null` omitted.
+
+---
+
+## RTINS10 — `compact()` not implemented; `compactJson()` used instead
+
+**Spec point:** RTINS10
+**What the spec requires:** `Instance#compact()` returns a recursively-compacted native snapshot (plain map/number/string), e.g. `result["score"] == 100`.
+**What the SDK does:** ably-java does not implement `compact()` (RTTS7d — typed SDKs need not). Only `compactJson()` is provided, returning a Gson `JsonObject`/`JsonElement` tree.
+**Workaround in tests:** The test calls `compactJson()` and navigates the resulting `JsonObject` (`snapshot.get("score").asInt`, etc.).
+**Tests affected (InstanceTest.kt):**
+- `RTINS10 - compact recursively compacts` (RTINS10/compact-0)
+
+---
+
+## RTLO4b4c1 — `LiveObjectUpdate.noop` flag not exposed on the public subscription event
+
+**Spec point:** RTLO4b4c1
+**What the spec requires:** When an applied operation produces a no-op `LiveObjectUpdate` (e.g. a `COUNTER_INC` of 0 that still passes the RTLO4a6 newness check), registered listeners must not be invoked — the spec frames this in terms of the internal `LiveObjectUpdate.noop` flag.
+**What the SDK does:** ably-java subscribes through the public `instance.subscribe(...)` and delivers an `InstanceSubscriptionEvent`, which exposes only `getObject()` / `getMessage()` (mapping §8). There is no public `noop` accessor on the event, and the internal `LiveObjectUpdate` diff is not surfaced. The noop is therefore observable only as *suppressed delivery*: the listener is simply not fired for the no-op operation.
+**Workaround in tests:** Send the real update first (listener fires once), then the no-op `COUNTER_INC(0)`, and assert the listener count stays at 1 — i.e. the noop is asserted via the absence of a second event rather than via a `noop` boolean.
+**Tests affected (LiveObjectSubscribeTest.kt):**
+- `RTLO4b4c1 - noop update does not trigger listener` (RTLO4b4c1/noop-no-trigger-0)
+
+## RTPO5b / RTPO6b — `get(non-string)` / `at(non-string)` failing with 40003 is not expressible
+
+**Spec points:** RTPO5b, RTPO6b
+**What the spec requires:** Calling `PathObject.get(key)` / `LiveMap.at(path)` with a non-String argument fails at runtime with `ErrorInfo` code `40003`.
+**What the SDK does:** ably-java is statically typed — `LiveMapPathObject.get(@NotNull String)` and `LiveMapPathObject.at(@NotNull String)` only accept a `String`. A non-string argument is a compile error, never a runtime failure, so there is no code path that returns a `40003` `AblyException` for this input.
+**Workaround in tests:** The case is not expressible; the test body documents the omission with an inline note and contains no executable assertion.
+**Tests affected (PathObjectTest.kt):**
+- `RTPO5b - get throws on non-string key` (RTPO5b/get-non-string-throws-0)
+- `RTPO6b - at throws for non-string input` (RTPO6b/at-non-string-throws-0)
+
+---
+
+## RTPO13 / RTPO13b5 / RTPO13c / RTPO3c1 — `compact()` not implemented; `compactJson()` used instead
+
+**Spec points:** RTPO13, RTPO13b5, RTPO13c, RTPO3c1
+**What the spec requires:** `PathObject#compact()` returns a recursively-compacted native snapshot — plain map/number/string/boolean/bytes values, nested LiveMaps recursed, nested LiveCounters resolved to numbers, raw binary preserved as bytes, and cyclic references reused as the same in-memory object (`result["prefs"]["back_ref"] IS result`). `compact()` returns `null` on resolution failure.
+**What the SDK does:** ably-java does not implement `compact()` (RTTS3f — typed SDKs need not). Only `compactJson(): JsonElement?` is provided, returning a Gson tree: binary values are base64-encoded strings (not raw bytes) and cyclic references are emitted as `{ "objectId": ... }` markers (not shared object identity). It returns `null` on resolution failure.
+**Workaround in tests:** Each test calls `compactJson()` and navigates the resulting `JsonElement`/`JsonObject`. The binary entry is asserted as its base64 string (`"AQID"`); the cycle is asserted as the `{ "objectId": "map:profile@1000" }` marker instead of object identity; the LiveCounter compacts to its numeric JSON value; the resolution-failure case asserts `compactJson() == null`.
+**Tests affected (PathObjectTest.kt):**
+- `RTPO13 - compact recursively compacts LiveMap tree` (RTPO13/compact-recursive-0) — base64 for binary.
+- `RTPO13b5 - compact handles cycles via shared reference` (RTPO13b5/compact-cycle-detection-0) — objectId marker instead of identity.
+- `RTPO13c - compact returns number for LiveCounter` (RTPO13c/compact-counter-0).
+- `RTPO3c1 - read operation returns null on resolution failure` (RTPO3c1/read-null-on-failure-0) — `compact()` sub-assertion uses `compactJson()`.
+
+---
+
+## RTLM20 / RTLM21 — set/remove wire-message-shape assertions are internal; assert observable local effect
+
+**Spec points:** RTLM20e2, RTLM20e3, RTLM20e6, RTLM20e7b, RTLM20e7c, RTLM20e7d, RTLM20e7e, RTLM20e7f, RTLM20h2, RTLM21e2, RTLM21e5
+**What the spec requires:** `set` / `remove` send an OBJECT ProtocolMessage whose captured wire form is asserted directly — `captured_messages[0].state[0].operation.action == "MAP_SET" / "MAP_REMOVE"`, `operation.objectId == "root"`, `mapSet.key`, `mapSet.value.string / .number / .boolean / .json / .bytes` (base64), `mapRemove.key`.
+**What the SDK does:** ably-java's public `LiveMapPathObject.set` / `remove` return a `CompletableFuture`; the bytes that go on the wire are internal `WireObjectMessage` objects in `ProtocolMessage.state` (`Object[]`), inaccessible through the public API (mapping §13). The public-observable consequence is that, once the operation is ACKed and echoed, it applies to the local graph.
+**Workaround in tests:** Perform the public write, then assert the equivalent observable effect via a local round-trip read after the auto-ACK echo applies (`root.get(key).as().value()` for set, `getType() == null` for remove), polling for application. The exact wire-message shape is not asserted.
+**Tests affected (LiveMapApiTest.kt):**
+- `RTLM20 - set sends MAP_SET message` (RTLM20/set-sends-map-set-0)
+- `RTLM20 - set with different value types` (RTLM20/set-value-types-0)
+- `RTLM20 - set with bytes value type` (RTLM20/set-bytes-value-0)
+- `RTLM21 - remove sends MAP_REMOVE message` (RTLM21/remove-sends-map-remove-0)
+
+---
+
+## RTLM20e7g / RTLM20h1 — value-type CREATE-message generation/ordering is internal; assert resolved object
+
+**Spec points:** RTLM20e7g1, RTLM20e7g2, RTLM20h1, RTLMV4d1, RTLMV4d2 (also RTLCV4 / RTLMV4 evaluation)
+**What the spec requires:** Setting a `LiveCounter` / `LiveMap` value type produces an OBJECT whose `state` array contains the generated `*_CREATE` ObjectMessages followed by a `MAP_SET`, in depth-first order, with the `MAP_SET`'s `mapSet.value.objectId` referencing the final CREATE's `objectId` (and `objectId` prefixes `counter:` / `map:`).
+**What the SDK does:** The evaluation of a value type into an ordered list of `*_CREATE` wire messages, nonce/objectId derivation, and the cross-referencing objectIds are all internal wire-level concerns (mapping §13) — not reachable through the public typed API. The public-observable consequence is that the new nested object is created and resolvable at the key.
+**Workaround in tests:** Perform the public write, then assert the equivalent observable effect: the new value resolves to a `LIVE_COUNTER` / `LIVE_MAP` with its initial value/entries (and, for the nested case, the nested counter and primitive resolve). The CREATE-message count, ordering, and objectId cross-references are not asserted.
+**Tests affected (LiveMapApiTest.kt):**
+- `RTLM20e7g - set with LiveCounterValueType generates COUNTER_CREATE plus MAP_SET` (RTLM20e7g/set-counter-value-type-0)
+- `RTLM20e7g - set with LiveMapValueType generates nested CREATE plus MAP_SET` (RTLM20e7g/set-map-value-type-0)
+- `RTLM20h1 - set with nested LiveMapValueType containing LiveCounterValueType` (RTLM20h1/set-nested-value-types-0)
+
+---
+
+## RTLM20 / RTLMV4c — invalid set value types (function / undefined / symbol) not expressible
+
+**Spec points:** RTLM20e1, RTLMV4c
+**What the spec requires:** A table-driven test feeds unsupported runtime values (a function, `undefined`, a symbol) into `set` and expects each to fail with `ErrorInfo` code `40013`.
+**What the SDK does:** ably-java's `LiveMapPathObject.set(String, LiveMapValue)` accepts only a `LiveMapValue`, and `LiveMapValue.of(...)` is overloaded solely for the supported types (Boolean, Binary/byte[], Number, String, JsonArray, JsonObject, LiveCounter, LiveMap). There is no overload that accepts a function / undefined / symbol, so these inputs are rejected at compile time (mapping §6) — the runtime `40013` assertion cannot be expressed.
+**Workaround in tests:** The test body is a documented no-op explaining the compile-time rejection; no runtime assertion is made.
+**Tests affected (LiveMapApiTest.kt):**
+- `RTLM20 - invalid set value types` (RTLM20/set-invalid-values-table-0)
+
+---
+
+## RTLC12e2 / RTLC12e3 / RTLC12e5 / RTLC13b — outbound COUNTER_INC wire message is internal
+
+**Spec points:** RTLC12e2, RTLC12e3, RTLC12e5, RTLC13b
+**What the spec requires:** After `increment(n)` / `decrement(n)`, inspect the published OBJECT message's wire form — `captured.state[0].operation.action == "COUNTER_INC"`, `.operation.objectId == "counter:score@1000"`, `.operation.counterInc.number == n` (and `== -15` for decrement, proving decrement negates the amount).
+**What the SDK does:** The outbound wire types (`WireObjectMessage` / `WireObjectOperation` / `WireCounterInc`) are `internal` to `:liveobjects` and not part of the public API; there is no public accessor for the message a `LiveCounterPathObject.increment` / `.decrement` publishes.
+**Workaround in tests:** The captured outbound `ProtocolMessage` is found in `mockWs.events` (`MessageFromClient` with `action == object`), and its `state[0]` wire object's `operation` / `action` / `objectId` / `counterInc.number` are read by reflection (the same reflection technique `helpers.kt` and `PublicObjectMessageTest.kt` use for internal `:liveobjects` types). Where the spec also provides an observable value outcome (decrement → `value() == 85`), that is asserted directly via the public API.
+**Tests affected (LiveCounterApiTest.kt):**
+- `RTLC12 - increment sends v6 COUNTER_INC message` (RTLC12/increment-sends-counter-inc-0)
+- `RTLC13 - decrement delegates to increment with negated amount` (RTLC13/decrement-negates-0)
+
+---
+
+## RTLC11b1 — LiveCounterUpdate diff (`update.amount`) not exposed on public event
+
+**Spec point:** RTLC11b1
+**What the spec requires:** Subscribing to a counter `instance` and incrementing it emits an event whose `message.operation.counterInc.number` (the increment amount) equals the applied value (`updates[0].message.operation.counterInc.number == 7`).
+**What the SDK does:** ably-java's public `InstanceSubscriptionEvent` carries no internal `LiveCounterUpdate` diff (no `update.amount` accessor — that is the internal RTLO4b update). It does expose the originating public `ObjectMessage` via `getMessage()`, whose `operation.counterInc.number` carries the amount.
+**Workaround in tests:** Assert `event.getMessage().operation.counterInc.number == 7.0` (and `operation.action == COUNTER_INC`) instead of an `update.amount` diff field.
+**Tests affected (LiveCounterApiTest.kt):**
+- `RTLC11 - LiveCounterUpdate emitted on increment` (RTLC11/counter-update-on-inc-0)
+
+---
+
+## RTLC12e1 — non-Number increment amounts are compile errors, not 40003 runtime failures
+
+**Spec point:** RTLC12e1
+**What the spec requires:** `increment(amount)` throws `ErrorInfo` code `40003` when `amount` is `null`, not a Number, not finite, or NaN — exercised both singly (`increment("not_a_number")`) and as a table (`null`, `NaN`, `±Infinity`, `"10"`, `true`, `[1,2]`, `{n:1}`).
+**What the SDK does:** ably-java's `LiveCounterPathObject.increment(@NotNull Number)` accepts only a non-null `Number`. The non-Number rows (`null`, String, Boolean, array, object) are rejected by the type system at compile time, so they cannot be written as runtime assertions. The numeric-but-invalid rows (`NaN`, `+Infinity`, `-Infinity`) are valid `Double` values and remain expressible runtime `40003` assertions.
+**Workaround in tests:** The non-Number cases are dropped with an inline note; the non-finite `Double` cases (`NaN`, `±Infinity`) are exercised and asserted to fail with `40003`. The dedicated single-case `increment-non-number-0` test is reduced to a documented placeholder for the same reason.
+**Tests affected (LiveCounterApiTest.kt):**
+- `RTLC12e1 - increment with non-number throws` (RTLC12e1/increment-non-number-0) — not expressible; placeholder.
+- `RTLC12e1 - Table-driven invalid increment amounts` (RTLC12e1/increment-invalid-amounts-table-0) — non-Number rows dropped; non-finite rows exercised.
+
+---
+
+## RTO15 — `RealtimeObject.publish` and its OBJECT/ACK wire-message assertions are internal, not public
+
+**Spec point:** RTO15 (RTO15e1, RTO15e2, RTO15e3, RTO15h)
+**What the spec requires:** `channel.object.publish([objectMessages])` sends an OBJECT `ProtocolMessage` whose captured wire form is asserted directly — `captured_messages[0].action == OBJECT`, `.channel == "test"`, `.state.length == 1` — and returns a `PublishResult` from the ACK whose `serials == ["serial-0"]`.
+**What the SDK does:** ably-java's `RealtimeObject` exposes no public `publish` method — `publish` / `publishAndApply` (RTO15 / RTO20) are marked `internal` in the IDL (mapping §13). The only public mutators are the typed `set` / `remove` / `increment` / `decrement` on the path/instance views, which return `CompletableFuture` (no `PublishResult`). The OBJECT `ProtocolMessage.state` entries are internal `WireObjectMessage` objects (`Object[]`), and the ACK `PublishResult` is consumed internally to drive the local apply — neither is reachable through the public API.
+**Workaround in tests:** None expressible against the public surface. The publish-and-apply *effect* (RTO20) is covered observably elsewhere in this file (e.g. `RTO20 - publishAndApply applies locally on ACK` asserts `value() == 110` after a public `increment`). The RTO15 test body is a documented no-op.
+**Tests affected (RealtimeObjectTest.kt):**
+- `RTO15 - publish sends OBJECT ProtocolMessage` (RTO15/publish-sends-object-pm-0) — not expressible; documented no-op.
+
+---
+
+## RTLCV3 / RTLMV3 — value-type internal count / entries have no public accessor
+
+**Spec points:** RTLCV3b, RTLCV3a1, RTLMV3b, RTLMV3a1
+**What the spec requires:** A constructed value type exposes its internal blueprint state for inspection — `LiveCounter.create(42).count == 42`, `LiveCounter.create().count == 0`, `LiveMap.create({...}).entries["name"] == "Alice"`.
+**What the SDK does:** ably-java's `LiveCounter` / `LiveMap` value types are opaque immutable holders: the initial count / entries are "held internally by the implementation; [they have] no public accessor" (their Javadoc). Only the static `create(...)` factory and the abstract type identity are observable; there is no `count` / `entries` getter.
+**Workaround in tests:** Assert construction succeeds and the result `is LiveCounter` / `is LiveMap` (the value-type identity). The internal `count` / `entries` sub-assertions are dropped with an inline note.
+**Tests affected (ValueTypesTest.kt):**
+- `RTLCV3 - LiveCounter create with initial count` (RTLCV3/create-with-count-0) — `vt.count == 42` omitted.
+- `RTLCV3 - LiveCounter create defaults to 0` (RTLCV3/create-default-zero-0) — `vt.count == 0` omitted.
+- `RTLMV3 - LiveMap create with entries` (RTLMV3/create-with-entries-0) — `vt.entries[...]` omitted.
+
+---
+
+## RTLCV4 / RTLMV4 — value-type `evaluate()` ObjectMessage generation is internal/wire-level
+
+**Spec points:** RTLCV4 (RTLCV4b1, RTLCV4c, RTLCV4d, RTLCV4f, RTLCV4g1–g5), RTLMV4 (RTLMV4e1, RTLMV4f, RTLMV4g, RTLMV4i, RTLMV4j1–j5, RTLMV4d1, RTLMV4d2, RTLMV4k, RTLMV4e2)
+**What the spec requires:** Calling `evaluate(vt)` on a value type returns the list of generated `ObjectMessage`s and asserts on their internal/wire form — `operation.action == "COUNTER_CREATE"/"MAP_CREATE"`, `operation.objectId` `counter:`/`map:` prefix and `@`-suffixed RTO14 derivation, `counterCreateWithObjectId`/`mapCreateWithObjectId` with a 16+-char `nonce` and a JSON `initialValue`, the retained local `counterCreate`/`mapCreate` (`count == 42`, `semantics == "LWW"`, `entries[k].data.`), depth-first ordering of nested creates with cross-referencing `entries[k].data.objectId`, and `mapCreate.entries == {}` for empty entries.
+**What the SDK does:** There is no public `evaluate` on the value types. Evaluation into an ordered list of `*_CREATE` wire messages, nonce / `initialValue` / `objectId` derivation, and the `counterCreateWithObjectId` / `mapCreateWithObjectId` wire forms are all internal/wire-level concerns (mapping §13), not reachable through the public typed API. (`PublicAPI::ObjectOperation` itself carries only the resolved `mapCreate`/`counterCreate`, never a `*WithObjectId` getter, per PAOOP1.)
+**Workaround in tests:** Only the public construction is exercised (`create(...)` returns a `LiveCounter`/`LiveMap`). The message-generation, nonce/objectId, `initialValue`, retained-create, ordering and empty-entries assertions are dropped with inline notes.
+**Tests affected (ValueTypesTest.kt):**
+- `RTLCV4 - Evaluation generates COUNTER_CREATE ObjectMessage` (RTLCV4/evaluate-generates-message-0)
+- `RTLCV4g5 - Evaluation retains local CounterCreate` (RTLCV4g5/retains-local-counter-create-0)
+- `RTLCV4 - Evaluation with count 0` (RTLCV4/evaluate-zero-count-0)
+- `RTLMV4 - Evaluation generates MAP_CREATE ObjectMessage` (RTLMV4/evaluate-generates-message-0)
+- `RTLMV4j5 - Evaluation retains local MapCreate` (RTLMV4j5/retains-local-map-create-0)
+- `RTLMV4d1, RTLMV4d2 - Nested value types produce depth-first ObjectMessages` (RTLMV4d1/nested-value-types-0)
+- `RTLMV4e2 - Empty entries produces MapCreate with empty entries` (RTLMV4e2/empty-entries-0)
+- `RTLMV4d - Entry value type mapping` (RTLMV4d/entry-value-types-0) — generated `data.` adapted to public `LiveMapValue` union inspection.
+- `RTLMV4d - Table-driven MAP_SET value type mapping` (RTLMV4d/map-set-all-types-table-0) — generated `data[field]` (incl. base64 "AQID") adapted to public `LiveMapValue` union inspection.
+
+---
+
+## RTLCV4a / RTLMV4a / RTLMV4b / RTLMV4c — wrong-typed value-type `create` args are compile errors, not runtime 40003/40013
+
+**Spec points:** RTLCV4a, RTLMV4a, RTLMV4b, RTLMV4c
+**What the spec requires:** Validation deferred to evaluation: `LiveCounter.create("not_a_number")` → 40003; `LiveMap.create(null)` → 40003; a non-String key (`{ 123: "value" }`) → 40003; an unsupported value (a function) → 40013.
+**What the SDK does:** ably-java's signatures reject all of these at compile time (mapping §6): `LiveCounter.create(@NotNull Number)` rejects a String and rejects null; `LiveMap.create(@NotNull Map)` rejects null, enforces String keys, and the `LiveMapValue` union constructs only from the supported types (Boolean, byte[], Number, String, JsonArray, JsonObject, LiveCounter, LiveMap) — an unsupported value cannot be wrapped. So none of these inputs can be written, and the runtime 40003/40013 failures are not expressible.
+**Workaround in tests:** Each test body is a documented no-op explaining the compile-time rejection; no runtime assertion is made.
+**Tests affected (ValueTypesTest.kt):**
+- `RTLCV4a - Evaluation validates count type` (RTLCV4a/evaluate-validates-count-0)
+- `RTLMV4a - Evaluation validates entries type` (RTLMV4a/evaluate-validates-entries-0)
+- `RTLMV4b - Evaluation validates key types` (RTLMV4b/evaluate-validates-keys-0)
+- `RTLMV4c - Evaluation validates value types` (RTLMV4c/evaluate-validates-values-0)
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/liveobjects/ObjectsFaultsTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/liveobjects/ObjectsFaultsTest.kt
new file mode 100644
index 000000000..b5989e5e8
--- /dev/null
+++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/liveobjects/ObjectsFaultsTest.kt
@@ -0,0 +1,316 @@
+package io.ably.lib.uts.integration.proxy.liveobjects
+
+import io.ably.lib.liveobjects.path.PathObject
+import io.ably.lib.liveobjects.value.LiveMapValue
+import io.ably.lib.realtime.AblyRealtime
+import io.ably.lib.realtime.Channel
+import io.ably.lib.realtime.ChannelState
+import io.ably.lib.realtime.ConnectionState
+import io.ably.lib.types.AblyException
+import io.ably.lib.types.ChannelMode
+import io.ably.lib.types.ChannelOptions
+import io.ably.lib.uts.infra.awaitChannelState
+import io.ably.lib.uts.infra.awaitState
+import io.ably.lib.uts.infra.integration.SandboxApp
+import io.ably.lib.uts.infra.integration.proxy.ProxyManager
+import io.ably.lib.uts.infra.integration.proxy.ProxySession
+import io.ably.lib.uts.infra.integration.proxy.connectThroughProxy
+import io.ably.lib.uts.infra.integration.proxy.wsFrameToClientRule
+import io.ably.lib.uts.infra.pollUntil
+import io.ably.lib.uts.infra.unit.TestRealtimeClient
+import kotlinx.coroutines.future.await
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withTimeout
+import org.junit.jupiter.api.AfterAll
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import java.util.UUID
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertFailsWith
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Proxy integration test against Ably Sandbox endpoint.
+ *
+ * Uses the programmable uts-proxy to inject transport-level faults while the
+ * SDK communicates with the real Ably backend. See
+ * `uts/realtime/integration/helpers/proxy.md` for proxy infrastructure details.
+ *
+ * Exercises objects sync/mutation behaviour under faults: sync interrupted by disconnect and
+ * re-synced on reconnect, mutations buffered during re-sync, server-initiated detach re-sync, and
+ * publishAndApply failing when the channel enters FAILED.
+ *
+ * Spec points: RTO5a2, RTO7, RTO8, RTO17, RTO20e. Source spec:
+ * `objects/integration/proxy/objects_faults.md`. Corresponding unit specs: `objects/unit/objects_pool.md`,
+ * `objects/unit/realtime_object.md`.
+ *
+ * Proxy tests always use JSON (the proxy can only inspect text frames), which is the
+ * `ClientOptionsBuilder` default.
+ *
+ * > **Translate-only:** `channel.object.get()` resolves only once the SDK's OBJECT_SYNC processing
+ * > + `RealtimeObject.get()` land, so these compile now and run once the LiveObjects engine is
+ * > implemented.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class ObjectsFaultsTest {
+
+ private lateinit var app: SandboxApp
+
+ @BeforeAll
+ fun setUpAll() = runBlocking {
+ ProxyManager.ensureProxy()
+ app = SandboxApp.create()
+ }
+
+ @AfterAll
+ fun tearDownAll() = runBlocking {
+ if (::app.isInitialized) app.delete()
+ }
+
+ /**
+ * @UTS objects/proxy/RTO5a2-RTO17/sync-interrupted-reconnect-0
+ */
+ @Test
+ fun `RTO5a2, RTO17 - sync interrupted by disconnect, re-syncs on reconnect`() = runTest {
+ val channelName = "objects-sync-interrupt-" + UUID.randomUUID()
+
+ // Disconnect after first OBJECT_SYNC (action 20) frame to interrupt the sync.
+ val session = ProxySession.create(
+ rules = listOf(
+ wsFrameToClientRule(action = mapOf("type" to "disconnect"), messageAction = 20, times = 1),
+ ),
+ )
+ val client = proxyClient(session)
+ try {
+ client.connect()
+ awaitState(client, ConnectionState.connected, 15.seconds)
+
+ val channel = objectChannel(client, channelName)
+
+ // First attach triggers sync; proxy disconnects mid-sync.
+ channel.attach()
+ awaitState(client, ConnectionState.disconnected, 15.seconds)
+
+ // Client auto-reconnects; re-attach triggers a fresh sync.
+ awaitState(client, ConnectionState.connected, 30.seconds)
+
+ // get() waits for SYNCED — resolves only if the re-sync completes.
+ val root = withTimeout(30.seconds) { channel.`object`.get().await() }
+
+ assertIs(root)
+ assertEquals("", root.path())
+ } finally {
+ client.close()
+ session.close()
+ }
+ }
+
+ /**
+ * @UTS objects/proxy/RTO7-RTO8/mutations-buffered-during-resync-0
+ */
+ @Test
+ fun `RTO7, RTO8 - mutations during re-sync are buffered and applied`() = runTest {
+ val channelName = "objects-buffer-resync-" + UUID.randomUUID()
+
+ // Client A: direct connection (no proxy), publishes mutations.
+ val clientA = directClient()
+ // Client B: through the proxy, will be disconnected mid-test.
+ val session = ProxySession.create(rules = emptyList())
+ val clientB = proxyClient(session)
+ try {
+ clientA.connect()
+ awaitState(clientA, ConnectionState.connected, 15.seconds)
+ val rootA = withTimeout(15.seconds) { objectChannel(clientA, channelName).`object`.get().await() }
+
+ // Set initial data
+ rootA.set("key1", LiveMapValue.of("initial")).await()
+
+ // Client B connects and syncs
+ clientB.connect()
+ awaitState(clientB, ConnectionState.connected, 15.seconds)
+ var rootB = withTimeout(15.seconds) { objectChannel(clientB, channelName).`object`.get().await() }
+ pollUntil(10.seconds) { rootB.get("key1").asString().value() == "initial" }
+
+ // Disconnect client B
+ session.triggerAction(mapOf("type" to "disconnect"))
+ awaitState(clientB, ConnectionState.disconnected, 15.seconds)
+
+ // While B is disconnected, A publishes a mutation
+ rootA.set("key1", LiveMapValue.of("updated_during_disconnect")).await()
+
+ // Client B reconnects and re-syncs; the mutation should be visible
+ awaitState(clientB, ConnectionState.connected, 30.seconds)
+ rootB = withTimeout(15.seconds) { objectChannel(clientB, channelName).`object`.get().await() }
+ pollUntil(15.seconds) { rootB.get("key1").asString().value() == "updated_during_disconnect" }
+
+ assertEquals("updated_during_disconnect", rootB.get("key1").asString().value())
+ } finally {
+ clientA.close()
+ clientB.close()
+ session.close()
+ }
+ }
+
+ /**
+ * @UTS objects/proxy/RTO17/server-detach-resync-0
+ */
+ @Test
+ fun `RTO17 - server-initiated detach triggers re-sync on re-attach`() = runTest {
+ val channelName = "objects-detach-resync-" + UUID.randomUUID()
+
+ val session = ProxySession.create(rules = emptyList())
+ val client = proxyClient(session)
+ try {
+ client.connect()
+ awaitState(client, ConnectionState.connected, 15.seconds)
+
+ val channel = objectChannel(client, channelName)
+ var root = withTimeout(15.seconds) { channel.`object`.get().await() }
+
+ // Set some data
+ root.set("before_detach", LiveMapValue.of("hello")).await()
+ assertEquals("hello", root.get("before_detach").asString().value())
+
+ // Inject a server-initiated DETACHED (action 13) for the channel.
+ session.triggerAction(
+ mapOf(
+ "type" to "inject_to_client",
+ "message" to mapOf("action" to 13, "channel" to channelName),
+ ),
+ )
+
+ // Client should auto-re-attach (RTL13a).
+ awaitChannelState(channel, ChannelState.attached, 30.seconds)
+
+ // Re-sync should restore the data.
+ root = withTimeout(15.seconds) { channel.`object`.get().await() }
+ pollUntil(15.seconds) { root.get("before_detach").asString().value() == "hello" }
+
+ assertEquals("hello", root.get("before_detach").asString().value())
+ } finally {
+ client.close()
+ session.close()
+ }
+ }
+
+ /**
+ * @UTS objects/proxy/RTO20e/publish-fails-on-channel-failed-0
+ */
+ @Test
+ fun `RTO20e - publishAndApply fails when channel enters FAILED during SYNCING`() = runTest {
+ val channelName = "objects-publish-failed-" + UUID.randomUUID()
+
+ val session = ProxySession.create(rules = emptyList())
+ val client = proxyClient(session)
+ try {
+ client.connect()
+ awaitState(client, ConnectionState.connected, 15.seconds)
+
+ val channel = objectChannel(client, channelName)
+ val root = withTimeout(15.seconds) { channel.`object`.get().await() }
+
+ // Inject a channel ERROR (action 9) to transition the channel to FAILED.
+ session.triggerAction(
+ mapOf(
+ "type" to "inject_to_client",
+ "message" to mapOf(
+ "action" to 9,
+ "channel" to channelName,
+ "error" to mapOf("statusCode" to 400, "code" to 90000, "message" to "injected error"),
+ ),
+ ),
+ )
+ awaitChannelState(channel, ChannelState.failed, 15.seconds)
+
+ // A mutation (publishAndApply internally) must fail since the channel is FAILED.
+ val error = assertFailsWith {
+ root.set("key", LiveMapValue.of("value")).await()
+ }
+ assertEquals(92008, error.errorInfo.code)
+ // The objects layer wraps the channel-level error as the AblyException cause
+ // (ablyException(errorInfo, cause) -> AblyException.fromErrorInfo(cause, errorInfo)),
+ // so the injected 90000 channel error is the cause.
+ val cause = assertIs(error.cause)
+ assertEquals(90000, cause.errorInfo.code)
+ } finally {
+ client.close()
+ session.close()
+ }
+ }
+
+ /**
+ * @UTS objects/proxy/RTO5-RTO7/publish-during-sync-echo-after-0
+ */
+ @Test
+ fun `RTO5, RTO7 - publish during sync, echo arrives after sync completes`() = runTest {
+ val channelName = "objects-publish-during-sync-" + UUID.randomUUID()
+
+ // Client A: direct, no proxy.
+ val clientA = directClient()
+ // Client B: through the proxy, with a delayed first OBJECT_SYNC to keep it SYNCING.
+ val session = ProxySession.create(
+ rules = listOf(
+ wsFrameToClientRule(action = mapOf("type" to "delay", "delayMs" to 3000), messageAction = 20, times = 1),
+ ),
+ )
+ val clientB = proxyClient(session)
+ try {
+ clientA.connect()
+ awaitState(clientA, ConnectionState.connected, 15.seconds)
+ val rootA = withTimeout(15.seconds) { objectChannel(clientA, channelName).`object`.get().await() }
+
+ // Set up initial data
+ rootA.set("existing", LiveMapValue.of("before")).await()
+
+ // Start client B — stuck in SYNCING due to the delayed OBJECT_SYNC.
+ clientB.connect()
+ awaitState(clientB, ConnectionState.connected, 15.seconds)
+ val channelB = objectChannel(clientB, channelName)
+ channelB.attach()
+
+ // While B is syncing, A publishes a mutation.
+ rootA.set("existing", LiveMapValue.of("after")).await()
+
+ // B's get() resolves once the delayed sync completes.
+ val rootB = withTimeout(30.seconds) { channelB.`object`.get().await() }
+
+ // The mutation from A should be visible (in sync data or as a buffered OBJECT).
+ pollUntil(15.seconds) { rootB.get("existing").asString().value() == "after" }
+
+ assertEquals("after", rootB.get("existing").asString().value())
+ } finally {
+ clientA.close()
+ clientB.close()
+ session.close()
+ }
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ /** A realtime client routed through the proxy (localhost hop → nonprod sandbox upstream). */
+ private fun proxyClient(session: ProxySession): AblyRealtime = TestRealtimeClient {
+ key = app.defaultKey
+ connectThroughProxy(session)
+ autoConnect = false
+ }
+
+ /** A realtime client connected straight to the nonprod sandbox (no proxy). */
+ private fun directClient(): AblyRealtime = TestRealtimeClient {
+ key = app.defaultKey
+ realtimeHost = ProxyManager.sandboxRealtimeHost
+ restHost = ProxyManager.sandboxRealtimeHost
+ autoConnect = false
+ }
+
+ /** A channel with the OBJECT_SUBSCRIBE + OBJECT_PUBLISH modes. */
+ private fun objectChannel(client: AblyRealtime, name: String): Channel =
+ client.channels.get(
+ name,
+ ChannelOptions().apply {
+ modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish)
+ },
+ )
+}
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsLifecycleTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsLifecycleTest.kt
new file mode 100644
index 000000000..12e90b6ad
--- /dev/null
+++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsLifecycleTest.kt
@@ -0,0 +1,283 @@
+package io.ably.lib.uts.integration.standard.liveobjects
+
+import io.ably.lib.liveobjects.path.PathObject
+import io.ably.lib.liveobjects.path.PathObjectListener
+import io.ably.lib.liveobjects.path.PathObjectSubscriptionEvent
+import io.ably.lib.liveobjects.value.LiveCounter
+import io.ably.lib.liveobjects.value.LiveMap
+import io.ably.lib.liveobjects.value.LiveMapValue
+import io.ably.lib.realtime.AblyRealtime
+import io.ably.lib.realtime.Channel
+import io.ably.lib.realtime.ConnectionState
+import io.ably.lib.types.ChannelMode
+import io.ably.lib.types.ChannelOptions
+import io.ably.lib.uts.infra.awaitState
+import io.ably.lib.uts.infra.integration.SandboxApp
+import io.ably.lib.uts.infra.integration.proxy.ProxyManager
+import io.ably.lib.uts.infra.pollUntil
+import io.ably.lib.uts.infra.unit.TestRealtimeClient
+import kotlinx.coroutines.future.await
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.AfterAll
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
+import java.util.Collections
+import java.util.UUID
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Direct-sandbox integration test against the Ably Sandbox
+ * (`sandbox.realtime.ably-nonprod.net`, via [ProxyManager.sandboxRealtimeHost]) — no proxy, no
+ * fault injection. Provisions one throwaway [SandboxApp] for the suite and connects real realtime
+ * clients straight to the sandbox.
+ *
+ * End-to-end LiveObjects lifecycle: connect, sync, create/mutate objects via [PathObject], and
+ * verify propagation to a second client.
+ *
+ * Spec points: RTO23, RTPO15, RTPO17. Source spec: `objects/integration/objects_lifecycle_test.md`.
+ *
+ * Each test runs once per protocol variant (JSON / msgpack) per the spec's `PROTOCOL` dimension —
+ * realised here with a `useBinaryProtocol` [ParameterizedTest] parameter.
+ *
+ * > **Translate-only:** `channel.object.get()` resolves only once the SDK's OBJECT_SYNC processing
+ * > + `RealtimeObject.get()` land, so these compile now and run once the LiveObjects engine is
+ * > implemented.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class ObjectsLifecycleTest {
+
+ private lateinit var app: SandboxApp
+
+ @BeforeAll
+ fun setUpAll() = runBlocking {
+ app = SandboxApp.create()
+ }
+
+ @AfterAll
+ fun tearDownAll() = runBlocking {
+ if (::app.isInitialized) app.delete()
+ }
+
+ /**
+ * @UTS objects/integration/RTO23-RTPO15/set-primitive-propagates-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RTO23, RTPO15 - set primitive via PathObject, second client reads it`(useBinaryProtocol: Boolean) = runTest {
+ val channelName = "objects-lifecycle-" + UUID.randomUUID()
+ val clientA = newClient(useBinaryProtocol)
+ val clientB = newClient(useBinaryProtocol)
+ try {
+ clientA.connect()
+ awaitState(clientA, ConnectionState.connected, 15.seconds)
+ clientB.connect()
+ awaitState(clientB, ConnectionState.connected, 15.seconds)
+
+ val channelA = objectChannel(clientA, channelName)
+ val channelB = objectChannel(clientB, channelName)
+
+ val rootA = channelA.`object`.get().await()
+ val rootB = channelB.`object`.get().await()
+
+ // Client A sets a value
+ rootA.set("greeting", LiveMapValue.of("hello")).await()
+
+ // Client B subscribes and waits for the update
+ val eventsB = Collections.synchronizedList(mutableListOf())
+ rootB.subscribe(PathObjectListener { event -> eventsB.add(event) })
+ pollUntil(10.seconds) { rootB.get("greeting").asString().value() == "hello" }
+
+ assertEquals("hello", rootB.get("greeting").asString().value())
+ } finally {
+ clientA.close()
+ clientB.close()
+ }
+ }
+
+ /**
+ * @UTS objects/integration/RTPO15/set-counter-value-type-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RTPO15 - set with LiveCounterValueType, second client reads counter`(useBinaryProtocol: Boolean) = runTest {
+ val channelName = "objects-counter-create-" + UUID.randomUUID()
+ val clientA = newClient(useBinaryProtocol)
+ val clientB = newClient(useBinaryProtocol)
+ try {
+ clientA.connect()
+ awaitState(clientA, ConnectionState.connected, 15.seconds)
+ clientB.connect()
+ awaitState(clientB, ConnectionState.connected, 15.seconds)
+
+ val rootA = objectChannel(clientA, channelName).`object`.get().await()
+ val rootB = objectChannel(clientB, channelName).`object`.get().await()
+
+ rootA.set("my_counter", LiveMapValue.of(LiveCounter.create(42))).await()
+ pollUntil(10.seconds) { rootB.get("my_counter").asLiveCounter().value() == 42.0 }
+
+ assertEquals(42.0, rootB.get("my_counter").asLiveCounter().value())
+ assertNotNull(rootB.get("my_counter").instance())
+ } finally {
+ clientA.close()
+ clientB.close()
+ }
+ }
+
+ /**
+ * @UTS objects/integration/RTPO17/increment-propagates-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RTPO17 - increment counter, second client sees updated value`(useBinaryProtocol: Boolean) = runTest {
+ val channelName = "objects-increment-" + UUID.randomUUID()
+ val clientA = newClient(useBinaryProtocol)
+ val clientB = newClient(useBinaryProtocol)
+ try {
+ clientA.connect()
+ awaitState(clientA, ConnectionState.connected, 15.seconds)
+ clientB.connect()
+ awaitState(clientB, ConnectionState.connected, 15.seconds)
+
+ val rootA = objectChannel(clientA, channelName).`object`.get().await()
+ val rootB = objectChannel(clientB, channelName).`object`.get().await()
+
+ // Create a counter first
+ rootA.set("hits", LiveMapValue.of(LiveCounter.create(0))).await()
+ pollUntil(10.seconds) { rootB.get("hits").asLiveCounter().value() == 0.0 }
+
+ // Increment it
+ rootA.get("hits").asLiveCounter().increment(10).await()
+ pollUntil(10.seconds) { rootB.get("hits").asLiveCounter().value() == 10.0 }
+
+ assertEquals(10.0, rootA.get("hits").asLiveCounter().value())
+ assertEquals(10.0, rootB.get("hits").asLiveCounter().value())
+ } finally {
+ clientA.close()
+ clientB.close()
+ }
+ }
+
+ /**
+ * @UTS objects/integration/RTPO15/set-map-value-type-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RTPO15 - set with LiveMapValueType, second client reads nested map`(useBinaryProtocol: Boolean) = runTest {
+ val channelName = "objects-map-create-" + UUID.randomUUID()
+ val clientA = newClient(useBinaryProtocol)
+ val clientB = newClient(useBinaryProtocol)
+ try {
+ clientA.connect()
+ awaitState(clientA, ConnectionState.connected, 15.seconds)
+ clientB.connect()
+ awaitState(clientB, ConnectionState.connected, 15.seconds)
+
+ val rootA = objectChannel(clientA, channelName).`object`.get().await()
+ val rootB = objectChannel(clientB, channelName).`object`.get().await()
+
+ rootA.set(
+ "settings",
+ LiveMapValue.of(
+ LiveMap.create(
+ mapOf(
+ "theme" to LiveMapValue.of("dark"),
+ "fontSize" to LiveMapValue.of(14),
+ ),
+ ),
+ ),
+ ).await()
+ pollUntil(10.seconds) {
+ rootB.get("settings").asLiveMap().get("theme").asString().value() == "dark"
+ }
+
+ assertEquals("dark", rootB.get("settings").asLiveMap().get("theme").asString().value())
+ assertEquals(14.0, rootB.get("settings").asLiveMap().get("fontSize").asNumber().value()?.toDouble())
+ } finally {
+ clientA.close()
+ clientB.close()
+ }
+ }
+
+ /**
+ * @UTS objects/integration/RTO23/get-returns-path-object-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RTO23 - get() waits for sync and returns PathObject`(useBinaryProtocol: Boolean) = runTest {
+ val channelName = "objects-get-root-" + UUID.randomUUID()
+ val client = newClient(useBinaryProtocol)
+ try {
+ client.connect()
+ awaitState(client, ConnectionState.connected, 15.seconds)
+
+ val root = objectChannel(client, channelName).`object`.get().await()
+
+ assertIs(root)
+ assertEquals("", root.path())
+ assertEquals(0L, root.size())
+ } finally {
+ client.close()
+ }
+ }
+
+ /**
+ * @UTS objects/integration/RTPO15/rest-provisioned-data-sync-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RTPO15 - client syncs pre-existing data provisioned via REST`(useBinaryProtocol: Boolean) = runTest {
+ val channelName = "objects-rest-provision-" + UUID.randomUUID()
+
+ // Provision data via REST before any realtime client connects (see helpers.kt).
+ // Both provisionObjectsViaRest and the realtime client below target the same nonprod
+ // sandbox host (ProxyManager.sandboxRealtimeHost), so the provisioned data is visible
+ // to the client once the SDK's OBJECT_SYNC + RealtimeObject.get() land.
+ provisionObjectsViaRest(
+ app.defaultKey,
+ channelName,
+ listOf(mapSetOp("provisioned", valueString("from_rest"), objectId = "root")),
+ )
+
+ val client = newClient(useBinaryProtocol)
+ try {
+ client.connect()
+ awaitState(client, ConnectionState.connected, 15.seconds)
+
+ val root = objectChannel(client, channelName).`object`.get().await()
+
+ assertEquals("from_rest", root.get("provisioned").asString().value())
+ } finally {
+ client.close()
+ }
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ /** A realtime client wired straight to the nonprod sandbox (no proxy). */
+ private fun newClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeClient {
+ key = app.defaultKey
+ realtimeHost = ProxyManager.sandboxRealtimeHost
+ restHost = ProxyManager.sandboxRealtimeHost
+ this.useBinaryProtocol = useBinaryProtocol
+ autoConnect = false
+ }
+
+ /** A channel with the object modes (defaults to OBJECT_SUBSCRIBE + OBJECT_PUBLISH). */
+ private fun objectChannel(client: AblyRealtime, name: String, vararg modes: ChannelMode): Channel =
+ client.channels.get(
+ name,
+ ChannelOptions().apply {
+ this.modes = if (modes.isEmpty()) {
+ arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish)
+ } else {
+ arrayOf(*modes)
+ }
+ },
+ )
+}
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsSyncTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsSyncTest.kt
new file mode 100644
index 000000000..5acb565c7
--- /dev/null
+++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsSyncTest.kt
@@ -0,0 +1,192 @@
+package io.ably.lib.uts.integration.standard.liveobjects
+
+import io.ably.lib.liveobjects.path.PathObject
+import io.ably.lib.liveobjects.value.LiveMapValue
+import io.ably.lib.realtime.AblyRealtime
+import io.ably.lib.realtime.Channel
+import io.ably.lib.realtime.ChannelState
+import io.ably.lib.realtime.ConnectionState
+import io.ably.lib.types.ChannelMode
+import io.ably.lib.types.ChannelOptions
+import io.ably.lib.uts.infra.awaitChannelState
+import io.ably.lib.uts.infra.awaitState
+import io.ably.lib.uts.infra.integration.SandboxApp
+import io.ably.lib.uts.infra.integration.proxy.ProxyManager
+import io.ably.lib.uts.infra.pollUntil
+import io.ably.lib.uts.infra.unit.TestRealtimeClient
+import kotlinx.coroutines.future.await
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.AfterAll
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
+import java.util.UUID
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Direct-sandbox integration test against the Ably Sandbox
+ * (`sandbox.realtime.ably-nonprod.net`, via [ProxyManager.sandboxRealtimeHost]) — no proxy, no
+ * fault injection. Provisions one throwaway [SandboxApp] for the suite.
+ *
+ * Verifies the objects sync sequence against the real server: attach with HAS_OBJECTS, receive
+ * OBJECT_SYNC, reach SYNCED; two-client convergence; and re-attach re-syncing the pool.
+ *
+ * Spec points: RTO4, RTO5, RTO17. Source spec: `objects/integration/objects_sync_test.md`.
+ *
+ * Each test runs once per protocol variant (JSON / msgpack) per the spec's `PROTOCOL` dimension —
+ * realised here with a `useBinaryProtocol` [ParameterizedTest] parameter.
+ *
+ * > **Translate-only:** `channel.object.get()` resolves only once the SDK's OBJECT_SYNC processing
+ * > + `RealtimeObject.get()` land, so these compile now and run once the LiveObjects engine is
+ * > implemented.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class ObjectsSyncTest {
+
+ private lateinit var app: SandboxApp
+
+ @BeforeAll
+ fun setUpAll() = runBlocking {
+ app = SandboxApp.create()
+ }
+
+ @AfterAll
+ fun tearDownAll() = runBlocking {
+ if (::app.isInitialized) app.delete()
+ }
+
+ /**
+ * @UTS objects/integration/RTO4-RTO5/attach-sync-get-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RTO4, RTO5 - attach triggers sync, get() resolves after SYNCED`(useBinaryProtocol: Boolean) = runTest {
+ val channelName = "objects-sync-" + UUID.randomUUID()
+ val client = newClient(useBinaryProtocol)
+ try {
+ client.connect()
+ awaitState(client, ConnectionState.connected, 15.seconds)
+
+ val root = objectChannel(client, channelName).`object`.get().await()
+
+ assertIs(root)
+ assertEquals("", root.path())
+ } finally {
+ client.close()
+ }
+ }
+
+ /**
+ * @UTS objects/integration/RTO5-RTO17/two-clients-sync-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RTO5, RTO17 - two clients sync same channel with pre-existing data`(useBinaryProtocol: Boolean) = runTest {
+ val channelName = "objects-two-sync-" + UUID.randomUUID()
+ val clientA = newClient(useBinaryProtocol)
+ val clientB = newClient(useBinaryProtocol)
+ try {
+ clientA.connect()
+ awaitState(clientA, ConnectionState.connected, 15.seconds)
+ clientB.connect()
+ awaitState(clientB, ConnectionState.connected, 15.seconds)
+
+ // Client A creates data
+ val rootA = objectChannel(clientA, channelName).`object`.get().await()
+ rootA.set("key1", LiveMapValue.of("value1")).await()
+
+ // Client B attaches and syncs — should see the data
+ val rootB = objectChannel(clientB, channelName).`object`.get().await()
+ pollUntil(10.seconds) { rootB.get("key1").asString().value() == "value1" }
+
+ assertEquals("value1", rootB.get("key1").asString().value())
+ } finally {
+ clientA.close()
+ clientB.close()
+ }
+ }
+
+ /**
+ * @UTS objects/integration/RTO17/reattach-resyncs-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RTO17 - re-attach re-syncs object pool`(useBinaryProtocol: Boolean) = runTest {
+ val channelName = "objects-reattach-" + UUID.randomUUID()
+ val client = newClient(useBinaryProtocol)
+ try {
+ client.connect()
+ awaitState(client, ConnectionState.connected, 15.seconds)
+
+ val channel = objectChannel(client, channelName)
+ var root = channel.`object`.get().await()
+
+ // Set some data
+ root.set("before_detach", LiveMapValue.of("hello")).await()
+ assertEquals("hello", root.get("before_detach").asString().value())
+
+ // Detach and re-attach
+ channel.detach()
+ awaitChannelState(channel, ChannelState.detached)
+ channel.attach()
+ awaitChannelState(channel, ChannelState.attached)
+
+ // Re-sync should restore data
+ root = channel.`object`.get().await()
+ pollUntil(10.seconds) { root.get("before_detach").asString().value() == "hello" }
+
+ assertEquals("hello", root.get("before_detach").asString().value())
+ } finally {
+ client.close()
+ }
+ }
+
+ /**
+ * @UTS objects/integration/RTO4/attach-subscribe-only-0
+ */
+ @ParameterizedTest(name = "useBinaryProtocol={0}")
+ @ValueSource(booleans = [false, true])
+ fun `RTO4 - attach without OBJECT_SUBSCRIBE still resolves get() with empty pool`(useBinaryProtocol: Boolean) = runTest {
+ val channelName = "objects-subscribe-only-" + UUID.randomUUID()
+ val client = newClient(useBinaryProtocol)
+ try {
+ client.connect()
+ awaitState(client, ConnectionState.connected, 15.seconds)
+
+ val root = objectChannel(client, channelName, ChannelMode.object_subscribe).`object`.get().await()
+
+ assertIs(root)
+ assertEquals(0L, root.size())
+ } finally {
+ client.close()
+ }
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ /** A realtime client wired straight to the nonprod sandbox (no proxy). */
+ private fun newClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeClient {
+ key = app.defaultKey
+ realtimeHost = ProxyManager.sandboxRealtimeHost
+ restHost = ProxyManager.sandboxRealtimeHost
+ this.useBinaryProtocol = useBinaryProtocol
+ autoConnect = false
+ }
+
+ /** A channel with the object modes (defaults to OBJECT_SUBSCRIBE + OBJECT_PUBLISH). */
+ private fun objectChannel(client: AblyRealtime, name: String, vararg modes: ChannelMode): Channel =
+ client.channels.get(
+ name,
+ ChannelOptions().apply {
+ this.modes = if (modes.isEmpty()) {
+ arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish)
+ } else {
+ arrayOf(*modes)
+ }
+ },
+ )
+}
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/helpers.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/helpers.kt
new file mode 100644
index 000000000..9d4001a2f
--- /dev/null
+++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/helpers.kt
@@ -0,0 +1,153 @@
+package io.ably.lib.uts.integration.standard.liveobjects
+
+import com.google.gson.JsonArray
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import io.ably.lib.http.HttpUtils
+import io.ably.lib.rest.AblyRest
+import io.ably.lib.types.ClientOptions
+import io.ably.lib.uts.infra.integration.proxy.ProxyManager
+
+/**
+ * LiveObjects **integration** test helpers — the ably-java translation of the UTS
+ * `objects/helpers/standard_test_pool.md` "## REST Fixture Provisioning" section (`provision_objects_via_rest`),
+ * used by integration specs that need pre-existing object state before the realtime client connects
+ * (currently `objects/integration/RTPO15`).
+ *
+ * ### Payload format: V2 (per the OpenAPI), NOT the UTS pseudocode
+ *
+ * The source of truth for the request shape is the published OpenAPI
+ * (`ably-docs/static/open-specs/liveobjects.yaml`, "Update LiveObjects REST API docs for **V2 format**",
+ * 2026-01-22), **not** the UTS pseudocode — the UTS spec helper describes a legacy/pre-V2 shape that is out
+ * of sync (see [provisionObjectsViaRest]). The V2 contract:
+ *
+ * - Endpoint: `POST /channels/{channel}/object` (**singular** `object`).
+ * - Body: a single operation object, **or** a bare JSON array of them — there is **no** `{ "messages": [...] }`
+ * wrapper.
+ * - An operation is identified by its **payload key** (`mapSet` / `mapRemove` / `mapCreate` / `counterInc` /
+ * `counterCreate`) plus a sibling target (`objectId` *or* `path`) — there is **no** `operation: "MAP_SET"`
+ * string and **no** `data` wrapper.
+ * - Values are `{ string }` / `{ number }` / `{ boolean }` / `{ bytes }`(base64) / `{ objectId }`.
+ * - `mapCreate.semantics` is the integer `0` (LWW); its `entries` wrap each value as `{ "data": }`.
+ *
+ * Compiles against `:java` only (`AblyRest` + `HttpUtils`), like the unit `helpers.kt`.
+ */
+
+// ---------------------------------------------------------------------------
+// Value builders — a V2 `PrimitiveValue` (the `value` of a mapSet / a mapCreate entry's `data`)
+// ---------------------------------------------------------------------------
+
+fun valueString(value: String, encoding: String? = null): JsonObject =
+ JsonObject().apply { addProperty("string", value); encoding?.let { addProperty("encoding", it) } }
+fun valueNumber(value: Number): JsonObject = JsonObject().apply { addProperty("number", value) }
+fun valueBoolean(value: Boolean): JsonObject = JsonObject().apply { addProperty("boolean", value) }
+fun valueBytes(base64: String, encoding: String? = null): JsonObject =
+ JsonObject().apply { addProperty("bytes", base64); encoding?.let { addProperty("encoding", it) } }
+fun valueObjectId(objectId: String): JsonObject = JsonObject().apply { addProperty("objectId", objectId) }
+
+// ---------------------------------------------------------------------------
+// Operation builders — a single V2 operation; target is `objectId` OR `path`
+// ---------------------------------------------------------------------------
+
+private fun operation(objectId: String?, path: String?, id: String?, build: JsonObject.() -> Unit): JsonObject =
+ JsonObject().apply {
+ id?.let { addProperty("id", it) } // OperationBase.id — idempotency key
+ objectId?.let { addProperty("objectId", it) }
+ path?.let { addProperty("path", it) }
+ build()
+ }
+
+/** `{ mapSet: { key, value }, objectId|path }` */
+fun mapSetOp(key: String, value: JsonObject, objectId: String? = null, path: String? = null, id: String? = null): JsonObject =
+ operation(objectId, path, id) {
+ add("mapSet", JsonObject().apply { addProperty("key", key); add("value", value) })
+ }
+
+/** `{ mapRemove: { key }, objectId|path }` */
+fun mapRemoveOp(key: String, objectId: String? = null, path: String? = null, id: String? = null): JsonObject =
+ operation(objectId, path, id) {
+ add("mapRemove", JsonObject().apply { addProperty("key", key) })
+ }
+
+/** `{ mapCreate: { semantics, entries: { k: { data: value } } }, objectId?|path? }` */
+fun mapCreateOp(
+ entries: Map = emptyMap(),
+ semantics: Int = 0, // 0 == LWW
+ objectId: String? = null,
+ path: String? = null,
+ id: String? = null,
+): JsonObject = operation(objectId, path, id) {
+ add(
+ "mapCreate",
+ JsonObject().apply {
+ addProperty("semantics", semantics)
+ add(
+ "entries",
+ JsonObject().apply {
+ entries.forEach { (k, v) -> add(k, JsonObject().apply { add("data", v) }) }
+ },
+ )
+ },
+ )
+}
+
+/** `{ counterCreate: { count }, objectId?|path? }` */
+fun counterCreateOp(count: Number, objectId: String? = null, path: String? = null, id: String? = null): JsonObject =
+ operation(objectId, path, id) {
+ add("counterCreate", JsonObject().apply { addProperty("count", count) })
+ }
+
+/** `{ counterInc: { number }, objectId|path }` (negative `number` = decrement) */
+fun counterIncOp(number: Number, objectId: String? = null, path: String? = null, id: String? = null): JsonObject =
+ operation(objectId, path, id) {
+ add("counterInc", JsonObject().apply { addProperty("number", number) })
+ }
+
+// ---------------------------------------------------------------------------
+// provision_objects_via_rest
+// ---------------------------------------------------------------------------
+
+/**
+ * `provision_objects_via_rest(api_key, channel_name, operations)` — seeds object state on a channel via the
+ * REST API before any realtime client connects.
+ *
+ * Translated to the **V2** OpenAPI contract (see file header), which diverges from the UTS pseudocode in
+ * `standard_test_pool.md` (legacy `POST …/objects` + `{ messages: [ { operation: { action, … } } ] }`). A
+ * single operation is posted as one object; multiple operations are posted as a JSON array (`BatchOperation`).
+ *
+ * @return the `objectIds` reported by the API for the created/updated objects.
+ */
+fun provisionObjectsViaRest(apiKey: String, channelName: String, operations: List): List {
+ require(operations.isNotEmpty()) { "operations must not be empty" }
+
+ val rest = AblyRest(
+ ClientOptions().apply {
+ key = apiKey
+ // Target the same nonprod sandbox host that SandboxApp/ProxyManager and the realtime
+ // clients use (sandbox.realtime.ably-nonprod.net) — NOT environment="sandbox", which
+ // resolves to the legacy prod-sandbox host sandbox-rest.ably.io (Hosts.java also forbids
+ // setting both environment and restHost). Matches standard_test_pool.md (ably/specification#497).
+ restHost = ProxyManager.sandboxRealtimeHost
+ useBinaryProtocol = false
+ },
+ )
+
+ val path = "/channels/$channelName/object" // V2: singular `object`
+ val body: JsonElement =
+ if (operations.size == 1) operations[0] else JsonArray().apply { operations.forEach { add(it) } }
+
+ val response = rest.request(
+ "POST",
+ path,
+ null,
+ HttpUtils.requestBodyFromGson(body, rest.options.useBinaryProtocol),
+ null,
+ )
+ check(response.success) {
+ "REST objects provisioning failed: HTTP ${response.statusCode} ${response.errorMessage}"
+ }
+
+ return response.items().flatMap { item ->
+ item.asJsonObject.get("objectIds")?.asJsonArray?.map { it.asString } ?: emptyList()
+ }
+}
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/private_deviations.md b/uts/src/test/kotlin/io/ably/lib/uts/private_deviations.md
new file mode 100644
index 000000000..2abf2c27b
--- /dev/null
+++ b/uts/src/test/kotlin/io/ably/lib/uts/private_deviations.md
@@ -0,0 +1,286 @@
+# Private Deviations — Objects UTS specs that cannot (yet) be translated
+
+> **Scope.** This file complements [`deviations.md`](./deviations.md). `deviations.md` records
+> *per-test* deviations inside tests that **were** translated and compile. This file records the
+> opposite: whole UTS spec files from the `objects` module that **could not be translated into the
+> `uts` module at all**, why, and what would unblock them. It is written for a human reviewer / the
+> LiveObjects implementers — not consumed by any tooling.
+
+---
+
+## 1. Status of all 15 `objects/unit` specs
+
+| # | UTS spec (`objects/unit/…`) | ably-java test class | Status | Layer it targets |
+|---|---|---|---|---|
+| 1 | `instance.md` | `InstanceTest` | ✅ Translated | Public view (`Instance`) |
+| 2 | `live_counter.md` | `LiveCounterTest` | ⛔ **Blocked** | **Internal CRDT node** |
+| 3 | `live_counter_api.md` | `LiveCounterApiTest` | ✅ Translated | Public view |
+| 4 | `live_map.md` | `LiveMapTest` | ⛔ **Blocked** | **Internal CRDT node** |
+| 5 | `live_map_api.md` | `LiveMapApiTest` | ✅ Translated | Public view |
+| 6 | `live_object_subscribe.md` | `LiveObjectSubscribeTest` | ✅ Translated | Public view |
+| 7 | `object_id.md` | `ObjectIdTest` | ⛔ **Blocked** | **Internal (object-id gen)** |
+| 8 | `objects_pool.md` | `ObjectsPoolTest` | ⛔ **Blocked** | **Internal (`ObjectsPool`)** |
+| 9 | `parent_references.md` | `ParentReferencesTest` | ⛔ **Blocked** | **Internal (parent graph)** |
+| 10 | `path_object.md` | `PathObjectTest` | ✅ Translated | Public view (`PathObject`) |
+| 11 | `path_object_mutations.md` | `PathObjectMutationsTest` | ✅ Translated | Public view |
+| 12 | `path_object_subscribe.md` | `PathObjectSubscribeTest` | ✅ Translated | Public view |
+| 13 | `public_object_message.md` | `PublicObjectMessageTest` | ✅ Translated | Public message layer |
+| 14 | `realtime_object.md` | `RealtimeObjectTest` | ✅ Translated (mixed) | Public `get()` + sync events |
+| 15 | `value_types.md` | `ValueTypesTest` | ✅ Translated (mixed) | Public `create` surface |
+
+**10 translated, 5 blocked.** The 5 blocked specs are the subject of this document.
+
+> Note: the translated specs that depend on `setupSyncedChannel` (most of the public-view tests)
+> compile today but only *run* once the SDK's `OBJECT_SYNC` processing + `RealtimeObject.get()` land.
+> That is the same missing engine described below — see [`deviations.md`](./deviations.md) and the
+> `helpers.kt` header for the per-test runtime caveat. The blocked specs below are a stronger case:
+> they cannot even be *written*.
+
+---
+
+## 2. Why these 5 specs target internals
+
+The objects spec is layered into three tiers (see the skill's `objects-mapping.md`):
+
+1. **Creation value types** — the immutable `LiveMap` / `LiveCounter` blueprints you pass *into* `set`.
+2. **Public read/write view** — `PathObject` / `Instance`, what user code navigates and subscribes on.
+3. **Internal CRDT graph** — the live conflict-free replicated nodes, the object pool, object-id
+ generation and the parent-reference graph. This is the convergence engine.
+
+The 10 translated specs live in tiers 1–2. The 5 blocked specs **are** tier 3. They have to assert on
+internal state because the behaviour they pin down — last-write-wins arbitration by site-serial,
+idempotent re-application, tombstones, create-op merging, garbage collection, object-id derivation —
+is **not observable through the public API**. You cannot verify "the second of two concurrent ops
+loses by site-serial" with `get()`/`value()`; you have to reach the node's `siteTimeserials` and call
+`applyOperation` directly. So the spec is correct to test internals — that is where the hard logic is.
+
+---
+
+## 3. The two blockers (in order of severity)
+
+### Blocker A — the internal implementation does not exist yet *(primary)*
+
+`:liveobjects` currently implements the **view** layer only. A symbol search of
+`liveobjects/src/main/kotlin/io/ably/lib/liveobjects` confirms the CRDT engine these specs assert on
+is absent:
+
+| Symbol required by the blocked specs | Found in `:liveobjects`? |
+|---|---|
+| `ObjectsPool` (the live object pool) | ❌ 0 references |
+| `generateObjectId` / object-id derivation (`RTO14`) | ❌ 0 references |
+| `applyOperation(...)` (apply op to a live node) | ❌ 0 references |
+| `replaceData(...)` | ❌ 0 references |
+| `createOperationIsMerged` | ❌ 0 references |
+| parent-reference graph (`parentRef…`) | ❌ 0 references |
+| pool `syncState` | ❌ 0 references |
+| `siteTimeserials` | ⚠️ only on the **wire DTO** (`WireObjectState` / `WireObjectsMapEntry`), not on a live CRDT node |
+
+What *does* exist: `DefaultPathObject`, `DefaultInstance`, the typed `Default*PathObject` /
+`Default*Instance` views, the `value/` creation types, and the `message/` + `serialization/` wire layer.
+There is **no live `InternalLiveMap` / `InternalLiveCounter` node, no `ObjectsPool`, and no
+operation-application engine.**
+
+**Consequence:** even with perfect cross-module visibility there is nothing to instantiate or assert
+against. These tests cannot be authored until the engine is implemented.
+
+### Blocker B — Kotlin `internal` is not visible across the module boundary *(secondary, applies once A is done)*
+
+When the engine *is* implemented it will (by the codebase's convention, and because `:liveobjects`
+uses `explicitApi()`) be declared `internal` — exactly like the existing `Default*` classes
+(`internal class DefaultLiveMap`, `internal class DefaultPathObject`, …).
+
+Kotlin's `internal` is scoped to a **module** = one compilation unit = one Gradle source set's compile
+task. The `:uts` test source set is a *different* module from `:liveobjects`'s `main`. The Kotlin
+compiler enforces `internal` across that boundary **regardless of dependency classpath scope**. So
+`:uts` test code cannot name those declarations at compile time.
+
+This is why the existing helper `buildPublicObjectMessage` (in `helpers.kt`) reaches the internal
+wire/message classes by **reflection** (`Class.forName(...)`), enabled by the current
+`testRuntimeOnly(project(":liveobjects"))` — runtime-only access. Reflection works for a handful of
+constructor/field hops but is the wrong tool for whole-CRDT-state assertions (no type safety, brittle,
+unreadable).
+
+---
+
+## 4. Per-spec detail
+
+| Spec | What it asserts on | Required internal symbols | Blocked by |
+|---|---|---|---|
+| **2 `live_counter.md`** | internal counter node state after applying ops | `InternalLiveCounter` (`.data`, `.siteTimeserials`, `.createOperationIsMerged`, `applyOperation`, `replaceData`) | A + B |
+| **4 `live_map.md`** | internal map node state after applying ops | `InternalLiveMap` (`.data`, `.siteTimeserials`, `.isTombstone`, `applyOperation`, `replaceData`) | A + B |
+| **7 `object_id.md`** | object-id generation & parsing | `generateObjectId` / object-id type (`RTO14`), `*WithObjectId` derivation | A + B |
+| **8 `objects_pool.md`** | the object pool and its sync lifecycle | `ObjectsPool`, `.syncState`, pool entry add/get/clear | A + B |
+| **9 `parent_references.md`** | the reverse parent-reference graph | parent-reference tracking on the pool/nodes | A + B |
+
+> Specs 2 and 4 have public counterparts (`live_counter_api.md` / `live_map_api.md`, both translated)
+> that cover the *outcome* of these operations through the public API. Specs 7–9 have **no** public
+> counterpart — they are purely internal and have no representation in tiers 1–2.
+
+---
+
+## 5. Solution options
+
+The ask was to make all of `liveobjects/src/main/kotlin/io/ably/lib/liveobjects` visible to `:uts`,
+probably by changing `testRuntimeOnly(project(":liveobjects"))` to `testImplementation(...)`. Here is
+the accurate picture, as lead dev.
+
+### 5.1 Why the `testRuntimeOnly` → `testImplementation` swap alone is *not* sufficient
+
+```kotlin
+// uts/build.gradle.kts — current
+testRuntimeOnly(project(":liveobjects")) // runtime classpath only → reflection-only access
+
+// proposed
+testImplementation(project(":liveobjects")) // adds COMPILE classpath too
+```
+
+`testImplementation` puts `:liveobjects` on the **compile** classpath, which lets `:uts` reference its
+**public** API directly (and lets the reflection helpers drop some `Class.forName`). But it does **not**
+grant access to `internal` declarations: Kotlin enforces `internal` at the *module* boundary at
+compile time, and a dependency-scope change does not cross that boundary. The CRDT engine will be
+`internal`, so the swap by itself does not unblock these tests. It is **necessary but not sufficient.**
+
+### 5.2 The Gradle/Kotlin configs that *can* expose internals
+
+There is exactly **one** primitive that grants one Kotlin compilation access to another's `internal`
+declarations: the compiler flag **`-Xfriend-paths`**. Everything below is either that flag directly, or
+a higher-level wrapper around it.
+
+**(a) `associateWith()` — the *supported* form, but intra-project only.**
+The Kotlin Gradle plugin exposes friend-paths through the `associateWith` API on compilations:
+
+```kotlin
+kotlin.target.compilations.getByName("test")
+ .associateWith(kotlin.target.compilations.getByName("main"))
+```
+
+This is how a module's own `test` source set sees its `main` internals (the plugin wires it up
+automatically), and how you'd give a *custom* source set (e.g. `integrationTest`) the same access. It is
+stable and IDE-aware — **but only between compilations of the same Gradle project.** There is no
+supported way to `associateWith` a compilation in a *different* project (`:uts` test ↔ `:liveobjects`
+main). Source: KTIJ-7662, KT associated-compilations docs.
+
+**(b) Raw `-Xfriend-paths` across projects — works, but unstable/unsupported.**
+You can manually point `:uts`'s test-compile task at `:liveobjects`'s `main` output:
+`-Xfriend-paths=…/liveobjects/build/classes/kotlin/main`. Per the Kotlin team this flag has *"no syntax,
+no IDE support, and no guarantees of stability — a compiler implementation detail, not a language
+feature."* It also hard-couples `:uts` to `:liveobjects`'s internal compile output path. **Not
+recommended** for production build config.
+
+**(c) The future fix (not available yet): `shared internal` (KEEP-0451).**
+The Kotlin team is *not* stabilizing `-Xfriend-paths`; instead KEEP-0451 proposes a first-class
+`shared internal` visibility modifier — declarations visible to designated dependent modules but not
+the general public. When it ships this is the clean answer, but it is a proposal today, not usable.
+
+### 5.3 Cleanest technical approach — write the internal-graph tests in `:liveobjects`'s own test source set
+
+> This is the lowest-ceremony option and what the SDK already does for its own internals, but it places
+> the tests **outside `uts/unit`**. If keeping them under `uts/unit` is required, prefer §5.4(a) instead.
+
+`:liveobjects` **already has** a unit-test source set and task:
+
+```kotlin
+// liveobjects/build.gradle.kts (existing)
+tasks.register("runLiveObjectsUnitTests") {
+ filter { includeTestsMatching("io.ably.lib.liveobjects.unit.*") }
+}
+```
+
+Tests placed under `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/…` see **all** of `main`'s
+`internal` declarations automatically (the plugin sets the friend-path for a module's own tests). This
+is the standard, supported way to test internal Kotlin code.
+
+**Plan once Blocker A is resolved (engine implemented):**
+1. Author specs 2, 4, 7, 8, 9 in `:liveobjects`'s own test source set, e.g. package
+ `io.ably.lib.liveobjects.unit.uts`, mirroring the `uts` conventions (one `@Test` per spec case, a
+ `/** @UTS objects/unit/… */` KDoc tag, the `deviations.md` discipline).
+2. Specs 7–9 (`object_id`, `objects_pool`, `parent_references`) are pure logic with no network/sync —
+ they will **run immediately** there, no mock-WebSocket harness needed.
+3. Specs 2 and 4 need object state applied to a node; reuse / port the relevant `helpers.kt` builders.
+4. Keep `:uts`'s `testRuntimeOnly(project(":liveobjects"))` as-is (reflection helpers stay valid), or
+ optionally promote to `testImplementation` purely for compile-time access to the **public** API.
+
+**Trade-off:** these five tests then live outside the `uts` module the skill normally targets. That is
+acceptable and correct — they are internal-implementation tests, and the SDK already groups its own
+internal tests under `:liveobjects`. The `@UTS` id convention keeps them traceable to the spec.
+
+### 5.4 Keeping the tests under `uts/unit` — what actually works
+
+This is the stated preference, so it gets its own analysis. To assert on `:liveobjects` internals from
+test code that physically lives in `:uts`, the realistic options are:
+
+**(a) `java-test-fixtures` bridge — the recommended way to honour the `uts/unit` preference.**
+Apply the `java-test-fixtures` plugin to `:liveobjects` and put a thin **inspection/bridge** layer in
+`liveobjects/src/testFixtures/kotlin`. Fixture code *belongs to the module*, so it can touch
+`:liveobjects` internals; it then re-exposes them as a small **public** API (e.g.
+`fun applyAndSnapshot(...): PublicCounterSnapshot`). `:uts` consumes it with:
+
+```kotlin
+// uts/build.gradle.kts
+testImplementation(testFixtures(project(":liveobjects")))
+```
+
+The **assertions stay in `uts/unit`** (calling the fixture's public API) — your preference is satisfied —
+while no raw internal is leaked onto `:uts`'s classpath. Caveat: for the *fixture itself* to see Kotlin
+`internal`, its compilation must be associated with `main` (Android exposes
+`android.experimental.enableTestFixturesKotlinSupport`; for plain JVM Kotlin verify the testFixtures→main
+`associateWith` is wired — it reduces back to §5.2(a), which is supported because it is intra-project).
+Cost: you design and maintain the bridge surface.
+
+**(b) Reflection from `:uts` (status quo, no build change).**
+Current `testRuntimeOnly(project(":liveobjects"))` already lets `:uts` reach internals by reflection at
+runtime — this is what `buildPublicObjectMessage` does. Keeps tests in `uts/unit` with zero build
+changes, but: stringly-typed, no compile-time safety, brittle to refactors, and verbose for whole-CRDT
+assertions. Fine for a couple of accessors; poor for five spec files of state assertions.
+
+> **On `@VisibleForTesting`:** it does **not** change visibility. It is a documentation/lint hint that
+> records what the visibility *would* be if not for tests; the actual access is still governed by the
+> `public`/`internal` modifier. So "mark it `@VisibleForTesting`" only helps if you *also* make the
+> member `public` (e.g. `@VisibleForTesting(otherwise = PRIVATE) public fun …`). It is not, by itself, a
+> cross-module visibility mechanism.
+
+---
+
+## 6. The realistic options
+
+Only **three** approaches are real candidates for our situation. (The mechanisms in §5.2 —
+`associateWith`, raw `-Xfriend-paths`, `shared internal` — and the bare dependency-scope swap in §5.1 are
+*not* viable on their own; see the note below.)
+
+| Option | Keeps tests in `uts/unit`? | Compile-safe? | Trade-off |
+|---|---|---|---|
+| **A. `java-test-fixtures` bridge** (§5.4a) | ✅ yes | ✅ yes | Design a small public snapshot surface in `:liveobjects`'s `testFixtures`. **Best fit for the preference.** |
+| **B. Tests in `:liveobjects/src/test`** (§5.3) | ❌ no — live in `:liveobjects` | ✅ yes | Least effort; internals visible by design. The SDK already tests its own internals this way. |
+| **C. Reflection from `:uts`** (§5.4b) | ✅ yes | ❌ no | No build change, but stringly-typed and brittle — fine for a few hops, poor for 5 spec files. |
+
+**Not viable on their own:** the bare `testRuntimeOnly` → `testImplementation` swap (exposes only the
+*public* API, not `internal`); `associateWith` (supported but *intra-project* — cannot bridge `:uts` ↔
+`:liveobjects`); raw cross-project `-Xfriend-paths` (unstable/unsupported); `shared internal` /
+KEEP-0451 (future proposal, not available yet).
+
+## 7. Recommendation & sequencing
+
+1. **Now:** nothing to translate for specs 2, 4, 7, 8, 9 — the internal engine they test is unbuilt
+ (Blocker A). Leave them blocked; this document is the record.
+2. **When the LiveObjects CRDT engine (`ObjectsPool`, internal live nodes + `applyOperation`,
+ object-id generation, parent references) is implemented**, pick the visibility approach:
+ - **To honour the `uts/unit` preference (recommended):** the **`java-test-fixtures` bridge** (§5.4a) —
+ assertions stay in `uts/unit`, a small fixture in `:liveobjects` exposes the needed internal state
+ as a public snapshot. Compile-safe and supported.
+ - **If colocation isn't required:** author them in **`:liveobjects/src/test`** (§5.3) — least
+ ceremony, internals visible by design (the SDK already tests its own internals this way).
+3. **Avoid** the bare `testImplementation` swap *as the internal-access mechanism* (it only exposes the
+ public API) and the manual cross-project `-Xfriend-paths` hack (unsupported, fragile).
+
+---
+
+## 8. References
+
+- Kotlin associated compilations / `associateWith` (intra-project internal access):
+ KTIJ-7662, Kotlin Multiplatform "Configure compilations" docs.
+- `-Xfriend-paths` is an unstable compiler detail; future `shared internal` modifier: KEEP-0451
+ ("Shared Internals" proposal).
+- `@VisibleForTesting` is a lint/documentation hint and does not change visibility.
+- Gradle `java-test-fixtures`: test-fixtures code has access to the module's internal API and is
+ consumed via `testImplementation(testFixtures(project(":…")))`; Kotlin support may require
+ associating the testFixtures compilation with `main`.
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InstanceTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InstanceTest.kt
new file mode 100644
index 000000000..c0305305a
--- /dev/null
+++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InstanceTest.kt
@@ -0,0 +1,362 @@
+package io.ably.lib.uts.unit.liveobjects
+
+import io.ably.lib.liveobjects.Subscription
+import io.ably.lib.liveobjects.instance.Instance
+import io.ably.lib.liveobjects.instance.InstanceListener
+import io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent
+import io.ably.lib.liveobjects.message.ObjectOperationAction
+import io.ably.lib.liveobjects.value.LiveMapValue
+import io.ably.lib.uts.infra.pollUntil
+import kotlinx.coroutines.future.await
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Derived from UTS `objects/unit/instance.md` (RTINS1–RTINS16) — the typed `Instance` view of a resolved
+ * LiveObject / primitive.
+ *
+ * ably-java implements the typed-SDK variant (RTTS), so the spec's single polymorphic `Instance` is
+ * partitioned: `id`, `value`, `get`, `set`, `subscribe`, … live on `LiveMapInstance` /
+ * `LiveCounterInstance` / the primitive instances, reached through `as*` casts. Unlike `PathObject`, an
+ * `Instance` cast **fails fast with `IllegalStateException`** on a type mismatch (RTTS9d). Three
+ * consequences for translation, recorded in `deviations.md`:
+ * - "wrong-type write/subscribe → ErrorInfo 92007" (RTINS12d/14d/16c) surfaces instead as the `as*` cast
+ * throwing `IllegalStateException` — there is no typed view on which to even call the wrong method.
+ * - `value()` on a map / `size()` on a counter (RTINS4d/RTINS9c) are not expressible — those accessors are
+ * partitioned off the wrong-typed view.
+ * - `compact()` is not implemented (RTTS7d); `compactJson()` is the supported snapshot (RTINS10).
+ *
+ * All tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing +
+ * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only).
+ */
+class InstanceTest {
+
+ /**
+ * @UTS objects/unit/RTINS3/id-returns-objectid-0
+ */
+ @Test
+ fun `RTINS3 - id property returns objectId`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val counterInst = root.get("score").instance()
+ assertEquals("counter:score@1000", counterInst!!.asLiveCounter().id)
+
+ val mapInst = root.get("profile").instance()
+ assertEquals("map:profile@1000", mapInst!!.asLiveMap().id)
+ }
+
+ /**
+ * @UTS objects/unit/RTINS4/value-counter-0
+ */
+ @Test
+ fun `RTINS4 - value returns counter number or primitive`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val counterInst = root.get("score").instance()
+ assertEquals(100.0, counterInst!!.asLiveCounter().value())
+
+ // DEVIATION (RTINS4d): spec asserts `map_inst.value() == null`, but ably-java's typed
+ // LiveMapInstance has no value() accessor (partitioned per RTTS10) — "value() on a map" is not
+ // expressible. See deviations.md.
+ }
+
+ /**
+ * @UTS objects/unit/RTINS5/get-wraps-entry-0
+ */
+ @Test
+ fun `RTINS5 - get returns Instance wrapping entry value`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val rootInst = root.instance()!!.asLiveMap()
+
+ val nameInst = rootInst.get("name")
+ assertNotNull(nameInst) // RTINS5c: IS Instance
+ assertEquals("Alice", nameInst!!.asString().value())
+
+ val scoreInst = rootInst.get("score")
+ assertEquals("counter:score@1000", scoreInst!!.asLiveCounter().id)
+
+ val nullInst = rootInst.get("nonexistent")
+ assertNull(nullInst)
+ }
+
+ /**
+ * @UTS objects/unit/RTINS6/entries-yields-instances-0
+ */
+ @Test
+ fun `RTINS6 - entries returns key Instance pairs`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val rootInst = root.instance()!!.asLiveMap()
+
+ val entries = mutableMapOf()
+ for ((key, inst) in rootInst.entries()) {
+ entries[key] = inst
+ }
+
+ assertEquals(7, entries.size)
+ assertNotNull(entries["name"]) // IS Instance
+ assertEquals("Alice", entries["name"]!!.asString().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTINS9/size-0
+ */
+ @Test
+ fun `RTINS9 - size returns non-tombstoned count`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val rootInst = root.instance()!!.asLiveMap()
+ assertEquals(7L, rootInst.size())
+
+ // DEVIATION (RTINS9c): spec asserts `counter_inst.size() == null`, but ably-java's typed
+ // LiveCounterInstance has no size() accessor (partitioned per RTTS10) — not expressible.
+ // See deviations.md.
+ }
+
+ /**
+ * @UTS objects/unit/RTINS10/compact-0
+ */
+ @Test
+ fun `RTINS10 - compact recursively compacts`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val rootInst = root.instance()!!.asLiveMap()
+
+ // DEVIATION (RTINS10): ably-java does not implement `compact()` (RTTS7d); `compactJson()` is the
+ // supported recursively-compacted snapshot. Assertions navigate the JsonObject. See deviations.md.
+ val snapshot = rootInst.compactJson()
+
+ assertEquals("Alice", snapshot.get("name").asString)
+ assertEquals(100, snapshot.get("score").asInt)
+ assertEquals("alice@example.com", snapshot.getAsJsonObject("profile").get("email").asString)
+ }
+
+ /**
+ * @UTS objects/unit/RTINS12/set-delegates-0
+ */
+ @Test
+ fun `RTINS12 - set delegates to LiveMap set`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val rootInst = root.instance()!!.asLiveMap()
+
+ rootInst.set("name", LiveMapValue.of("Bob")).await()
+
+ assertEquals("Bob", root.get("name").asString().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTINS12d/set-non-map-throws-0
+ */
+ @Test
+ fun `RTINS12d - set on non-LiveMap throws`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val counterInst = root.get("score").instance()
+
+ // DEVIATION (RTINS12d): spec expects `set()` to fail with ErrorInfo 92007. ably-java has no `set`
+ // on a non-map typed view; the failure surfaces as the `asLiveMap()` cast throwing
+ // IllegalStateException (RTTS9d). See deviations.md.
+ assertFailsWith { counterInst!!.asLiveMap() }
+ }
+
+ /**
+ * @UTS objects/unit/RTINS13/remove-delegates-0
+ */
+ @Test
+ fun `RTINS13 - remove delegates to LiveMap remove`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val rootInst = root.instance()!!.asLiveMap()
+
+ rootInst.remove("name").await()
+
+ assertNull(root.get("name").asString().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTINS14/increment-delegates-0
+ */
+ @Test
+ fun `RTINS14 - increment delegates to LiveCounter increment`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val counterInst = root.get("score").instance()!!.asLiveCounter()
+
+ counterInst.increment(25).await()
+
+ assertEquals(125.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTINS14d/increment-non-counter-throws-0
+ */
+ @Test
+ fun `RTINS14d - increment on non-LiveCounter throws`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val mapInst = root.instance()
+
+ // DEVIATION (RTINS14d): spec expects ErrorInfo 92007; ably-java has no `increment` on a non-counter
+ // typed view, so the failure surfaces as `asLiveCounter()` throwing IllegalStateException (RTTS9d).
+ // See deviations.md.
+ assertFailsWith { mapInst!!.asLiveCounter() }
+ }
+
+ /**
+ * @UTS objects/unit/RTINS15/decrement-delegates-0
+ */
+ @Test
+ fun `RTINS15 - decrement delegates to LiveCounter decrement`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val counterInst = root.get("score").instance()!!.asLiveCounter()
+
+ counterInst.decrement(10).await()
+
+ assertEquals(90.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTINS14a/increment-default-0
+ */
+ @Test
+ fun `RTINS14a - increment defaults to 1`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val counterInst = root.get("score").instance()!!.asLiveCounter()
+
+ counterInst.increment().await()
+
+ assertEquals(101.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTINS15a/decrement-default-0
+ */
+ @Test
+ fun `RTINS15a - decrement defaults to 1`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val counterInst = root.get("score").instance()!!.asLiveCounter()
+
+ counterInst.decrement().await()
+
+ assertEquals(99.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTINS16/subscribe-receives-events-0
+ */
+ @Test
+ fun `RTINS16 - subscribe receives InstanceSubscriptionEvent`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val counterInst = root.get("score").instance()!!.asLiveCounter()
+ val events = mutableListOf()
+ val sub: Subscription = counterInst.subscribe(InstanceListener { events.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ assertNotNull(sub) // IS Subscription
+ assertEquals(1, events.size)
+ assertNotNull(events[0].getObject()) // IS Instance
+ assertEquals("counter:score@1000", events[0].getObject().asLiveCounter().id)
+ }
+
+ /**
+ * @UTS objects/unit/RTINS16c/subscribe-primitive-throws-0
+ */
+ @Test
+ fun `RTINS16c - subscribe on primitive throws`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val nameInst = root.instance()!!.asLiveMap().get("name")
+
+ // DEVIATION (RTINS16c): spec expects ErrorInfo 92007. ably-java's primitive instances expose no
+ // `subscribe`; obtaining a subscribable (map/counter) view of a primitive fails fast with
+ // IllegalStateException (RTTS9d). See deviations.md.
+ assertFailsWith { nameInst!!.asLiveMap() }
+ }
+
+ /**
+ * @UTS objects/unit/RTINS16e2/subscription-event-message-0
+ */
+ @Test
+ fun `RTINS16e2 - InstanceSubscriptionEvent contains ObjectMessage`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val rootInst = root.instance()!!.asLiveMap()
+ val events = mutableListOf()
+ rootInst.subscribe(InstanceListener { events.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "99", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ val event = events[0]
+ assertNotNull(event.getObject()) // IS Instance
+ assertEquals("root", event.getObject().asLiveMap().id)
+ val message = event.getMessage()
+ assertNotNull(message)
+ assertEquals("test", message!!.channel)
+ assertEquals(ObjectOperationAction.MAP_SET, message.operation.action)
+ assertEquals("root", message.operation.objectId)
+ assertEquals("name", message.operation.mapSet!!.key)
+ }
+
+ /**
+ * @UTS objects/unit/RTINS16f/subscribe-returns-subscription-0
+ */
+ @Test
+ fun `RTINS16f - subscribe returns Subscription for deregistration`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val counterInst = root.get("score").instance()!!.asLiveCounter()
+ val events = mutableListOf()
+ val sub = counterInst.subscribe(InstanceListener { events.add(it) })
+ sub.unsubscribe()
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))),
+ )
+
+ assertEquals(0, events.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTINS16g/subscription-follows-identity-0
+ */
+ @Test
+ fun `RTINS16g - Instance subscription follows identity not path`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val counterInst = root.get("score").instance()!!.asLiveCounter()
+ val events = mutableListOf()
+ counterInst.subscribe(InstanceListener { events.add(it) })
+
+ // Re-point root.score at a different counter, then increment the original counter by identity.
+ mockWs.sendToClient(
+ buildObjectMessage(
+ "test",
+ listOf(buildMapSet("root", "score", dataObjectId("counter:new@2000"), "99", "remote")),
+ ),
+ )
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "100", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ assertTrue(events.size >= 1)
+ assertEquals("counter:score@1000", counterInst.id)
+ }
+
+ /**
+ * @UTS objects/unit/RTINS16h/subscribe-no-side-effects-0
+ */
+ @Test
+ fun `RTINS16h - subscribe has no side effects`() = runTest {
+ val (_, channel, root, _) = setupSyncedChannel("test")
+ val counterInst = root.get("score").instance()!!.asLiveCounter()
+ val channelStateBefore = channel.state
+
+ counterInst.subscribe(InstanceListener { })
+
+ assertEquals(channelStateBefore, channel.state)
+ }
+}
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveCounterApiTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveCounterApiTest.kt
new file mode 100644
index 000000000..c5fd1b3bd
--- /dev/null
+++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveCounterApiTest.kt
@@ -0,0 +1,197 @@
+package io.ably.lib.uts.unit.liveobjects
+
+import io.ably.lib.liveobjects.instance.InstanceListener
+import io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent
+import io.ably.lib.liveobjects.message.ObjectOperationAction
+import io.ably.lib.types.AblyException
+import io.ably.lib.types.ProtocolMessage
+import io.ably.lib.uts.infra.pollUntil
+import io.ably.lib.uts.infra.unit.MockEvent
+import io.ably.lib.uts.infra.unit.MockWebSocket
+import kotlinx.coroutines.future.await
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Derived from UTS `objects/unit/live_counter_api.md` (RTLC5, RTLC11–RTLC13) — the **public** read/write
+ * surface of a LiveCounter via `PathObject` / `Instance`.
+ *
+ * ably-java implements the typed-SDK variant (RTTS): the spec's polymorphic `root.get("score")` is a base
+ * `PathObject`; counter reads/writes live on `LiveCounterPathObject`, reached via `asLiveCounter()`. So
+ * `counter.value()` → `root.get("score").asLiveCounter().value(): Double?` (assert `100.0`), and
+ * `counter.increment(n)` / `.decrement(n)` → `…asLiveCounter().increment(n)` returning
+ * `CompletableFuture` (`.await()`).
+ *
+ * Two translation notes (recorded in `deviations.md`):
+ * - The "increment sends a v6 COUNTER_INC wire message" / "decrement negates the amount" assertions
+ * (RTLC12e2/e3/e5, RTLC13b) inspect the **outbound wire `ObjectMessage`** (`captured.state[0].operation`).
+ * That wire form (`WireObjectMessage` / `WireObjectOperation`) is `internal` to `:liveobjects` and not part
+ * of the public API, so it is read by reflection off the captured `ProtocolMessage.state` (the same
+ * reflection pattern `helpers.kt` / `PublicObjectMessageTest.kt` already use). The observable public-API
+ * outcome (counter value after the await) is asserted alongside where the spec provides it.
+ * - RTLC12e1 feeds non-`Number` increment amounts and expects `40003`. ably-java's
+ * `increment(@NotNull Number)` signature rejects every one of those at compile time, so the cases are not
+ * expressible as runtime assertions — see deviations.md.
+ *
+ * All tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing +
+ * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only).
+ */
+class LiveCounterApiTest {
+
+ /**
+ * @UTS objects/unit/RTLC5/value-returns-data-0
+ */
+ @Test
+ fun `RTLC5 - value returns current counter data`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val counter = root.get("score")
+ assertEquals(100.0, counter.asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTLC12/increment-sends-counter-inc-0
+ */
+ @Test
+ fun `RTLC12 - increment sends v6 COUNTER_INC message`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+
+ root.get("score").asLiveCounter().increment(25).await()
+
+ // DEVIATION (RTLC12e2/e3/e5): the spec asserts on the outbound wire ObjectMessage
+ // (captured.state[0].operation.action/objectId/counterInc.number). The wire form
+ // (WireObjectMessage/WireObjectOperation) is internal to :liveobjects; read it by reflection off the
+ // captured ProtocolMessage.state. See deviations.md.
+ val captured = capturedObjectMessages(mockWs)
+ assertEquals(1, captured.size)
+ val op = wireOperation(captured[0].state!![0]!!)
+ assertEquals("CounterInc", wireActionName(op))
+ assertEquals("counter:score@1000", wireObjectId(op))
+ assertEquals(25.0, wireCounterIncNumber(op))
+ }
+
+ /**
+ * @UTS objects/unit/RTLC12/increment-applies-locally-0
+ */
+ @Test
+ fun `RTLC12 - increment applies locally after ACK`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.get("score").asLiveCounter().increment(50).await()
+
+ assertEquals(150.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTLC12e1/increment-non-number-0
+ */
+ @Test
+ fun `RTLC12e1 - increment with non-number throws`() = runTest {
+ setupSyncedChannel("test")
+
+ // DEVIATION (RTLC12e1): spec calls `increment("not_a_number")` and expects ErrorInfo 40003. ably-java's
+ // `LiveCounterPathObject.increment(@NotNull Number)` rejects a String at compile time, so the case is
+ // not expressible as a runtime assertion. See deviations.md.
+ }
+
+ /**
+ * @UTS objects/unit/RTLC13/decrement-negates-0
+ */
+ @Test
+ fun `RTLC13 - decrement delegates to increment with negated amount`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+
+ root.get("score").asLiveCounter().decrement(15).await()
+
+ // DEVIATION (RTLC13b): the spec asserts the outbound wire counterInc.number == -15 (decrement is an
+ // alias for increment with a negated amount). Read the internal wire form by reflection; see
+ // deviations.md. The public value outcome is asserted directly.
+ val captured = capturedObjectMessages(mockWs)
+ assertEquals(-15.0, wireCounterIncNumber(wireOperation(captured[0].state!![0]!!)))
+ assertEquals(85.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTLC11/counter-update-on-inc-0
+ */
+ @Test
+ fun `RTLC11 - LiveCounterUpdate emitted on increment`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+
+ val updates = mutableListOf()
+ val instance = root.get("score").instance()!!.asLiveCounter()
+ instance.subscribe(InstanceListener { updates.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage(
+ "test",
+ listOf(buildCounterInc("counter:score@1000", 7, "99", "remote-site")),
+ ),
+ )
+ pollUntil(5.seconds) { updates.size >= 1 }
+
+ // DEVIATION (RTLC11b1): the spec reads `updates[0].message.operation.counterInc.number == 7`. ably-java's
+ // public InstanceSubscriptionEvent carries no LiveCounterUpdate diff (no `update.amount`), but it does
+ // expose the originating public ObjectMessage via getMessage(); assert the counterInc on that. See
+ // deviations.md.
+ val message = updates[0].getMessage()!!
+ assertEquals(ObjectOperationAction.COUNTER_INC, message.operation.action)
+ assertEquals(7.0, message.operation.counterInc!!.number)
+ }
+
+ /**
+ * @UTS objects/unit/RTLC12e1/increment-invalid-amounts-table-0
+ */
+ @Test
+ fun `RTLC12e1 - Table-driven invalid increment amounts`() = runTest {
+ // DEVIATION (RTLC12e1): the table feeds null / NaN / ±Infinity / String / Boolean / array / object as
+ // the increment amount, each expecting ErrorInfo 40003. ably-java's `increment(@NotNull Number)`
+ // signature makes the non-Number rows (null, String, Boolean, array, object) compile errors, so they
+ // are not expressible. The numeric-but-invalid rows (NaN, +Infinity, -Infinity) ARE expressible as
+ // runtime assertions and are exercised below. See deviations.md.
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val counter = root.get("score").asLiveCounter()
+
+ for (invalid in listOf(Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY)) {
+ // The non-finite amounts must be rejected with 40003 (RTLC12e1).
+ val ex = assertFailsWith { counter.increment(invalid).await() }
+ assertEquals(40003, ex.errorInfo.code)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Reflective access to the outbound wire ObjectMessage (internal to :liveobjects).
+//
+// The SDK serializes a published OBJECT operation into ProtocolMessage.state as internal
+// WireObjectMessage instances. These are decoded back by the mock and recorded in mockWs.events. Their
+// operation/action/objectId/counterInc fields are internal Kotlin data-class properties — addressable by
+// their declared field names on the JVM (Kotlin `internal` is not name-mangled here), reached with
+// isAccessible since they are package-private/internal. Mirrors the reflection pattern in helpers.kt.
+// ---------------------------------------------------------------------------
+
+private fun capturedObjectMessages(mockWs: MockWebSocket): List =
+ mockWs.events
+ .filterIsInstance()
+ .map { it.message }
+ .filter { it.action == ProtocolMessage.Action.`object` }
+
+private fun field(target: Any, name: String): Any? =
+ target.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(target)
+
+private fun wireOperation(wireObjectMessage: Any): Any =
+ field(wireObjectMessage, "operation") ?: error("wire ObjectMessage has no operation")
+
+private fun wireActionName(wireOperation: Any): String =
+ (field(wireOperation, "action") as Enum<*>).name
+
+private fun wireObjectId(wireOperation: Any): String? =
+ field(wireOperation, "objectId") as String?
+
+private fun wireCounterIncNumber(wireOperation: Any): Double {
+ val counterInc = field(wireOperation, "counterInc") ?: error("wire operation has no counterInc")
+ return (field(counterInc, "number") as Number).toDouble()
+}
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveMapApiTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveMapApiTest.kt
new file mode 100644
index 000000000..99070c8ec
--- /dev/null
+++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveMapApiTest.kt
@@ -0,0 +1,289 @@
+package io.ably.lib.uts.unit.liveobjects
+
+import com.google.gson.JsonObject
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.value.LiveCounter
+import io.ably.lib.liveobjects.value.LiveMap
+import io.ably.lib.liveobjects.value.LiveMapValue
+import io.ably.lib.uts.infra.pollUntil
+import kotlinx.coroutines.future.await
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Derived from UTS `objects/unit/live_map_api.md` (RTLM5, RTLM10–RTLM13, RTLM20–RTLM21, RTLM24, RTLMV4,
+ * RTLCV4) — the public LiveMap read/write surface (`get` / `size` / `entries` / `keys` / `set` / `remove`).
+ *
+ * ably-java implements the typed-SDK variant (RTTS): the root from `setupSyncedChannel` is a
+ * `LiveMapPathObject`, so `get`/`set`/`remove`/`size`/`entries`/`keys` need no cast; navigated `PathObject`s
+ * are narrowed with `as*` casts before a typed read (`asString().value()`, `asNumber().value()`,
+ * `asLiveCounter().value()`, …). Write values are wrapped in the `LiveMapValue` union and
+ * `LiveMap.create` / `LiveCounter.create` value types.
+ *
+ * Several spec cases assert on the **wire form of the sent OBJECT ProtocolMessage**
+ * (`captured_messages[...].state[...].operation.action / mapSet.value.*`, the COUNTER_CREATE/MAP_CREATE
+ * ordering for value-type sets, the MAP_REMOVE wire shape) — that is the internal `WireObjectMessage`
+ * graph (objects-mapping §13), not the public API. Those are translated to the equivalent **observable
+ * public effect** (the local round-trip read after the auto-ACK echo applies), with the wire-shape
+ * sub-assertions recorded as deviations. The table-driven invalid-value case (function / undefined / symbol)
+ * is rejected at compile time by the `LiveMapValue` union, so it is not expressible (deviation, §6).
+ *
+ * All tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing +
+ * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only).
+ */
+class LiveMapApiTest {
+
+ /**
+ * @UTS objects/unit/RTLM5/get-string-value-0
+ */
+ @Test
+ fun `RTLM5 - get returns resolved value from LiveMap`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ assertEquals("Alice", root.get("name").asString().value())
+ assertEquals(30.0, root.get("age").asNumber().value()?.toDouble())
+ assertEquals(true, root.get("active").asBoolean().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTLM5/get-nonexistent-key-0
+ */
+ @Test
+ fun `RTLM5 - get returns null for non-existent key`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ // No entry at this path: getType() is null and a typed read returns null.
+ assertNull(root.get("nonexistent").getType())
+ assertNull(root.get("nonexistent").asString().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTLM5/get-objectid-reference-0
+ */
+ @Test
+ fun `RTLM5 - get resolves objectId to LiveObject`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ // score -> counter:score@1000 (value 100); spec `value() == 100` on a counter.
+ assertEquals(100.0, root.get("score").asLiveCounter().value())
+ // profile -> map:profile@1000; navigate the nested map and read the email primitive.
+ assertEquals("alice@example.com", root.get("profile").asLiveMap().get("email").asString().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTLM10/size-non-tombstoned-0
+ */
+ @Test
+ fun `RTLM10 - size returns non-tombstoned entry count`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ assertEquals(7L, root.size())
+ }
+
+ /**
+ * @UTS objects/unit/RTLM11/entries-yields-pairs-0
+ */
+ @Test
+ fun `RTLM11 - entries yields key value pairs`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val entries = mutableListOf()
+ for ((key, _) in root.entries()) {
+ entries.add(key)
+ }
+
+ assertTrue("name" in entries)
+ assertTrue("age" in entries)
+ assertTrue("active" in entries)
+ assertTrue("score" in entries)
+ assertTrue("profile" in entries)
+ assertTrue("data" in entries)
+ assertTrue("avatar" in entries)
+ assertEquals(7, entries.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTLM12/keys-0
+ */
+ @Test
+ fun `RTLM12 - keys yields only keys`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val keys = root.keys().toList()
+
+ assertEquals(7, keys.size)
+ assertTrue("name" in keys)
+ }
+
+ /**
+ * @UTS objects/unit/RTLM20/set-sends-map-set-0
+ */
+ @Test
+ fun `RTLM20 - set sends MAP_SET message`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.set("name", LiveMapValue.of("Bob")).await()
+
+ // DEVIATION (RTLM20e2/e3/e6/e7c, RTLM20h2): the spec asserts on the sent OBJECT ProtocolMessage's
+ // wire shape (`captured_messages[0].state[0].operation.action == "MAP_SET"`, `objectId == "root"`,
+ // `mapSet.key`, `mapSet.value.string`). That is the internal WireObjectMessage graph (§13), not the
+ // public API. We assert the equivalent observable effect: the MAP_SET applies locally after the ACK
+ // echo, so the typed read returns the new value. See deviations.md.
+ pollUntil(5.seconds) { root.get("name").asString().value() == "Bob" }
+ assertEquals("Bob", root.get("name").asString().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTLM20/set-value-types-0
+ */
+ @Test
+ fun `RTLM20 - set with different value types`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.set("num_key", LiveMapValue.of(42)).await()
+ root.set("bool_key", LiveMapValue.of(false)).await()
+ val nested = JsonObject().apply { addProperty("nested", true) }
+ root.set("json_key", LiveMapValue.of(nested)).await()
+
+ // DEVIATION (RTLM20e7b/d/e): the spec asserts on the sent wire `mapSet.value.number / .boolean /
+ // .json`. Those are internal WireObjectMessage fields (§13). We assert the equivalent observable
+ // effect: each value round-trips through the local graph as the matching typed value.
+ pollUntil(5.seconds) { root.get("json_key").getType() == ValueType.JSON_OBJECT }
+ assertEquals(42.0, root.get("num_key").asNumber().value()?.toDouble())
+ assertEquals(false, root.get("bool_key").asBoolean().value())
+ assertEquals(nested, root.get("json_key").asJsonObject().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTLM20/set-bytes-value-0
+ */
+ @Test
+ fun `RTLM20 - set with bytes value type`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val bytes = byteArrayOf(1, 2, 3)
+ root.set("binary_data", LiveMapValue.of(bytes)).await()
+
+ // DEVIATION (RTLM20e7f): the spec asserts the sent wire `mapSet.value.bytes == "AQID"` (base64).
+ // That is the internal WireObjectMessage encoding (§13). We assert the equivalent observable effect:
+ // the binary value round-trips through the local graph byte-for-byte.
+ pollUntil(5.seconds) { root.get("binary_data").getType() == ValueType.BINARY }
+ assertEquals(bytes.toList(), root.get("binary_data").asBinary().value()?.toList())
+ }
+
+ /**
+ * @UTS objects/unit/RTLM20e7g/set-counter-value-type-0
+ */
+ @Test
+ fun `RTLM20e7g - set with LiveCounterValueType generates COUNTER_CREATE plus MAP_SET`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.set("new_counter", LiveMapValue.of(LiveCounter.create(50))).await()
+
+ // DEVIATION (RTLM20e7g1/e7g2, RTLM20h1): the spec asserts the sent OBJECT carries two wire messages,
+ // a COUNTER_CREATE (objectId starts with "counter:") followed by a MAP_SET whose value.objectId
+ // references it. That ordered wire-message generation (RTLCV4) is internal (§13). We assert the
+ // equivalent observable effect: a new LiveCounter is created and reachable at the key with its
+ // initial value.
+ pollUntil(5.seconds) { root.get("new_counter").getType() == ValueType.LIVE_COUNTER }
+ assertEquals(ValueType.LIVE_COUNTER, root.get("new_counter").getType())
+ assertEquals(50.0, root.get("new_counter").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTLM20e7g/set-map-value-type-0
+ */
+ @Test
+ fun `RTLM20e7g - set with LiveMapValueType generates nested CREATE plus MAP_SET`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.set("nested_map", LiveMapValue.of(LiveMap.create(mapOf("key1" to LiveMapValue.of("value1"))))).await()
+
+ // DEVIATION (RTLM20e7g1/e7g2, RTLM20h1): the spec asserts the sent OBJECT carries an ordered list of
+ // wire messages (MAP_CREATE with objectId starting "map:" followed by MAP_SET referencing it). That
+ // wire-message generation (RTLMV4) is internal (§13). We assert the equivalent observable effect: a
+ // new LiveMap is created at the key with its initial entry.
+ pollUntil(5.seconds) { root.get("nested_map").getType() == ValueType.LIVE_MAP }
+ assertEquals(ValueType.LIVE_MAP, root.get("nested_map").getType())
+ assertEquals("value1", root.get("nested_map").asLiveMap().get("key1").asString().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTLM20h1/set-nested-value-types-0
+ */
+ @Test
+ fun `RTLM20h1 - set with nested LiveMapValueType containing LiveCounterValueType`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.set(
+ "stats",
+ LiveMapValue.of(
+ LiveMap.create(
+ mapOf(
+ "count" to LiveMapValue.of(LiveCounter.create(0)),
+ "label" to LiveMapValue.of("test"),
+ ),
+ ),
+ ),
+ ).await()
+
+ // DEVIATION (RTLM20h1, RTLMV4d1/d2): the spec asserts the sent OBJECT carries COUNTER_CREATE,
+ // MAP_CREATE, MAP_SET in depth-first order with cross-referencing objectIds. That ordered wire-message
+ // generation is internal (§13). We assert the equivalent observable effect: the nested map and its
+ // nested counter / primitive resolve locally.
+ pollUntil(5.seconds) { root.get("stats").getType() == ValueType.LIVE_MAP }
+ val stats = root.get("stats").asLiveMap()
+ assertEquals(0.0, stats.get("count").asLiveCounter().value())
+ assertEquals("test", stats.get("label").asString().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTLM21/remove-sends-map-remove-0
+ */
+ @Test
+ fun `RTLM21 - remove sends MAP_REMOVE message`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.remove("name").await()
+
+ // DEVIATION (RTLM21e2/e5): the spec asserts the sent wire `operation.action == "MAP_REMOVE"`,
+ // `objectId == "root"`, `mapRemove.key == "name"`. That is the internal WireObjectMessage graph
+ // (§13). We assert the equivalent observable effect: the MAP_REMOVE applies locally after the ACK
+ // echo, so the key no longer resolves.
+ pollUntil(5.seconds) { root.get("name").getType() == null }
+ assertNull(root.get("name").asString().value())
+ assertNull(root.get("name").getType())
+ }
+
+ /**
+ * @UTS objects/unit/RTLM20/set-applies-locally-0
+ */
+ @Test
+ fun `RTLM20 - set applies locally after ACK`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.set("name", LiveMapValue.of("Bob")).await()
+
+ pollUntil(5.seconds) { root.get("name").asString().value() == "Bob" }
+ assertEquals("Bob", root.get("name").asString().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTLM20/set-invalid-values-table-0
+ */
+ @Test
+ fun `RTLM20 - invalid set value types`() = runTest {
+ setupSyncedChannel("test")
+
+ // DEVIATION (RTLM20e1, RTLMV4c): the spec feeds deliberately unsupported values (a function,
+ // `undefined`, a symbol) and expects ErrorInfo 40013 at runtime. ably-java's `set` takes a
+ // `LiveMapValue`, and `LiveMapValue.of(...)` is only overloaded for the supported types
+ // (Boolean/Binary/Number/String/JsonArray/JsonObject/LiveCounter/LiveMap) — there is no way to
+ // construct a LiveMapValue from a function / undefined / symbol, so these cases are rejected at
+ // compile time and are not expressible as a runtime 40013 assertion (§6). See deviations.md.
+ }
+}
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveObjectSubscribeTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveObjectSubscribeTest.kt
new file mode 100644
index 000000000..029c496f6
--- /dev/null
+++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveObjectSubscribeTest.kt
@@ -0,0 +1,258 @@
+package io.ably.lib.uts.unit.liveobjects
+
+import io.ably.lib.liveobjects.Subscription
+import io.ably.lib.liveobjects.instance.InstanceListener
+import io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent
+import io.ably.lib.liveobjects.message.ObjectOperationAction
+import io.ably.lib.uts.infra.pollUntil
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Derived from UTS `objects/unit/live_object_subscribe.md` (RTLO4b, RTLO4b3, RTLO4b4c1, RTLO4b4c3a,
+ * RTLO4b4c3c, RTLO4b4d, RTLO4b4e, RTLO4b6, RTLO4b7) — registering a listener for LiveObject data updates.
+ *
+ * The spec subscribes through the **public** `instance.subscribe(...)` (RTINS16) and cites the *internal*
+ * `RTLO4b` `LiveObjectUpdate` diff (fields `update` / `noop` / `objectMessage` / `tombstone`). In ably-java
+ * the public event is [InstanceSubscriptionEvent], which exposes only `getObject()` and `getMessage()` — no
+ * diff / `noop` / `tombstone` accessors (mapping §8). So:
+ * - "listener fired N times" and "returns a Subscription" translate directly (`events.size`, `Subscription`).
+ * - The noop case (RTLO4b4c1) is observed only as *suppressed delivery* (no event), since there is no public
+ * `update.noop` flag to assert — adapted, recorded in deviations.md.
+ * - The tombstone diff flag (RTLO4b4c3c / RTLO4b4e) is observed through `message.operation.action ==
+ * OBJECT_DELETE` (the spec itself prescribes this public-API proxy), not a `tombstone` boolean.
+ *
+ * All tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing +
+ * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only).
+ */
+class LiveObjectSubscribeTest {
+
+ /**
+ * @UTS objects/unit/RTLO4b/subscribe-receives-updates-0
+ */
+ @Test
+ fun `RTLO4b - subscribe registers listener for data updates`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val updates = mutableListOf()
+ val instance = root.get("score").instance()!!.asLiveCounter()
+ val sub: Subscription = instance.subscribe(InstanceListener { updates.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))),
+ )
+ pollUntil(5.seconds) { updates.size >= 1 }
+
+ assertNotNull(sub) // IS Subscription
+ assertEquals(1, updates.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTLO4b7/subscribe-returns-subscription-0
+ */
+ @Test
+ fun `RTLO4b7 - subscribe returns Subscription with unsubscribe method`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val instance = root.get("score").instance()!!.asLiveCounter()
+
+ val sub: Subscription = instance.subscribe(InstanceListener { })
+
+ assertNotNull(sub) // IS Subscription
+ // `sub.unsubscribe IS Function` -> the Subscription exposes a callable unsubscribe(); calling it is a no-op.
+ sub.unsubscribe()
+ }
+
+ /**
+ * @UTS objects/unit/RTLO4b7/subscription-unsubscribe-stops-delivery-0
+ */
+ @Test
+ fun `RTLO4b7 - Subscription unsubscribe stops delivery`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val updates = mutableListOf()
+ val instance = root.get("score").instance()!!.asLiveCounter()
+ val sub = instance.subscribe(InstanceListener { updates.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "01", "remote"))),
+ )
+ pollUntil(5.seconds) { updates.size >= 1 }
+
+ sub.unsubscribe()
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "02", "remote"))),
+ )
+
+ assertEquals(1, updates.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTLO4b7/subscription-unsubscribe-idempotent-0
+ */
+ @Test
+ fun `RTLO4b7 - Subscription unsubscribe is idempotent`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val instance = root.get("score").instance()!!.asLiveCounter()
+ val sub = instance.subscribe(InstanceListener { })
+
+ // No error thrown — both calls complete without error.
+ sub.unsubscribe()
+ sub.unsubscribe()
+ }
+
+ /**
+ * @UTS objects/unit/RTLO4b4c1/noop-no-trigger-0
+ */
+ @Test
+ fun `RTLO4b4c1 - noop update does not trigger listener`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val updates = mutableListOf()
+ val instance = root.get("score").instance()!!.asLiveCounter()
+ instance.subscribe(InstanceListener { updates.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "01", "remote"))),
+ )
+ pollUntil(5.seconds) { updates.size >= 1 }
+
+ // Serial "02" passes the newness check (RTLO4a6); the zero increment is the noop.
+ // DEVIATION (RTLO4b4c1): the spec asserts on the internal `LiveObjectUpdate.noop` flag. The public
+ // InstanceSubscriptionEvent has no `noop` accessor (mapping §8), so the noop is observed only as
+ // suppressed delivery — the listener is not fired a second time. See deviations.md.
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 0, "02", "remote"))),
+ )
+
+ assertEquals(1, updates.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTLO4b6/subscribe-no-side-effects-0
+ */
+ @Test
+ fun `RTLO4b6 - subscribe has no side effects`() = runTest {
+ val (_, channel, root, _) = setupSyncedChannel("test")
+ val stateBefore = channel.state
+ val instance = root.get("score").instance()!!.asLiveCounter()
+
+ instance.subscribe(InstanceListener { })
+
+ assertEquals(stateBefore, channel.state)
+ }
+
+ /**
+ * @UTS objects/unit/RTLO4b/subscribe-map-update-0
+ */
+ @Test
+ fun `RTLO4b - subscribe on LiveMap receives update`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val updates = mutableListOf()
+ val instance = root.instance()!!.asLiveMap()
+ instance.subscribe(InstanceListener { updates.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "99", "remote"))),
+ )
+ pollUntil(5.seconds) { updates.size >= 1 }
+
+ assertEquals(1, updates.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTLO4b4c3c/tombstone-deregisters-listeners-0
+ */
+ @Test
+ fun `RTLO4b4c3c - tombstone update deregisters all Instance subscribe listeners`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val updatesA = mutableListOf()
+ val updatesB = mutableListOf()
+ val instance = root.get("score").instance()!!.asLiveCounter()
+ instance.subscribe(InstanceListener { updatesA.add(it) })
+ instance.subscribe(InstanceListener { updatesB.add(it) })
+
+ // Send an OBJECT_DELETE which causes a tombstone.
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildObjectDelete("counter:score@1000", "50", "remote"))),
+ )
+ pollUntil(5.seconds) { updatesA.size >= 1 }
+
+ // Both listeners should have received the tombstone update. The tombstone is identified by the
+ // OBJECT_DELETE action (spec-prescribed public-API proxy for the internal `tombstone` flag).
+ assertEquals(1, updatesA.size)
+ assertEquals(ObjectOperationAction.OBJECT_DELETE, updatesA[0].getMessage()!!.operation.action)
+ assertEquals(1, updatesB.size)
+ assertEquals(ObjectOperationAction.OBJECT_DELETE, updatesB[0].getMessage()!!.operation.action)
+
+ // Send another update — listeners should have been deregistered by the tombstone.
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 3, "51", "remote"))),
+ )
+
+ assertEquals(1, updatesA.size)
+ assertEquals(1, updatesB.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTLO4b4d/update-has-object-message-0
+ */
+ @Test
+ fun `RTLO4b4d - InstanceSubscriptionEvent message is populated from source ObjectMessage`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val updates = mutableListOf()
+ val instance = root.get("score").instance()!!.asLiveCounter()
+ instance.subscribe(InstanceListener { updates.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))),
+ )
+ pollUntil(5.seconds) { updates.size >= 1 }
+
+ assertEquals(1, updates.size)
+ val message = updates[0].getMessage()
+ assertNotNull(message)
+ assertEquals("99", message!!.serial)
+ assertEquals("remote", message.siteCode)
+ assertEquals(ObjectOperationAction.COUNTER_INC, message.operation.action)
+ assertEquals("counter:score@1000", message.operation.objectId)
+ }
+
+ /**
+ * @UTS objects/unit/RTLO4b4e/tombstone-flag-true-0
+ */
+ @Test
+ fun `RTLO4b4e - tombstone update identified by OBJECT_DELETE action`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val updates = mutableListOf()
+ val instance = root.get("score").instance()!!.asLiveCounter()
+ instance.subscribe(InstanceListener { updates.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildObjectDelete("counter:score@1000", "50", "remote"))),
+ )
+ pollUntil(5.seconds) { updates.size >= 1 }
+
+ assertEquals(1, updates.size)
+ assertEquals(ObjectOperationAction.OBJECT_DELETE, updates[0].getMessage()!!.operation.action)
+ }
+
+ /**
+ * @UTS objects/unit/RTLO4b4e/tombstone-flag-false-0
+ */
+ @Test
+ fun `RTLO4b4e - normal update carries non-tombstone action`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val updates = mutableListOf()
+ val instance = root.get("score").instance()!!.asLiveCounter()
+ instance.subscribe(InstanceListener { updates.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))),
+ )
+ pollUntil(5.seconds) { updates.size >= 1 }
+
+ assertEquals(1, updates.size)
+ assertEquals(ObjectOperationAction.COUNTER_INC, updates[0].getMessage()!!.operation.action)
+ }
+}
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectMutationsTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectMutationsTest.kt
new file mode 100644
index 000000000..351e1c94d
--- /dev/null
+++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectMutationsTest.kt
@@ -0,0 +1,200 @@
+package io.ably.lib.uts.unit.liveobjects
+
+import io.ably.lib.liveobjects.value.LiveMapValue
+import io.ably.lib.types.AblyException
+import kotlinx.coroutines.future.await
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNull
+
+/**
+ * Derived from UTS `objects/unit/path_object_mutations.md` (RTPO15–RTPO18, RTPO3c2) — write operations
+ * through the typed `PathObject` view.
+ *
+ * ably-java implements the typed-SDK variant (RTTS): the spec's single polymorphic `PathObject` partitions
+ * `set`/`remove` onto `LiveMapPathObject` (via `asLiveMap()`) and `increment`/`decrement` onto
+ * `LiveCounterPathObject` (via `asLiveCounter()`). The root from `setupSyncedChannel` is already a
+ * `LiveMapPathObject`, so `root.set(...)`/`root.remove(...)` need no cast; deeper navigated nodes do.
+ *
+ * Wrong-type write cases (RTPO15d/16d/17d/18d) and unresolvable-path cases (RTPO3c2) are fully expressible:
+ * the `as*` cast never throws (RTTS5d), so we cast to the view whose write method we need, then assert the
+ * **operation** itself throws `AblyException` with the spec's error code (92007 wrong type, 92005
+ * unresolvable path). No deviations.
+ *
+ * All tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing +
+ * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only).
+ */
+class PathObjectMutationsTest {
+
+ /**
+ * @UTS objects/unit/RTPO15/set-delegates-to-map-0
+ */
+ @Test
+ fun `RTPO15 - set delegates to LiveMap set`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.set("name", LiveMapValue.of("Bob")).await()
+
+ assertEquals("Bob", root.get("name").asString().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO15/set-nested-path-0
+ */
+ @Test
+ fun `RTPO15 - set on nested path`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.get("profile").asLiveMap().set("email", LiveMapValue.of("bob@example.com")).await()
+
+ assertEquals(
+ "bob@example.com",
+ root.get("profile").asLiveMap().get("email").asString().value(),
+ )
+ }
+
+ /**
+ * @UTS objects/unit/RTPO15d/set-non-map-throws-0
+ */
+ @Test
+ fun `RTPO15d - set on non-LiveMap throws 92007`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ // The cast never throws (RTTS5d); the MAP_SET operation on a counter fails with 92007.
+ val ex = assertFailsWith {
+ root.get("score").asLiveMap().set("key", LiveMapValue.of("value")).await()
+ }
+ assertEquals(92007, ex.errorInfo.code)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO16/remove-delegates-to-map-0
+ */
+ @Test
+ fun `RTPO16 - remove delegates to LiveMap remove`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.remove("name").await()
+
+ assertNull(root.get("name").asString().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO16d/remove-non-map-throws-0
+ */
+ @Test
+ fun `RTPO16d - remove on non-LiveMap throws 92007`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val ex = assertFailsWith {
+ root.get("score").asLiveMap().remove("key").await()
+ }
+ assertEquals(92007, ex.errorInfo.code)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO17/increment-delegates-to-counter-0
+ */
+ @Test
+ fun `RTPO17 - increment delegates to LiveCounter increment`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.get("score").asLiveCounter().increment(25).await()
+
+ assertEquals(125.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO17/increment-default-amount-0
+ */
+ @Test
+ fun `RTPO17 - increment defaults to 1`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.get("score").asLiveCounter().increment().await()
+
+ assertEquals(101.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO17d/increment-non-counter-throws-0
+ */
+ @Test
+ fun `RTPO17d - increment on non-LiveCounter throws 92007`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ // increment on the root map: cast never throws (RTTS5d); the COUNTER_INC operation fails with 92007.
+ val ex = assertFailsWith {
+ root.asLiveCounter().increment(5).await()
+ }
+ assertEquals(92007, ex.errorInfo.code)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO18/decrement-delegates-to-counter-0
+ */
+ @Test
+ fun `RTPO18 - decrement delegates to LiveCounter decrement`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.get("score").asLiveCounter().decrement(10).await()
+
+ assertEquals(90.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO18/decrement-default-amount-0
+ */
+ @Test
+ fun `RTPO18 - decrement defaults to 1`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.get("score").asLiveCounter().decrement().await()
+
+ assertEquals(99.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO18d/decrement-non-counter-throws-0
+ */
+ @Test
+ fun `RTPO18d - decrement on non-LiveCounter throws 92007`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val ex = assertFailsWith {
+ root.asLiveCounter().decrement(5).await()
+ }
+ assertEquals(92007, ex.errorInfo.code)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO3c2/set-unresolvable-throws-0
+ */
+ @Test
+ fun `RTPO3c2 - set on unresolvable path throws 92005`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val ex = assertFailsWith {
+ root.get("nonexistent").asLiveMap().get("deep").asLiveMap()
+ .set("key", LiveMapValue.of("value")).await()
+ }
+ assertEquals(92005, ex.errorInfo.code)
+ assertEquals(400, ex.errorInfo.statusCode)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO3c2/increment-unresolvable-throws-0
+ */
+ @Test
+ fun `RTPO3c2 - increment on unresolvable path throws 92005`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val ex = assertFailsWith {
+ root.get("nonexistent").asLiveCounter().increment(5).await()
+ }
+ assertEquals(92005, ex.errorInfo.code)
+ assertEquals(400, ex.errorInfo.statusCode)
+ }
+}
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectSubscribeTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectSubscribeTest.kt
new file mode 100644
index 000000000..0d47ba1ac
--- /dev/null
+++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectSubscribeTest.kt
@@ -0,0 +1,543 @@
+package io.ably.lib.uts.unit.liveobjects
+
+import io.ably.lib.liveobjects.Subscription
+import io.ably.lib.liveobjects.message.ObjectOperationAction
+import io.ably.lib.liveobjects.path.PathObjectListener
+import io.ably.lib.liveobjects.path.PathObjectSubscriptionEvent
+import io.ably.lib.liveobjects.path.PathObjectSubscriptionOptions
+import io.ably.lib.realtime.ChannelState
+import io.ably.lib.types.AblyException
+import io.ably.lib.types.ProtocolMessage
+import io.ably.lib.uts.infra.awaitChannelState
+import io.ably.lib.uts.infra.pollUntil
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Derived from UTS `objects/unit/path_object_subscribe.md` (RTPO19, RTO24, RTO25) — path-based
+ * subscriptions on the typed `PathObject`.
+ *
+ * Translation rules (mapping §8): `pathObj.subscribe(closure)` becomes
+ * `pathObj.subscribe(PathObjectListener { event -> … })` returning a `Subscription` synchronously;
+ * `{ depth: n }` becomes `PathObjectSubscriptionOptions(n)` (non-positive depth throws AblyException
+ * 400/40003, RTPO19c1a); `event.object` / `event.message` become `event.getObject()` /
+ * `event.getMessage()`. Counter `value()` is `Double` (assert `107.0`). The DETACHED-precondition case
+ * (RTPO19b) drives the channel to DETACHED via a server-sent DETACHED protocol message over the existing
+ * mock (the shared helper's mock does not respond to DETACH), then asserts the synchronous failure.
+ *
+ * All tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing +
+ * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only).
+ */
+class PathObjectSubscribeTest {
+
+ /**
+ * @UTS objects/unit/RTPO19/subscribe-receives-events-0
+ */
+ @Test
+ fun `RTPO19 - subscribe returns Subscription and receives events`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ val sub: Subscription = root.get("score").subscribe(PathObjectListener { events.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ assertNotNull(sub) // IS Subscription
+ assertEquals(1, events.size)
+ assertNotNull(events[0].getObject()) // IS PathObject
+ assertEquals("score", events[0].getObject().path())
+ val message = events[0].getMessage()
+ assertNotNull(message)
+ assertEquals("99", message!!.serial)
+ assertEquals("remote", message.siteCode)
+ assertNotNull(message.operation)
+ assertEquals(ObjectOperationAction.COUNTER_INC, message.operation.action)
+ assertEquals("test", message.channel)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO19b/subscribe-precondition-detached-0
+ */
+ @Test
+ fun `RTPO19b - subscribe on DETACHED channel throws 90001`() = runTest {
+ val (_, channel, root, mockWs) = setupSyncedChannel("test")
+
+ // Drive the channel to DETACHED. The shared helper's mock does not respond to DETACH, so the
+ // server-sent DETACHED is injected directly over the existing mock.
+ channel.detach()
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.detached).apply { this.channel = "test" },
+ )
+ awaitChannelState(channel, ChannelState.detached)
+
+ val ex = assertFailsWith { root.subscribe(PathObjectListener { }) }
+ assertEquals(90001, ex.errorInfo.code)
+ assertEquals(400, ex.errorInfo.statusCode)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO19c1a/subscribe-non-positive-depth-throws-0
+ */
+ @Test
+ fun `RTPO19c1a - subscribe with non-positive depth throws 40003`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val ex = assertFailsWith {
+ root.subscribe(PathObjectListener { }, PathObjectSubscriptionOptions(0))
+ }
+ assertEquals(40003, ex.errorInfo.code)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO19c1a/subscribe-negative-depth-throws-0
+ */
+ @Test
+ fun `RTPO19c1a - subscribe with negative depth throws 40003`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val ex = assertFailsWith {
+ root.subscribe(PathObjectListener { }, PathObjectSubscriptionOptions(-1))
+ }
+ assertEquals(40003, ex.errorInfo.code)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO19c1/subscribe-depth-1-self-only-0
+ */
+ @Test
+ fun `RTPO19c1 - subscribe with depth 1 only receives self events`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ root.subscribe(PathObjectListener { events.add(it) }, PathObjectSubscriptionOptions(1))
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "99", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "100", "remote"))),
+ )
+
+ assertEquals(1, events.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO19c1/subscribe-depth-2-children-0
+ */
+ @Test
+ fun `RTPO19c1 - subscribe with depth 2 receives self and children`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ root.subscribe(PathObjectListener { events.add(it) }, PathObjectSubscriptionOptions(2))
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "99", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "100", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 2 }
+
+ mockWs.sendToClient(
+ buildObjectMessage(
+ "test",
+ listOf(buildMapSet("map:profile@1000", "email", dataString("bob@example.com"), "101", "remote")),
+ ),
+ )
+
+ assertEquals(2, events.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO19c1/subscribe-unlimited-depth-0
+ */
+ @Test
+ fun `RTPO19c1 - subscribe with no depth receives all descendants`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ root.subscribe(PathObjectListener { events.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "99", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "100", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 2 }
+
+ mockWs.sendToClient(
+ buildObjectMessage(
+ "test",
+ listOf(buildMapSet("map:prefs@1000", "theme", dataString("light"), "101", "remote")),
+ ),
+ )
+ pollUntil(5.seconds) { events.size >= 3 }
+
+ assertTrue(events.size >= 3)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO19d/subscribe-returns-subscription-0
+ */
+ @Test
+ fun `RTPO19d - subscribe returns Subscription with unsubscribe`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ val sub = root.get("score").subscribe(PathObjectListener { events.add(it) })
+
+ assertNotNull(sub) // IS Subscription
+ sub.unsubscribe()
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))),
+ )
+
+ assertEquals(0, events.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO19e1/event-path-object-correct-0
+ */
+ @Test
+ fun `RTPO19e1 - subscribe event provides correct PathObject`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ root.subscribe(PathObjectListener { events.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ assertNotNull(events[0].getObject()) // IS PathObject
+ assertEquals("score", events[0].getObject().path())
+ assertEquals(107.0, events[0].getObject().asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO19e2/event-message-delivery-0
+ */
+ @Test
+ fun `RTPO19e2 - subscribe event delivers ObjectMessage for operations`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ root.get("score").subscribe(PathObjectListener { events.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 42, "serial-1", "site-a"))),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ val message = events[0].getMessage()
+ assertNotNull(message)
+ assertEquals("test", message!!.channel)
+ assertEquals("serial-1", message.serial)
+ assertEquals("site-a", message.siteCode)
+ assertNotNull(message.operation)
+ assertEquals(ObjectOperationAction.COUNTER_INC, message.operation.action)
+ assertEquals("counter:score@1000", message.operation.objectId)
+ assertEquals(42.0, message.operation.counterInc!!.number)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO19e2/event-message-omitted-no-operation-0
+ */
+ @Test
+ fun `RTPO19e2 - subscribe event omits message when objectMessage has no operation`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ root.subscribe(PathObjectListener { events.add(it) })
+
+ // OBJECT_SYNC that changes counter:score@1000's state without an operation field — the resulting
+ // update flows through replaceData, so the delivered event carries no ObjectMessage.
+ mockWs.sendToClient(
+ buildObjectSyncMessage(
+ "test",
+ "sync2:",
+ listOf(
+ buildObjectState(
+ "counter:score@1000",
+ mapOf("aaa" to "t:1"),
+ counter = counterState(200),
+ createOp = counterCreateOp(200),
+ ),
+ ),
+ ),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ for (event in events) {
+ assertNull(event.getMessage())
+ }
+ }
+
+ /**
+ * @UTS objects/unit/RTPO19f/subscribe-follows-path-0
+ */
+ @Test
+ fun `RTPO19f - subscribe follows path not identity`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ root.get("score").subscribe(PathObjectListener { events.add(it) })
+
+ // Replace the counter at "score" with a new counter, then increment the new counter.
+ mockWs.sendToClient(
+ buildObjectMessage(
+ "test",
+ listOf(buildMapSet("root", "score", dataObjectId("counter:new@2000"), "99", "remote")),
+ ),
+ )
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:new@2000", 10, "100", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ var foundNew = false
+ for (event in events) {
+ if (event.getObject().path() == "score") foundNew = true
+ }
+ assertTrue(foundNew)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO19g/subscribe-no-side-effects-0
+ */
+ @Test
+ fun `RTPO19g - subscribe has no side effects`() = runTest {
+ val (_, channel, root, _) = setupSyncedChannel("test")
+ val stateBefore = channel.state
+
+ root.get("score").subscribe(PathObjectListener { })
+
+ assertEquals(stateBefore, channel.state)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO19/subscribe-primitive-path-0
+ */
+ @Test
+ fun `RTPO19 - subscribe on primitive path receives change events`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ root.get("name").subscribe(PathObjectListener { events.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "99", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ assertEquals(1, events.size)
+ assertEquals("name", events[0].getObject().path())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO19/map-clear-triggers-child-events-0
+ */
+ @Test
+ fun `RTPO19 - MAP_CLEAR triggers subscription events on child paths`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ root.subscribe(PathObjectListener { events.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildMapClear("root", "99", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ assertTrue(events.size >= 1)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO19/child-events-bubble-0
+ */
+ @Test
+ fun `RTPO19 - child events bubble up to parent subscription`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ root.get("profile").subscribe(PathObjectListener { events.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage(
+ "test",
+ listOf(buildMapSet("map:profile@1000", "email", dataString("bob@example.com"), "99", "remote")),
+ ),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:nested@1000", 3, "100", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 2 }
+
+ assertTrue(events.size >= 2)
+ }
+
+ /**
+ * @UTS objects/unit/RTO24c1/depth-filtering-formula-0
+ */
+ @Test
+ fun `RTO24c1 - depth filtering formula`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ root.get("profile").subscribe(PathObjectListener { events.add(it) }, PathObjectSubscriptionOptions(2))
+
+ // Self event (profile map update).
+ mockWs.sendToClient(
+ buildObjectMessage(
+ "test",
+ listOf(buildMapSet("map:profile@1000", "email", dataString("bob@example.com"), "99", "remote")),
+ ),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ // Child event (nested counter).
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:nested@1000", 3, "100", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 2 }
+
+ // Grandchild event (prefs.theme) — should NOT be received.
+ mockWs.sendToClient(
+ buildObjectMessage(
+ "test",
+ listOf(buildMapSet("map:prefs@1000", "theme", dataString("light"), "101", "remote")),
+ ),
+ )
+
+ assertEquals(2, events.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTO24c1/prefix-mismatch-0
+ */
+ @Test
+ fun `RTO24c1 - prefix mismatch does not trigger subscription`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val profileEvents = mutableListOf()
+ root.get("profile").subscribe(PathObjectListener { profileEvents.add(it) })
+
+ // Change at "score" — "profile" is not a prefix of "score".
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))),
+ )
+
+ // Change at "name" — "profile" is not a prefix of "name".
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "100", "remote"))),
+ )
+
+ assertEquals(0, profileEvents.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTO24b2a/candidate-paths-map-keys-0
+ */
+ @Test
+ fun `RTO24b2a - candidate path construction includes map update keys`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val scoreEvents = mutableListOf()
+ val rootEvents = mutableListOf()
+ root.get("score").subscribe(PathObjectListener { scoreEvents.add(it) })
+ root.subscribe(PathObjectListener { rootEvents.add(it) })
+
+ // MAP_SET on root with key "score" — candidates [] (root) and ["score"]; both subscriptions fire.
+ mockWs.sendToClient(
+ buildObjectMessage(
+ "test",
+ listOf(buildMapSet("root", "score", dataObjectId("counter:new@2000"), "99", "remote")),
+ ),
+ )
+ pollUntil(5.seconds) { scoreEvents.size >= 1 }
+ pollUntil(5.seconds) { rootEvents.size >= 1 }
+
+ assertEquals(1, scoreEvents.size)
+ assertEquals("score", scoreEvents[0].getObject().path())
+ assertEquals(1, rootEvents.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTO24b2c/listener-exception-caught-0
+ */
+ @Test
+ fun `RTO24b2c - listener exception does not affect other listeners`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ root.subscribe(PathObjectListener { throw RuntimeException("boom") })
+ root.subscribe(PathObjectListener { events.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "99", "remote"))),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ assertEquals(1, events.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTO24b1/multi-path-dispatch-0
+ */
+ @Test
+ fun `RTO24b1 - dispatch via getFullPaths for multi-path objects`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val eventsScore = mutableListOf()
+ val eventsAlias = mutableListOf()
+
+ // Add a second reference "alias" -> counter:score@1000 so it has two paths.
+ mockWs.sendToClient(
+ buildObjectMessage(
+ "test",
+ listOf(buildMapSet("root", "alias", dataObjectId("counter:score@1000"), "98", "remote")),
+ ),
+ )
+
+ root.get("score").subscribe(PathObjectListener { eventsScore.add(it) })
+ root.get("alias").subscribe(PathObjectListener { eventsAlias.add(it) })
+
+ // Increment counter:score@1000 — getFullPaths returns ["score"] and ["alias"].
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "99", "remote"))),
+ )
+ pollUntil(5.seconds) { eventsScore.size >= 1 }
+ pollUntil(5.seconds) { eventsAlias.size >= 1 }
+
+ assertEquals(1, eventsScore.size)
+ assertEquals("score", eventsScore[0].getObject().path())
+ assertEquals(1, eventsAlias.size)
+ assertEquals("alias", eventsAlias[0].getObject().path())
+ }
+
+ /**
+ * @UTS objects/unit/RTO24b2b/fires-once-per-dispatch-0
+ */
+ @Test
+ fun `RTO24b2b - subscription fires exactly once per dispatch`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ // Subscribe at root (unlimited depth) — covers both [] and ["score"].
+ root.subscribe(PathObjectListener { events.add(it) })
+
+ // MAP_SET on root with key "score" — candidates [] and ["score"]; root fires exactly once.
+ mockWs.sendToClient(
+ buildObjectMessage(
+ "test",
+ listOf(buildMapSet("root", "score", dataObjectId("counter:new@2000"), "99", "remote")),
+ ),
+ )
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ assertEquals(1, events.size)
+ }
+}
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectTest.kt
new file mode 100644
index 000000000..6a1ff3452
--- /dev/null
+++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectTest.kt
@@ -0,0 +1,454 @@
+package io.ably.lib.uts.unit.liveobjects
+
+import io.ably.lib.liveobjects.path.PathObject
+import io.ably.lib.uts.infra.pollUntil
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Derived from UTS `objects/unit/path_object.md` (RTPO1–RTPO14) — the typed `PathObject` read/navigation
+ * surface: `path()`, `get()` / `at()`, `value()`, `instance()`, `entries()` / `keys()` / `values()`,
+ * `size()`, `getType()`, and the compacted-snapshot accessor.
+ *
+ * ably-java implements the typed-SDK variant (RTTS), so the spec's single polymorphic `PathObject.value()`
+ * splits across typed `as*()` accessors, each returning `null` (never throwing) on a type mismatch (RTTS5d /
+ * RTTS6g). `root` (from `setupSyncedChannel`) is already a `LiveMapPathObject`, so `root.get(...)` needs no
+ * cast; deeper navigated nodes are `asLiveMap()`-ed before map ops. Number gotchas: counter `value()` is
+ * `Double` (100.0), primitive `asNumber().value()` is a boxed `Number` (normalise with `?.toDouble()`),
+ * `size()` is `Long` (7L). Three deviations recorded in `deviations.md`:
+ * - `get(non-string)` / `at(non-string)` failing with 40003 (RTPO5b / RTPO6b) is not expressible — the
+ * signatures take `@NotNull String`, so a non-string argument is a compile error.
+ * - `compact()` is not implemented (RTTS3f); `compactJson()` is the supported snapshot (RTPO13 / RTPO13b5 /
+ * RTPO13c, and the `compact()` sub-assertion of RTPO3c1).
+ *
+ * All tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing +
+ * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only).
+ */
+class PathObjectTest {
+
+ /**
+ * @UTS objects/unit/RTPO4/path-string-representation-0
+ */
+ @Test
+ fun `RTPO4 - path returns dot-delimited string`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ assertEquals("", root.path())
+ assertEquals("profile", root.get("profile").path())
+ assertEquals("profile.email", root.get("profile").asLiveMap().get("email").path())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO4b/path-escapes-dots-0
+ */
+ @Test
+ fun `RTPO4b - path escapes dots in segments`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val po = root.get("a.b").asLiveMap().get("c")
+
+ assertEquals("a\\.b.c", po.path())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO5/get-appends-key-0
+ */
+ @Test
+ fun `RTPO5 - get returns new PathObject with appended key`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val child = root.get("profile")
+ val grandchild = child.asLiveMap().get("email")
+
+ assertEquals("profile", child.path())
+ assertEquals("profile.email", grandchild.path())
+ assertTrue(child !== root) // RTPO5c: new PathObject, not the same instance as root
+ }
+
+ /**
+ * @UTS objects/unit/RTPO5b/get-non-string-throws-0
+ */
+ @Test
+ fun `RTPO5b - get throws on non-string key`() = runTest {
+ setupSyncedChannel("test")
+
+ // DEVIATION (RTPO5b): spec passes a non-string key (`root.get(123)`) and expects ErrorInfo 40003.
+ // ably-java's `LiveMapPathObject.get(@NotNull String)` only accepts a String, so a non-string
+ // argument is a compile error, not a runtime failure — the case is not expressible. See deviations.md.
+ }
+
+ /**
+ * @UTS objects/unit/RTPO6/at-parses-path-0
+ */
+ @Test
+ fun `RTPO6 - at parses dot-delimited path`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val po = root.at("profile.email")
+
+ assertEquals("profile.email", po.path())
+ assertEquals("alice@example.com", po.asString().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO6/at-escaped-dots-0
+ */
+ @Test
+ fun `RTPO6 - at respects escaped dots`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val po = root.at("a\\.b.c") // segments ["a.b", "c"]
+
+ assertEquals("a\\.b.c", po.path())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO6b/at-non-string-throws-0
+ */
+ @Test
+ fun `RTPO6b - at throws for non-string input`() = runTest {
+ setupSyncedChannel("test")
+
+ // DEVIATION (RTPO6b): spec passes a non-string path (`root.at(123)`) and expects ErrorInfo 40003.
+ // ably-java's `LiveMapPathObject.at(@NotNull String)` only accepts a String, so a non-string argument
+ // is a compile error, not a runtime failure — the case is not expressible. See deviations.md.
+ }
+
+ /**
+ * @UTS objects/unit/RTPO7/value-counter-0
+ */
+ @Test
+ fun `RTPO7 - value returns counter numeric value`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ // Counter value() is Double (RTPO7c -> LiveCounter#value); assert 100.0.
+ assertEquals(100.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO7/value-primitive-0
+ */
+ @Test
+ fun `RTPO7 - value returns primitive value`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ assertEquals("Alice", root.get("name").asString().value())
+ assertEquals(30.0, root.get("age").asNumber().value()?.toDouble())
+ assertEquals(true, root.get("active").asBoolean().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO7d/value-livemap-null-0
+ */
+ @Test
+ fun `RTPO7d - value returns null for LiveMap`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ // RTPO7e: a LiveMap has no scalar value; the typed counter/primitive accessors return null.
+ assertNull(root.get("profile").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO7e/value-unresolvable-null-0
+ */
+ @Test
+ fun `RTPO7e - value returns null on resolution failure`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ assertNull(root.get("nonexistent").asLiveMap().get("deep").asString().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO7/value-bytes-0
+ */
+ @Test
+ fun `RTPO7 - value returns bytes for binary entry`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ // STANDARD_POOL_OBJECTS stores avatar as base64 "AQID" == bytes [1, 2, 3].
+ assertEquals(listOf(1, 2, 3), root.get("avatar").asBinary().value()?.toList())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO8/instance-live-object-0
+ */
+ @Test
+ fun `RTPO8 - instance returns Instance for LiveObject`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val counterInst = root.get("score").instance()
+ assertNotNull(counterInst) // RTPO8c: IS Instance
+ assertEquals("counter:score@1000", counterInst!!.asLiveCounter().id)
+
+ val mapInst = root.get("profile").instance()
+ assertNotNull(mapInst) // RTPO8c: IS Instance
+ assertEquals("map:profile@1000", mapInst!!.asLiveMap().id)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO8c/instance-primitive-null-0
+ */
+ @Test
+ fun `RTPO8c - instance returns null for primitive`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ assertNull(root.get("name").instance())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO9/entries-yields-pairs-0
+ */
+ @Test
+ fun `RTPO9 - entries returns key PathObject pairs`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val entries = mutableMapOf()
+ for ((key, pathObj) in root.entries()) {
+ entries[key] = pathObj.path()
+ }
+
+ assertEquals("name", entries["name"])
+ assertEquals("profile", entries["profile"])
+ assertEquals(7, entries.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO9d/entries-non-map-empty-0
+ */
+ @Test
+ fun `RTPO9d - entries returns empty for non-LiveMap`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val entries = root.get("score").asLiveMap().entries().toList()
+
+ assertEquals(0, entries.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO10/keys-returns-array-0
+ */
+ @Test
+ fun `RTPO10 - keys returns array of key strings`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val keys = root.keys().toList()
+
+ assertEquals(7, keys.size)
+ assertTrue("name" in keys)
+ assertTrue("profile" in keys)
+ assertTrue("score" in keys)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO10d/keys-non-map-empty-0
+ */
+ @Test
+ fun `RTPO10d - keys returns empty for non-LiveMap`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val keys = root.get("score").asLiveMap().keys().toList()
+
+ assertEquals(0, keys.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO11/values-returns-array-0
+ */
+ @Test
+ fun `RTPO11 - values returns array of PathObjects`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val vals = root.values().toList()
+
+ assertEquals(7, vals.size)
+ // Each element is a PathObject whose path is the key.
+ val paths = mutableSetOf()
+ for (v in vals) {
+ paths.add(v.path())
+ }
+ assertTrue("name" in paths)
+ assertTrue("profile" in paths)
+ assertTrue("score" in paths)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO11d/values-non-map-empty-0
+ */
+ @Test
+ fun `RTPO11d - values returns empty for non-LiveMap`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val vals = root.get("score").asLiveMap().values().toList()
+
+ assertEquals(0, vals.size)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO12/size-count-0
+ */
+ @Test
+ fun `RTPO12 - size returns non-tombstoned count`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ assertEquals(7L, root.size())
+ assertEquals(3L, root.get("profile").asLiveMap().size())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO12c/size-non-map-null-0
+ */
+ @Test
+ fun `RTPO12c - size returns null for non-LiveMap`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ assertNull(root.get("score").asLiveMap().size())
+ assertNull(root.get("name").asLiveMap().size())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO13/compact-recursive-0
+ */
+ @Test
+ fun `RTPO13 - compact recursively compacts LiveMap tree`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ // DEVIATION (RTPO13): ably-java does not implement `compact()` (RTTS3f); `compactJson()` is the
+ // supported recursively-compacted snapshot. Binary is base64-encoded rather than raw bytes, so the
+ // avatar assertion checks the base64 string. Assertions navigate the JsonObject. See deviations.md.
+ val result = root.compactJson()!!.asJsonObject
+
+ assertEquals("Alice", result.get("name").asString)
+ assertEquals(30, result.get("age").asInt)
+ assertEquals(true, result.get("active").asBoolean)
+ assertEquals(100, result.get("score").asInt)
+ assertEquals("a", result.getAsJsonObject("data").getAsJsonArray("tags").get(0).asString)
+ assertEquals("b", result.getAsJsonObject("data").getAsJsonArray("tags").get(1).asString)
+ assertEquals("AQID", result.get("avatar").asString) // base64 of bytes [1, 2, 3]
+ val profile = result.getAsJsonObject("profile")
+ assertEquals("alice@example.com", profile.get("email").asString)
+ assertEquals(5, profile.get("nested_counter").asInt)
+ assertEquals("dark", profile.getAsJsonObject("prefs").get("theme").asString)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO13b5/compact-cycle-detection-0
+ */
+ @Test
+ fun `RTPO13b5 - compact handles cycles via shared reference`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+
+ // Introduce a cycle: map:prefs@1000.back_ref points back at map:profile@1000.
+ mockWs.sendToClient(
+ buildObjectMessage(
+ "test",
+ listOf(buildMapSet("map:prefs@1000", "back_ref", dataObjectId("map:profile@1000"), "99", "remote")),
+ ),
+ )
+ pollUntil(5.seconds) { root.get("profile").asLiveMap().get("prefs").asLiveMap().get("back_ref").exists() }
+
+ // DEVIATION (RTPO13b5): spec asserts `result["prefs"]["back_ref"] IS result` — native object identity
+ // from the unimplemented `compact()` (RTTS3f). `compactJson()` represents the cycle as an
+ // `{ "objectId": ... }` marker instead (see RTPO14), so the identity assertion is replaced by the
+ // objectId-marker assertion. See deviations.md.
+ val result = root.get("profile").compactJson()!!.asJsonObject
+
+ assertEquals(
+ "map:profile@1000",
+ result.getAsJsonObject("prefs").getAsJsonObject("back_ref").get("objectId").asString,
+ )
+ }
+
+ /**
+ * @UTS objects/unit/RTPO13c/compact-counter-0
+ */
+ @Test
+ fun `RTPO13c - compact returns number for LiveCounter`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ // DEVIATION (RTPO13c): `compact()` is unimplemented (RTTS3f); `compactJson()` is used. A LiveCounter
+ // compacts to its numeric JSON value. See deviations.md.
+ assertEquals(100, root.get("score").compactJson()!!.asInt)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO14/compact-json-0
+ */
+ @Test
+ fun `RTPO14 - compactJson encodes cycles as objectId`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+
+ mockWs.sendToClient(
+ buildObjectMessage(
+ "test",
+ listOf(buildMapSet("map:prefs@1000", "back_ref", dataObjectId("map:profile@1000"), "99", "remote")),
+ ),
+ )
+ pollUntil(5.seconds) { root.get("profile").asLiveMap().get("prefs").asLiveMap().get("back_ref").exists() }
+
+ val result = root.get("profile").compactJson()!!.asJsonObject
+
+ assertEquals(
+ "map:profile@1000",
+ result.getAsJsonObject("prefs").getAsJsonObject("back_ref").get("objectId").asString,
+ )
+ }
+
+ /**
+ * @UTS objects/unit/RTPO14/compact-json-bytes-0
+ */
+ @Test
+ fun `RTPO14 - compactJson encodes bytes as base64 string`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val result = root.compactJson()!!.asJsonObject
+
+ assertEquals("AQID", result.get("avatar").asString)
+ }
+
+ /**
+ * @UTS objects/unit/RTPO3/path-resolution-walk-0
+ */
+ @Test
+ fun `RTPO3 - path resolution walks through LiveMaps`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ // RTPO3b: empty path resolves to root (a LiveMap) -> no scalar value.
+ assertNull(root.asLiveCounter().value())
+ assertEquals(
+ "dark",
+ root.get("profile").asLiveMap().get("prefs").asLiveMap().get("theme").asString().value(),
+ )
+ }
+
+ /**
+ * @UTS objects/unit/RTPO3a1/intermediate-not-map-0
+ */
+ @Test
+ fun `RTPO3a1 - resolution fails if intermediate is not LiveMap`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ // score resolves to a counter, so navigating past it fails to resolve -> read returns null.
+ assertNull(root.get("score").asLiveMap().get("something").asString().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTPO3c1/read-null-on-failure-0
+ */
+ @Test
+ fun `RTPO3c1 - read operation returns null on resolution failure`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ val nonexistent: PathObject = root.get("nonexistent")
+ assertNull(nonexistent.asString().value())
+ assertNull(nonexistent.instance())
+ assertNull(nonexistent.asLiveMap().size())
+ // DEVIATION (RTPO3c1): spec asserts `compact() == null` on resolution failure; `compact()` is
+ // unimplemented (RTTS3f), so `compactJson()` is asserted null instead. See deviations.md.
+ assertNull(nonexistent.compactJson())
+ }
+}
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PublicObjectMessageTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PublicObjectMessageTest.kt
new file mode 100644
index 000000000..840e2821a
--- /dev/null
+++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PublicObjectMessageTest.kt
@@ -0,0 +1,367 @@
+package io.ably.lib.uts.unit.liveobjects
+
+import com.google.gson.JsonObject
+import io.ably.lib.liveobjects.message.ObjectMessage
+import io.ably.lib.liveobjects.message.ObjectOperationAction
+import io.ably.lib.liveobjects.message.ObjectsMapSemantics
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+/**
+ * Derived from UTS `objects/unit/public_object_message.md` — construction of the public-facing
+ * `ObjectMessage` (PAOM3) and `ObjectOperation` (PAOOP3) from a source wire object message.
+ *
+ * Pure data-structure construction, no mocks. The spec's `PublicObjectMessage.fromObjectMessage(source,
+ * channel)` / `PublicObjectOperation.fromObjectOperation(op)` (PAOM3 / PAOOP3) are `internal` in
+ * `:liveobjects`; [buildPublicObjectMessage] (in `helpers.kt`) reaches them by reflection, so the source is
+ * built with the wire op builders (`buildMapSet`, `buildCounterInc`, …) and the public getters are asserted
+ * on the result. Spec `op.action == "MAP_SET"` (string tag) becomes the `ObjectOperationAction` enum
+ * constant; `op.mapCreate == null` becomes `assertNull`; getters read as Kotlin properties.
+ */
+class PublicObjectMessageTest {
+
+ /**
+ * @UTS objects/unit/PAOM3/construction-all-fields-0
+ */
+ @Test
+ fun `PAOM3 - construction copies all fields from source ObjectMessage`() {
+ val extras = JsonObject().apply { addProperty("key", "value") }
+ // MAP_SET operation + every optional top-level field. The op builders cover serial/siteCode/the
+ // operation; the remaining top-level fields aren't builder params, so augment the wire JSON directly.
+ val source = buildMapSet("map:abc@1000", "name", dataString("Alice"), serial = "01", siteCode = "site1").apply {
+ addProperty("id", "msg-id-1")
+ addProperty("clientId", "client-1")
+ addProperty("connectionId", "conn-1")
+ addProperty("timestamp", 1700000000000L)
+ addProperty("serialTimestamp", 1700000001000L)
+ add("extras", extras)
+ }
+
+ val publicMsg = buildPublicObjectMessage(source, "test-channel")
+
+ assertEquals("msg-id-1", publicMsg.id)
+ assertEquals("client-1", publicMsg.clientId)
+ assertEquals("conn-1", publicMsg.connectionId)
+ assertEquals(1700000000000L, publicMsg.timestamp)
+ assertEquals("test-channel", publicMsg.channel)
+ assertEquals("01", publicMsg.serial)
+ assertEquals(1700000001000L, publicMsg.serialTimestamp)
+ assertEquals("site1", publicMsg.siteCode)
+ assertEquals(extras, publicMsg.extras)
+ assertNotNull(publicMsg.operation)
+ assertEquals(ObjectOperationAction.MAP_SET, publicMsg.operation.action)
+ assertEquals("map:abc@1000", publicMsg.operation.objectId)
+ assertEquals("name", publicMsg.operation.mapSet!!.key)
+ }
+
+ /**
+ * @UTS objects/unit/PAOM3/construction-optional-fields-missing-0
+ */
+ @Test
+ fun `PAOM3 - construction with optional fields missing`() {
+ // Only the required operation present; all optional top-level fields absent.
+ val source = buildCounterInc("counter:abc@1000", 5)
+
+ val publicMsg = buildPublicObjectMessage(source, "my-channel")
+
+ assertNull(publicMsg.id)
+ assertNull(publicMsg.clientId)
+ assertNull(publicMsg.connectionId)
+ assertNull(publicMsg.timestamp)
+ assertEquals("my-channel", publicMsg.channel)
+ assertNull(publicMsg.serial)
+ assertNull(publicMsg.serialTimestamp)
+ assertNull(publicMsg.siteCode)
+ assertNull(publicMsg.extras)
+ assertNotNull(publicMsg.operation)
+ assertEquals(ObjectOperationAction.COUNTER_INC, publicMsg.operation.action)
+ }
+
+ /**
+ * @UTS objects/unit/PAOM3/channel-from-channel-name-0
+ */
+ @Test
+ fun `PAOM3b - channel is set from channel name not from ObjectMessage`() {
+ val source = buildObjectDelete("counter:abc@1000")
+
+ val publicMsg = buildPublicObjectMessage(source, "different-channel-name")
+
+ assertEquals("different-channel-name", publicMsg.channel)
+ }
+
+ /**
+ * @UTS objects/unit/PAOOP3/map-set-copies-fields-0
+ */
+ @Test
+ fun `PAOOP3a - MAP_SET operation copies mapSet, omits unrelated fields`() {
+ val source = buildMapSet("map:abc@1000", "color", dataString("blue"))
+
+ val op = buildPublicObjectMessage(source, "test-channel").operation
+
+ assertEquals(ObjectOperationAction.MAP_SET, op.action)
+ assertEquals("map:abc@1000", op.objectId)
+ assertEquals("color", op.mapSet!!.key)
+ assertEquals("blue", op.mapSet!!.value.string)
+ assertNull(op.mapCreate)
+ assertNull(op.mapRemove)
+ assertNull(op.counterCreate)
+ assertNull(op.counterInc)
+ assertNull(op.objectDelete)
+ assertNull(op.mapClear)
+ }
+
+ /**
+ * @UTS objects/unit/PAOOP3/map-remove-copies-fields-0
+ */
+ @Test
+ fun `PAOOP3a - MAP_REMOVE operation copies mapRemove, omits unrelated fields`() {
+ val source = buildMapRemove("map:abc@1000", "old-key")
+
+ val op = buildPublicObjectMessage(source, "test-channel").operation
+
+ assertEquals(ObjectOperationAction.MAP_REMOVE, op.action)
+ assertEquals("map:abc@1000", op.objectId)
+ assertEquals("old-key", op.mapRemove!!.key)
+ assertNull(op.mapCreate)
+ assertNull(op.mapSet)
+ assertNull(op.counterCreate)
+ assertNull(op.counterInc)
+ assertNull(op.objectDelete)
+ assertNull(op.mapClear)
+ }
+
+ /**
+ * @UTS objects/unit/PAOOP3/counter-inc-copies-fields-0
+ */
+ @Test
+ fun `PAOOP3a - COUNTER_INC operation copies counterInc, omits unrelated fields`() {
+ val source = buildCounterInc("counter:abc@1000", 42)
+
+ val op = buildPublicObjectMessage(source, "test-channel").operation
+
+ assertEquals(ObjectOperationAction.COUNTER_INC, op.action)
+ assertEquals("counter:abc@1000", op.objectId)
+ assertEquals(42.0, op.counterInc!!.number)
+ assertNull(op.mapCreate)
+ assertNull(op.mapSet)
+ assertNull(op.mapRemove)
+ assertNull(op.counterCreate)
+ assertNull(op.objectDelete)
+ assertNull(op.mapClear)
+ }
+
+ /**
+ * @UTS objects/unit/PAOOP3/object-delete-copies-fields-0
+ */
+ @Test
+ fun `PAOOP3a - OBJECT_DELETE operation copies objectDelete, omits unrelated fields`() {
+ val source = buildObjectDelete("counter:abc@1000")
+
+ val op = buildPublicObjectMessage(source, "test-channel").operation
+
+ assertEquals(ObjectOperationAction.OBJECT_DELETE, op.action)
+ assertEquals("counter:abc@1000", op.objectId)
+ assertNotNull(op.objectDelete)
+ assertNull(op.mapCreate)
+ assertNull(op.mapSet)
+ assertNull(op.mapRemove)
+ assertNull(op.counterCreate)
+ assertNull(op.counterInc)
+ assertNull(op.mapClear)
+ }
+
+ /**
+ * @UTS objects/unit/PAOOP3/map-clear-copies-fields-0
+ */
+ @Test
+ fun `PAOOP3a - MAP_CLEAR operation copies mapClear, omits unrelated fields`() {
+ val source = buildMapClear("map:abc@1000")
+
+ val op = buildPublicObjectMessage(source, "test-channel").operation
+
+ assertEquals(ObjectOperationAction.MAP_CLEAR, op.action)
+ assertEquals("map:abc@1000", op.objectId)
+ assertNotNull(op.mapClear)
+ assertNull(op.mapCreate)
+ assertNull(op.mapSet)
+ assertNull(op.mapRemove)
+ assertNull(op.counterCreate)
+ assertNull(op.counterInc)
+ assertNull(op.objectDelete)
+ }
+
+ /**
+ * @UTS objects/unit/PAOOP3/map-create-direct-0
+ */
+ @Test
+ fun `PAOOP3b1 - MAP_CREATE with mapCreate directly present`() {
+ val source = buildMapCreate(
+ "map:new@2000",
+ mapState(linkedMapOf("key1" to mapEntry(dataString("val1")))),
+ )
+
+ val op = buildPublicObjectMessage(source, "test-channel").operation
+
+ assertEquals(ObjectOperationAction.MAP_CREATE, op.action)
+ assertEquals("map:new@2000", op.objectId)
+ assertNotNull(op.mapCreate)
+ assertEquals(ObjectsMapSemantics.LWW, op.mapCreate!!.semantics)
+ assertEquals("val1", op.mapCreate!!.entries["key1"]!!.data!!.string)
+ assertNull(op.counterCreate)
+ }
+
+ /**
+ * @UTS objects/unit/PAOOP3/map-create-from-with-object-id-0
+ */
+ @Test
+ fun `PAOOP3b2 - MAP_CREATE resolved from mapCreateWithObjectId`() {
+ // The source carries mapCreateWithObjectId (no direct mapCreate); the public op must resolve
+ // mapCreate from the MapCreate the WithObjectId variant was derived from. In ably-java that derived
+ // form (WireMapCreateWithObjectId.derivedFrom) is @Transient/outbound-only and never arrives over the
+ // wire, so it can't be carried by the wire-JSON helper — reconstruct it reflectively (see
+ // publicMessageWithDerivedCreate).
+ val withObjectId = JsonObject().apply {
+ add(
+ "operation",
+ JsonObject().apply {
+ addProperty("action", 0) // MAP_CREATE wire code
+ addProperty("objectId", "map:derived@3000")
+ add(
+ "mapCreateWithObjectId",
+ JsonObject().apply {
+ addProperty("initialValue", "stub-initial-value")
+ addProperty("nonce", "stub-nonce")
+ },
+ )
+ },
+ )
+ }
+ val derived = buildMapCreate(
+ "map:derived@3000",
+ mapState(linkedMapOf("x" to mapEntry(dataNumber(10)))),
+ )
+
+ val op = publicMessageWithDerivedCreate(withObjectId, derived, "test-channel").operation
+
+ assertEquals(ObjectOperationAction.MAP_CREATE, op.action)
+ assertEquals("map:derived@3000", op.objectId)
+ assertNotNull(op.mapCreate)
+ assertEquals(ObjectsMapSemantics.LWW, op.mapCreate!!.semantics)
+ assertEquals(10.0, op.mapCreate!!.entries["x"]!!.data!!.number)
+ assertNull(op.counterCreate)
+ }
+
+ /**
+ * @UTS objects/unit/PAOOP3/counter-create-from-with-object-id-0
+ */
+ @Test
+ fun `PAOOP3c2 - COUNTER_CREATE resolved from counterCreateWithObjectId`() {
+ // As PAOOP3b2 but for the counter variant — counterCreate resolved from the derived CounterCreate.
+ val withObjectId = JsonObject().apply {
+ add(
+ "operation",
+ JsonObject().apply {
+ addProperty("action", 3) // COUNTER_CREATE wire code
+ addProperty("objectId", "counter:derived@3000")
+ add(
+ "counterCreateWithObjectId",
+ JsonObject().apply {
+ addProperty("initialValue", "stub-initial-value")
+ addProperty("nonce", "stub-nonce")
+ },
+ )
+ },
+ )
+ }
+ val derived = buildCounterCreate("counter:derived@3000", counterState(100))
+
+ val op = publicMessageWithDerivedCreate(withObjectId, derived, "test-channel").operation
+
+ assertEquals(ObjectOperationAction.COUNTER_CREATE, op.action)
+ assertEquals("counter:derived@3000", op.objectId)
+ assertNotNull(op.counterCreate)
+ assertEquals(100.0, op.counterCreate!!.count)
+ assertNull(op.mapCreate)
+ }
+
+ /**
+ * @UTS objects/unit/PAOOP3/create-payloads-omitted-0
+ */
+ @Test
+ fun `PAOOP3b3, PAOOP3c3 - create payloads omitted when neither variant is present`() {
+ val source = buildMapSet("map:abc@1000", "k", dataString("v"))
+
+ val op = buildPublicObjectMessage(source, "test-channel").operation
+
+ assertNull(op.mapCreate)
+ assertNull(op.counterCreate)
+ }
+
+ /**
+ * @UTS objects/unit/PAOOP3/only-relevant-field-per-action-0
+ */
+ @Test
+ fun `PAOOP3 - only the relevant operation field is present per action type`() {
+ val source = buildCounterCreate("counter:new@2000", counterState(50))
+
+ val op = buildPublicObjectMessage(source, "test-channel").operation
+
+ assertEquals(ObjectOperationAction.COUNTER_CREATE, op.action)
+ assertEquals("counter:new@2000", op.objectId)
+ assertNotNull(op.counterCreate)
+ assertEquals(50.0, op.counterCreate!!.count)
+ assertNull(op.mapCreate)
+ assertNull(op.mapSet)
+ assertNull(op.mapRemove)
+ assertNull(op.counterInc)
+ assertNull(op.objectDelete)
+ assertNull(op.mapClear)
+ }
+}
+
+/**
+ * Builds a public [ObjectMessage] whose operation carries a `*CreateWithObjectId` variant resolved to its
+ * derived `MapCreate` / `CounterCreate` (PAOOP3b2 / PAOOP3c2).
+ *
+ * Why this exists: `WireMapCreateWithObjectId.derivedFrom` / `WireCounterCreateWithObjectId.derivedFrom` are
+ * `@Transient` — populated only when the SDK constructs an outbound create operation locally, never
+ * deserialized from the wire. `buildPublicObjectMessage`'s wire-JSON path therefore cannot carry it. This
+ * helper reconstructs it: it deserializes [withObjectIdMessage] to its internal `WireObjectMessage`,
+ * manufactures the derived `WireMapCreate` / `WireCounterCreate` by deserializing [derivedCreateMessage]
+ * (a normal direct-create message), grafts it onto the WithObjectId variant's `derivedFrom` field, then
+ * builds the public message via the same `DefaultObjectMessage(WireObjectMessage, String)` constructor that
+ * `buildPublicObjectMessage` uses. All access is by reflection because the wire/Default types are `internal`
+ * to `:liveobjects` (runtime-only on the uts classpath).
+ */
+private fun publicMessageWithDerivedCreate(
+ withObjectIdMessage: JsonObject,
+ derivedCreateMessage: JsonObject,
+ channelName: String,
+): ObjectMessage {
+ val serializationKt = Class.forName("io.ably.lib.liveobjects.serialization.JsonSerializationKt")
+ val toWire = serializationKt.getMethod("toObjectMessage", JsonObject::class.java)
+ val mainWire = toWire.invoke(null, withObjectIdMessage)
+ val derivedWire = toWire.invoke(null, derivedCreateMessage)
+
+ val operationField = mainWire.javaClass.getDeclaredField("operation").apply { isAccessible = true }
+ val mainOp = operationField.get(mainWire)
+ val derivedOp = operationField.get(derivedWire)
+
+ fun graft(withObjectIdFieldName: String, derivedCreateFieldName: String) {
+ val withObjectId = mainOp.javaClass.getDeclaredField(withObjectIdFieldName)
+ .apply { isAccessible = true }.get(mainOp) ?: return
+ val derivedCreate = derivedOp.javaClass.getDeclaredField(derivedCreateFieldName)
+ .apply { isAccessible = true }.get(derivedOp)
+ withObjectId.javaClass.getDeclaredField("derivedFrom")
+ .apply { isAccessible = true }.set(withObjectId, derivedCreate)
+ }
+ graft("mapCreateWithObjectId", "mapCreate")
+ graft("counterCreateWithObjectId", "counterCreate")
+
+ val wireClass = Class.forName("io.ably.lib.liveobjects.message.WireObjectMessage")
+ return Class.forName("io.ably.lib.liveobjects.message.DefaultObjectMessage")
+ .getConstructor(wireClass, String::class.java)
+ .newInstance(mainWire, channelName) as ObjectMessage
+}
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/RealtimeObjectTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/RealtimeObjectTest.kt
new file mode 100644
index 000000000..6fac9053a
--- /dev/null
+++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/RealtimeObjectTest.kt
@@ -0,0 +1,1091 @@
+package io.ably.lib.uts.unit.liveobjects
+
+import io.ably.lib.liveobjects.path.PathObjectListener
+import io.ably.lib.liveobjects.path.PathObjectSubscriptionEvent
+import io.ably.lib.liveobjects.path.PathObjectSubscriptionOptions
+import io.ably.lib.liveobjects.state.ObjectStateChange
+import io.ably.lib.liveobjects.state.ObjectStateEvent
+import io.ably.lib.liveobjects.value.LiveMapValue
+import io.ably.lib.realtime.Channel
+import io.ably.lib.realtime.ChannelState
+import io.ably.lib.types.AblyException
+import io.ably.lib.types.ChannelMode
+import io.ably.lib.types.ChannelOptions
+import io.ably.lib.types.ErrorInfo
+import io.ably.lib.types.ProtocolMessage
+import io.ably.lib.types.PublishResult
+import io.ably.lib.uts.infra.awaitChannelState
+import io.ably.lib.uts.infra.pollUntil
+import io.ably.lib.uts.infra.unit.ConnectionDetails
+import io.ably.lib.uts.infra.unit.FakeClock
+import io.ably.lib.uts.infra.unit.MockWebSocket
+import io.ably.lib.uts.infra.unit.TestRealtimeClient
+import kotlinx.coroutines.future.await
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Derived from UTS `objects/unit/realtime_object.md` (RTO2, RTO10, RTO15, RTO17–RTO20, RTO22–RTO26) — the
+ * `RealtimeObject` entry point (`channel.object`): the `get()` root accessor, its access/write/mode
+ * preconditions, publish-and-apply local-apply / echo-dedup behaviour, the sync-state event API
+ * (`on(SYNCING/SYNCED)` / `off` / `unsubscribe`), the single subscription register, depth coverage, and GC.
+ *
+ * This is a **mixed** spec (mapping §13). The public-API parts translate directly:
+ * - `channel.object.get()` (§2) → `CompletableFuture`, awaited with `.await()`.
+ * - Precondition failures (§12): `40024` (missing OBJECT_SUBSCRIBE/OBJECT_PUBLISH mode), `90001`
+ * (DETACHED/FAILED channel), `92008` (channel leaves ATTACHED while awaiting SYNCED), `40000`
+ * (`echoMessages` false) — all `AblyException` with those int codes.
+ * - Sync-state events (§9): `channel.object.on(ObjectStateEvent.SYNCING/SYNCED, listener)` returning a
+ * `Subscription`, `off(listener)`, and `Subscription.unsubscribe()`.
+ * - publishAndApply (RTO20) and GC (RTO10) effects are asserted **observably** through the public read API
+ * (counter `value()`), since the apply/echo/dedup/GC machinery is internal (§13).
+ *
+ * The one internal-only case is `publish` (RTO15): `channel.object.publish(...)` is marked `internal` in the
+ * IDL and its OBJECT/ACK wire-message assertions reach `ProtocolMessage.state` wire objects (§13). There is
+ * no public `publish` on `RealtimeObject`, so that test is a documented deviation (see deviations.md).
+ *
+ * Most tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing +
+ * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only).
+ */
+class RealtimeObjectTest {
+
+ private fun connected(withSiteCode: Boolean = true, gcGracePeriodMs: Long? = 86_400_000L): ProtocolMessage =
+ ProtocolMessage(ProtocolMessage.Action.connected).apply {
+ connectionId = "conn-1"
+ connectionDetails = ConnectionDetails {
+ connectionKey = "key-1"
+ if (withSiteCode) siteCode = "test-site"
+ gcGracePeriodMs?.let { objectsGCGracePeriod = it }
+ }
+ }
+
+ /**
+ * @UTS objects/unit/RTO23/get-returns-path-object-0
+ */
+ @Test
+ fun `RTO23d - get returns PathObject wrapping root`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ // root IS PathObject (always a LiveMapPathObject, RTO23f); path is the empty list -> "".
+ assertEquals("", root.path())
+ }
+
+ /**
+ * @UTS objects/unit/RTO23a/get-requires-subscribe-mode-0
+ */
+ @Test
+ fun `RTO23a - get requires OBJECT_SUBSCRIBE mode`() = runTest {
+ lateinit var mockWs: MockWebSocket
+ mockWs = MockWebSocket {
+ onConnectionAttempt = { it.respondWithSuccess(connected(gcGracePeriodMs = null)) }
+ onMessageFromClient = { msg ->
+ if (msg.action == ProtocolMessage.Action.attach) {
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ channel = msg.channel
+ channelSerial = "sync1:"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ }
+ }
+ }
+ val client = TestRealtimeClient { key = "fake:key"; install(mockWs) }
+ val channel = client.channels.get(
+ "test",
+ ChannelOptions().apply { modes = arrayOf(ChannelMode.object_publish) },
+ )
+
+ val ex = assertFailsWith { channel.`object`.get().await() }
+ assertEquals(40024, ex.errorInfo.code)
+ }
+
+ /**
+ * @UTS objects/unit/RTO23b/get-throws-detached-0
+ */
+ @Test
+ fun `RTO23b - get throws on DETACHED channel`() = runTest {
+ lateinit var mockWs: MockWebSocket
+ mockWs = MockWebSocket {
+ onConnectionAttempt = { it.respondWithSuccess(connected(gcGracePeriodMs = null)) }
+ onMessageFromClient = { msg ->
+ when (msg.action) {
+ ProtocolMessage.Action.attach -> {
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ channel = msg.channel
+ channelSerial = "sync1:"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS))
+ }
+ ProtocolMessage.Action.detach ->
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.detached).apply { channel = msg.channel },
+ )
+ else -> Unit
+ }
+ }
+ }
+ val client = TestRealtimeClient { key = "fake:key"; install(mockWs) }
+ val channel = client.channels.get(
+ "test",
+ ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe) },
+ )
+
+ channel.`object`.get().await()
+ channel.detach()
+ awaitChannelState(channel, ChannelState.detached)
+
+ val ex = assertFailsWith { channel.`object`.get().await() }
+ assertEquals(90001, ex.errorInfo.code)
+ assertEquals(400, ex.errorInfo.statusCode)
+ }
+
+ /**
+ * @UTS objects/unit/RTO23c/get-waits-for-synced-0
+ */
+ @Test
+ fun `RTO23c - get waits for SYNCED state`() = runTest {
+ var attachSent = false
+ lateinit var mockWs: MockWebSocket
+ mockWs = MockWebSocket {
+ onConnectionAttempt = { it.respondWithSuccess(connected()) }
+ onMessageFromClient = { msg ->
+ if (msg.action == ProtocolMessage.Action.attach) {
+ attachSent = true
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ channel = msg.channel
+ channelSerial = "sync1:cursor"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ }
+ }
+ }
+ val client = TestRealtimeClient { key = "fake:key"; install(mockWs) }
+ val channel = client.channels.get(
+ "test",
+ ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) },
+ )
+
+ val getFuture = channel.`object`.get()
+ pollUntil(5.seconds) { attachSent }
+
+ mockWs.sendToClient(buildObjectSyncMessage("test", "sync1:", STANDARD_POOL_OBJECTS))
+
+ val root = getFuture.await()
+ assertEquals("", root.path())
+ }
+
+ /**
+ * @UTS objects/unit/RTO15/publish-sends-object-pm-0
+ */
+ @Test
+ fun `RTO15 - publish sends OBJECT ProtocolMessage`() = runTest {
+ // DEVIATION (RTO15): the spec calls the internal `channel.object.publish([...])` and asserts on the
+ // captured OBJECT ProtocolMessage's wire form (action/channel/state) and the PublishResult.serials
+ // from the ACK. ably-java's `RealtimeObject` exposes no public `publish` method (RTO15 is `internal`
+ // in the IDL), and the wire `state` objects + ACK PublishResult are internal `:liveobjects` types
+ // not reachable through the public API (mapping §13). Not expressible against the public surface.
+ // See deviations.md.
+ }
+
+ /**
+ * @UTS objects/unit/RTO20/publish-and-apply-local-0
+ */
+ @Test
+ fun `RTO20 - publishAndApply applies locally on ACK`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+
+ root.get("score").asLiveCounter().increment(10).await()
+
+ assertEquals(110.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTO20c/missing-site-code-0
+ */
+ @Test
+ fun `RTO20c - publishAndApply logs error when siteCode missing`() = runTest {
+ lateinit var mockWs: MockWebSocket
+ mockWs = MockWebSocket {
+ onConnectionAttempt = { it.respondWithSuccess(connected(withSiteCode = false)) }
+ onMessageFromClient = { msg ->
+ when (msg.action) {
+ ProtocolMessage.Action.attach -> {
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ channel = msg.channel
+ channelSerial = "sync1:"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage("test", "sync1:", STANDARD_POOL_OBJECTS))
+ }
+ ProtocolMessage.Action.`object` ->
+ mockWs.sendToClient(buildAckMessage(msg.msgSerial, listOf("serial-0")))
+ else -> Unit
+ }
+ }
+ }
+ val client = TestRealtimeClient { key = "fake:key"; install(mockWs) }
+ val channel = client.channels.get(
+ "test",
+ ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) },
+ )
+ val root = channel.`object`.get().await()
+
+ root.get("score").asLiveCounter().increment(10).await()
+
+ // With no siteCode in ConnectionDetails, the synthetic message cannot be applied locally (RTO20c1),
+ // so the score stays at its synced value of 100.
+ assertEquals(100.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTO20d1/null-serial-skipped-0
+ */
+ @Test
+ fun `RTO20d1 - null serial in PublishResult is skipped`() = runTest {
+ lateinit var mockWs: MockWebSocket
+ mockWs = MockWebSocket {
+ onConnectionAttempt = { it.respondWithSuccess(connected()) }
+ onMessageFromClient = { msg ->
+ when (msg.action) {
+ ProtocolMessage.Action.attach -> {
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ channel = msg.channel
+ channelSerial = "sync1:"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage("test", "sync1:", STANDARD_POOL_OBJECTS))
+ }
+ ProtocolMessage.Action.`object` ->
+ // A single null serial in the PublishResult — built directly since the
+ // buildAckMessage helper only accepts non-null serials.
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.ack).apply {
+ msgSerial = msg.msgSerial
+ res = arrayOf(PublishResult(arrayOf(null)))
+ },
+ )
+ else -> Unit
+ }
+ }
+ }
+ val client = TestRealtimeClient { key = "fake:key"; install(mockWs) }
+ val channel = client.channels.get(
+ "test",
+ ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) },
+ )
+ val root = channel.`object`.get().await()
+
+ root.get("score").asLiveCounter().increment(10).await()
+
+ // Null serial in the PublishResult means the synthetic message is skipped (RTO20d1), so the local
+ // apply does not happen and the score stays at the synced value of 100.
+ assertEquals(100.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTO20e/waits-for-synced-0
+ */
+ @Test
+ fun `RTO20e - publishAndApply waits for SYNCED during SYNCING`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+
+ // Begin a new sync (channel re-ATTACHED with a new cursor and HAS_OBJECTS).
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ this.channel = "test"
+ channelSerial = "sync2:cursor"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+
+ val incFuture = root.get("score").asLiveCounter().increment(10)
+
+ // Complete the sync — the pending increment can now apply.
+ mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS))
+
+ incFuture.await()
+ assertEquals(110.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTO20e1/fails-on-channel-failed-0
+ */
+ @Test
+ fun `RTO20e1 - publishAndApply fails when channel enters FAILED during sync wait`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ this.channel = "test"
+ channelSerial = "sync2:cursor"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+
+ val incFuture = root.get("score").asLiveCounter().increment(10)
+
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.detached).apply {
+ this.channel = "test"
+ error = ErrorInfo("Channel detached", 400, 90000)
+ },
+ )
+
+ val ex = assertFailsWith { incFuture.await() }
+ assertEquals(92008, ex.errorInfo.code)
+ }
+
+ /**
+ * @UTS objects/unit/RTO17/sync-state-events-0
+ */
+ @Test
+ fun `RTO17 RTO18 - Sync state events`() = runTest {
+ lateinit var mockWs: MockWebSocket
+ mockWs = MockWebSocket {
+ onConnectionAttempt = { it.respondWithSuccess(connected()) }
+ onMessageFromClient = { msg ->
+ if (msg.action == ProtocolMessage.Action.attach) {
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ channel = msg.channel
+ channelSerial = "sync1:cursor"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ }
+ }
+ }
+ val client = TestRealtimeClient { key = "fake:key"; install(mockWs) }
+ val channel = client.channels.get(
+ "test",
+ ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) },
+ )
+
+ val events = mutableListOf()
+ channel.`object`.on(ObjectStateEvent.SYNCING, ObjectStateChange.Listener { events.add("SYNCING") })
+ channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { events.add("SYNCED") })
+
+ val getFuture = channel.`object`.get()
+ pollUntil(5.seconds) { events.size >= 1 }
+
+ mockWs.sendToClient(buildObjectSyncMessage("test", "sync1:", STANDARD_POOL_OBJECTS))
+ getFuture.await()
+
+ pollUntil(5.seconds) { events.size >= 2 }
+ assertEquals(listOf("SYNCING", "SYNCED"), events)
+ }
+
+ /**
+ * @UTS objects/unit/RTO18d/duplicate-listener-0
+ */
+ @Test
+ fun `RTO18d - Duplicate listener registered twice fires twice`() = runTest {
+ val (_, channel, _, mockWs) = setupSyncedChannel("test")
+ var callCount = 0
+ val listener = ObjectStateChange.Listener { callCount++ }
+ channel.`object`.on(ObjectStateEvent.SYNCED, listener)
+ channel.`object`.on(ObjectStateEvent.SYNCED, listener)
+
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ this.channel = "test"
+ channelSerial = "sync2:cursor"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS))
+
+ pollUntil(5.seconds) { callCount >= 2 }
+ assertEquals(2, callCount)
+ }
+
+ /**
+ * @UTS objects/unit/RTO19/off-deregisters-0
+ */
+ @Test
+ fun `RTO19 - off deregisters listener`() = runTest {
+ val (_, channel, _, mockWs) = setupSyncedChannel("test")
+ var callCount = 0
+ val listener = ObjectStateChange.Listener { callCount++ }
+ // The spec's `sub.off()` maps to the returned Subscription's unsubscribe() (§9).
+ val sub = channel.`object`.on(ObjectStateEvent.SYNCED, listener)
+ sub.unsubscribe()
+
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ this.channel = "test"
+ channelSerial = "sync2:cursor"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS))
+
+ assertEquals(0, callCount)
+ }
+
+ /**
+ * @UTS objects/unit/RTO2/mode-enforcement-0
+ */
+ @Test
+ fun `RTO2 - Channel mode enforcement`() = runTest {
+ lateinit var mockWs: MockWebSocket
+ mockWs = MockWebSocket {
+ onConnectionAttempt = { it.respondWithSuccess(connected()) }
+ onMessageFromClient = { msg ->
+ if (msg.action == ProtocolMessage.Action.attach) {
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ channel = msg.channel
+ channelSerial = "sync1:"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ // Server grants only OBJECT_SUBSCRIBE (RTO2a checks granted modes when ATTACHED);
+ // granted modes are carried as flag bits, not a `modes` field, on ProtocolMessage.
+ setFlag(ProtocolMessage.Flag.object_subscribe)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS))
+ }
+ }
+ }
+ val client = TestRealtimeClient { key = "fake:key"; install(mockWs) }
+ val channel = client.channels.get(
+ "test",
+ ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) },
+ )
+ val root = channel.`object`.get().await()
+
+ val ex = assertFailsWith { root.set("name", LiveMapValue.of("Bob")).await() }
+ assertEquals(40024, ex.errorInfo.code)
+ }
+
+ /**
+ * @UTS objects/unit/RTO25a/access-requires-subscribe-mode-0
+ */
+ @Test
+ fun `RTO25a - Access API requires OBJECT_SUBSCRIBE mode`() = runTest {
+ lateinit var mockWs: MockWebSocket
+ mockWs = MockWebSocket {
+ onConnectionAttempt = { it.respondWithSuccess(connected()) }
+ onMessageFromClient = { msg ->
+ if (msg.action == ProtocolMessage.Action.attach) {
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ channel = msg.channel
+ channelSerial = "sync1:"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ setFlag(ProtocolMessage.Flag.object_publish)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS))
+ }
+ }
+ }
+ val client = TestRealtimeClient { key = "fake:key"; install(mockWs) }
+ val channel = client.channels.get(
+ "test",
+ ChannelOptions().apply { modes = arrayOf(ChannelMode.object_publish) },
+ )
+
+ val ex = assertFailsWith { channel.`object`.get().await() }
+ assertEquals(40024, ex.errorInfo.code)
+ assertEquals(400, ex.errorInfo.statusCode)
+ }
+
+ /**
+ * @UTS objects/unit/RTO25b/access-throws-detached-0
+ */
+ @Test
+ fun `RTO25b - Access API throws on DETACHED channel`() = runTest {
+ lateinit var mockWs: MockWebSocket
+ mockWs = MockWebSocket {
+ onConnectionAttempt = { it.respondWithSuccess(connected(gcGracePeriodMs = null)) }
+ onMessageFromClient = { msg ->
+ when (msg.action) {
+ ProtocolMessage.Action.attach -> {
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ channel = msg.channel
+ channelSerial = "sync1:"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS))
+ }
+ ProtocolMessage.Action.detach ->
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.detached).apply { channel = msg.channel },
+ )
+ else -> Unit
+ }
+ }
+ }
+ val client = TestRealtimeClient { key = "fake:key"; install(mockWs) }
+ val channel = client.channels.get(
+ "test",
+ ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe) },
+ )
+
+ channel.`object`.get().await()
+ channel.detach()
+ awaitChannelState(channel, ChannelState.detached)
+
+ val ex = assertFailsWith { channel.`object`.get().await() }
+ assertEquals(90001, ex.errorInfo.code)
+ assertEquals(400, ex.errorInfo.statusCode)
+ }
+
+ /**
+ * @UTS objects/unit/RTO25b/access-throws-failed-0
+ */
+ @Test
+ fun `RTO25b - Access API throws on FAILED channel`() = runTest {
+ lateinit var mockWs: MockWebSocket
+ mockWs = MockWebSocket {
+ onConnectionAttempt = { it.respondWithSuccess(connected(gcGracePeriodMs = null)) }
+ onMessageFromClient = { msg ->
+ if (msg.action == ProtocolMessage.Action.attach) {
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.error).apply {
+ channel = msg.channel
+ error = ErrorInfo("Channel error", 400, 90000)
+ },
+ )
+ }
+ }
+ }
+ val client = TestRealtimeClient { key = "fake:key"; install(mockWs) }
+ val channel = client.channels.get(
+ "test",
+ ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe) },
+ )
+
+ channel.attach()
+ awaitChannelState(channel, ChannelState.failed)
+
+ val ex = assertFailsWith { channel.`object`.get().await() }
+ assertEquals(90001, ex.errorInfo.code)
+ assertEquals(400, ex.errorInfo.statusCode)
+ }
+
+ /**
+ * @UTS objects/unit/RTO26a/write-requires-publish-mode-0
+ */
+ @Test
+ fun `RTO26a - Write API requires OBJECT_PUBLISH mode`() = runTest {
+ lateinit var mockWs: MockWebSocket
+ mockWs = MockWebSocket {
+ onConnectionAttempt = { it.respondWithSuccess(connected()) }
+ onMessageFromClient = { msg ->
+ if (msg.action == ProtocolMessage.Action.attach) {
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ channel = msg.channel
+ channelSerial = "sync1:"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ setFlag(ProtocolMessage.Flag.object_subscribe)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS))
+ }
+ }
+ }
+ val client = TestRealtimeClient { key = "fake:key"; install(mockWs) }
+ val channel = client.channels.get(
+ "test",
+ ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe) },
+ )
+ val root = channel.`object`.get().await()
+
+ val ex = assertFailsWith {
+ root.set("name", LiveMapValue.of("Bob")).await()
+ }
+ assertEquals(40024, ex.errorInfo.code)
+ assertEquals(400, ex.errorInfo.statusCode)
+ }
+
+ /**
+ * @UTS objects/unit/RTO26b/write-throws-detached-0
+ */
+ @Test
+ fun `RTO26b - Write API throws on DETACHED channel`() = runTest {
+ val (_, channel, root, mockWs) = setupSyncedChannel("test")
+
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.detached).apply {
+ this.channel = "test"
+ error = ErrorInfo("Channel detached", 400, 90000)
+ },
+ )
+ awaitChannelState(channel, ChannelState.detached)
+
+ val ex = assertFailsWith {
+ root.set("name", LiveMapValue.of("Bob")).await()
+ }
+ assertEquals(90001, ex.errorInfo.code)
+ assertEquals(400, ex.errorInfo.statusCode)
+ }
+
+ /**
+ * @UTS objects/unit/RTO26b/write-throws-failed-0
+ */
+ @Test
+ fun `RTO26b - Write API throws on FAILED channel`() = runTest {
+ val (_, channel, root, mockWs) = setupSyncedChannel("test")
+
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.error).apply {
+ this.channel = "test"
+ error = ErrorInfo("Channel error", 400, 90000)
+ },
+ )
+ awaitChannelState(channel, ChannelState.failed)
+
+ val ex = assertFailsWith {
+ root.set("name", LiveMapValue.of("Bob")).await()
+ }
+ assertEquals(90001, ex.errorInfo.code)
+ assertEquals(400, ex.errorInfo.statusCode)
+ }
+
+ /**
+ * @UTS objects/unit/RTO26c/write-throws-echo-disabled-0
+ */
+ @Test
+ fun `RTO26c - Write API throws when echoMessages is false`() = runTest {
+ lateinit var mockWs: MockWebSocket
+ mockWs = MockWebSocket {
+ onConnectionAttempt = { it.respondWithSuccess(connected()) }
+ onMessageFromClient = { msg ->
+ if (msg.action == ProtocolMessage.Action.attach) {
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ channel = msg.channel
+ channelSerial = "sync1:"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS))
+ }
+ }
+ }
+ val client = TestRealtimeClient {
+ key = "fake:key"
+ echoMessages = false
+ install(mockWs)
+ }
+ val channel = client.channels.get(
+ "test",
+ ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) },
+ )
+ val root = channel.`object`.get().await()
+
+ val ex = assertFailsWith {
+ root.set("name", LiveMapValue.of("Bob")).await()
+ }
+ assertEquals(40000, ex.errorInfo.code)
+ assertEquals(400, ex.errorInfo.statusCode)
+ }
+
+ /**
+ * @UTS objects/unit/RTO24a/single-register-instance-0
+ */
+ @Test
+ fun `RTO24a - RealtimeObject maintains a single PathObjectSubscriptionRegister`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+
+ val eventsRoot = mutableListOf()
+ val eventsScore = mutableListOf()
+
+ root.subscribe(PathObjectListener { eventsRoot.add(it) })
+ val scorePath = root.get("score")
+ scorePath.subscribe(PathObjectListener { eventsScore.add(it) })
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "s:1", "aaa"))),
+ )
+ pollUntil(5.seconds) { eventsScore.size >= 1 }
+
+ // Both subscriptions are managed by the same register and both fire.
+ assertTrue(eventsRoot.size >= 1)
+ assertTrue(eventsScore.size >= 1)
+ }
+
+ /**
+ * @UTS objects/unit/RTO24c1/coverage-prefix-depth-0
+ */
+ @Test
+ fun `RTO24c1 - Subscription coverage prefix match with depth constraint`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+
+ val shallowEvents = mutableListOf()
+ val deepEvents = mutableListOf()
+
+ // depth 1 — covers root and immediate children only.
+ root.subscribe(PathObjectListener { shallowEvents.add(it) }, PathObjectSubscriptionOptions(1))
+ // no depth limit — covers everything.
+ root.subscribe(PathObjectListener { deepEvents.add(it) })
+
+ // Update a direct child of root (path ["score"]) — depth 1 from root.
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "s:1", "aaa"))),
+ )
+ pollUntil(5.seconds) { deepEvents.size >= 1 }
+
+ // Update a nested object (path ["profile", "nested_counter"]) — depth 2 from root.
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:nested@1000", 1, "s:2", "aaa"))),
+ )
+ pollUntil(5.seconds) { deepEvents.size >= 2 }
+
+ assertEquals(1, shallowEvents.size)
+ assertTrue(deepEvents.size >= 2)
+ }
+
+ /**
+ * @UTS objects/unit/RTO10/gc-tombstoned-objects-0
+ */
+ @Test
+ fun `RTO10 - GC removes tombstoned objects past grace period`() = runTest {
+ val fakeClock = FakeClock()
+ lateinit var mockWs: MockWebSocket
+ mockWs = MockWebSocket {
+ onConnectionAttempt = { it.respondWithSuccess(connected()) }
+ onMessageFromClient = { msg ->
+ when (msg.action) {
+ ProtocolMessage.Action.attach -> {
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ channel = msg.channel
+ channelSerial = "sync1:"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS))
+ }
+ ProtocolMessage.Action.`object` -> {
+ val serials = (msg.state?.indices ?: IntRange.EMPTY).map { "ack-${msg.msgSerial}:$it" }
+ mockWs.sendToClient(buildAckMessage(msg.msgSerial, serials))
+ }
+ else -> Unit
+ }
+ }
+ }
+ val client = TestRealtimeClient {
+ key = "fake:key"
+ install(mockWs)
+ enableFakeTimers(fakeClock)
+ }
+ val channel = client.channels.get(
+ "test",
+ ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) },
+ )
+ val root = channel.`object`.get().await()
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildObjectDelete("counter:score@1000", "99", "site1", 1000))),
+ )
+
+ // Advance past the GC grace period (86400000ms) plus the check interval.
+ fakeClock.advance(86_400_000L + 300_000L)
+
+ assertNull(root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTO10b1/gc-grace-period-source-0
+ */
+ @Test
+ fun `RTO10b1 - GC grace period from ConnectionDetails`() = runTest {
+ val fakeClock = FakeClock()
+ lateinit var mockWs: MockWebSocket
+ mockWs = MockWebSocket {
+ onConnectionAttempt = { it.respondWithSuccess(connected(gcGracePeriodMs = 5000L)) }
+ onMessageFromClient = { msg ->
+ when (msg.action) {
+ ProtocolMessage.Action.attach -> {
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ channel = msg.channel
+ channelSerial = "sync1:"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS))
+ }
+ ProtocolMessage.Action.`object` -> {
+ val serials = (msg.state?.indices ?: IntRange.EMPTY).map { "ack-${msg.msgSerial}:$it" }
+ mockWs.sendToClient(buildAckMessage(msg.msgSerial, serials))
+ }
+ else -> Unit
+ }
+ }
+ }
+ val client = TestRealtimeClient {
+ key = "fake:key"
+ install(mockWs)
+ enableFakeTimers(fakeClock)
+ }
+ val channel = client.channels.get(
+ "test",
+ ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) },
+ )
+ val root = channel.`object`.get().await()
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildObjectDelete("counter:score@1000", "99", "site1", 1000))),
+ )
+
+ // Short grace period (5000ms) — advance past it.
+ fakeClock.advance(5000L + 1000L)
+
+ assertNull(root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTO20/echo-dedup-0
+ */
+ @Test
+ fun `RTO20 - Echo deduplication via appliedOnAckSerials`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+
+ root.get("score").asLiveCounter().increment(10).await()
+ val scoreAfterApply = root.get("score").asLiveCounter().value()
+
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "ack-0:0", "test-site"))),
+ )
+ val scoreAfterEcho = root.get("score").asLiveCounter().value()
+
+ assertEquals(110.0, scoreAfterApply)
+ assertEquals(110.0, scoreAfterEcho)
+ }
+
+ /**
+ * @UTS objects/unit/RTO20f/ack-no-site-timeserials-update-0
+ */
+ @Test
+ fun `RTO20f - Apply-on-ACK does not update siteTimeserials`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+
+ root.get("score").asLiveCounter().increment(10).await()
+ assertEquals(110.0, root.get("score").asLiveCounter().value())
+
+ // Inbound COUNTER_INC from siteCode "test" with serial "t:1:0" (same as the ACK). If LOCAL had
+ // incorrectly written siteTimeserials, the newness check would reject this as stale.
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "t:1:0", "test"))),
+ )
+ pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == 120.0 }
+
+ assertEquals(120.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTO20/ack-after-echo-no-double-apply-0
+ */
+ @Test
+ fun `RTO20 - ACK after echo does not double-apply`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannelNoAck("test")
+
+ val incFuture = root.get("score").asLiveCounter().increment(10)
+
+ // Send the echo BEFORE the ACK.
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "ack-0:0", "test-site"))),
+ )
+
+ // Now send the ACK.
+ mockWs.sendToClient(buildAckMessage(0, listOf("ack-0:0")))
+
+ incFuture.await()
+ assertEquals(110.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTO5c9-RTO20/ack-serials-cleared-on-resync-0
+ */
+ @Test
+ fun `RTO5c9 RTO20 - appliedOnAckSerials cleared on re-sync`() = runTest {
+ val (_, _, root, mockWs) = setupSyncedChannel("test")
+
+ root.get("score").asLiveCounter().increment(10).await()
+ assertEquals(110.0, root.get("score").asLiveCounter().value())
+
+ // Trigger re-sync — appliedOnAckSerials should be cleared per RTO5c9; score resets to synced 100.
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ this.channel = "test"
+ channelSerial = "sync2:cursor"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS))
+ pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == 100.0 }
+ assertEquals(100.0, root.get("score").asLiveCounter().value())
+
+ // Replay the same serial used for apply-on-ACK. If cleared, this applies normally.
+ mockWs.sendToClient(
+ buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "t:1:0", "test"))),
+ )
+ pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == 110.0 }
+
+ assertEquals(110.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTO20/subscription-fires-on-ack-apply-0
+ */
+ @Test
+ fun `RTO20 - Subscription fires on apply-on-ACK`() = runTest {
+ val (_, _, root, _) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ root.get("score").subscribe(PathObjectListener { events.add(it) })
+
+ root.get("score").asLiveCounter().increment(10).await()
+
+ assertTrue(events.size >= 1)
+ assertEquals(110.0, root.get("score").asLiveCounter().value())
+ }
+
+ /**
+ * @UTS objects/unit/RTO23/get-implicit-attach-0
+ */
+ @Test
+ fun `RTO23 - get implicitly attaches channel`() = runTest {
+ lateinit var mockWs: MockWebSocket
+ mockWs = MockWebSocket {
+ onConnectionAttempt = { it.respondWithSuccess(connected()) }
+ onMessageFromClient = { msg ->
+ when (msg.action) {
+ ProtocolMessage.Action.attach -> {
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ channel = msg.channel
+ channelSerial = "sync1:"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS))
+ }
+ ProtocolMessage.Action.`object` -> {
+ val serials = (msg.state?.indices ?: IntRange.EMPTY).map { "ack-${msg.msgSerial}:$it" }
+ mockWs.sendToClient(buildAckMessage(msg.msgSerial, serials))
+ }
+ else -> Unit
+ }
+ }
+ }
+ val client = TestRealtimeClient { key = "fake:key"; install(mockWs) }
+ val channel = client.channels.get(
+ "test",
+ ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) },
+ )
+
+ assertEquals(ChannelState.initialized, channel.state)
+ val root = channel.`object`.get().await()
+
+ assertEquals("", root.path())
+ assertEquals(ChannelState.attached, channel.state)
+ }
+
+ /**
+ * @UTS objects/unit/RTO23d/get-resolves-immediately-synced-0
+ */
+ @Test
+ fun `RTO23d - get resolves immediately when already SYNCED`() = runTest {
+ val (_, channel, _, _) = setupSyncedChannel("test")
+
+ val root2 = channel.`object`.get().await()
+ assertEquals("", root2.path())
+ }
+
+ /**
+ * @UTS objects/unit/RTO17-RTO18/sync-event-sequences-0
+ */
+ @Test
+ fun `RTO17 RTO18 - Sync event sequences for all state transitions`() = runTest {
+ data class Scenario(
+ val name: String,
+ val trigger: (channel: Channel, mockWs: MockWebSocket) -> Unit,
+ val expectedEvents: List,
+ )
+
+ val scenarios = listOf(
+ Scenario(
+ name = "initial attach",
+ trigger = { channel, _ -> channel.attach() },
+ expectedEvents = listOf("SYNCING", "SYNCED"),
+ ),
+ Scenario(
+ name = "re-attach after detach",
+ trigger = { _, mockWs ->
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.detached).apply { channel = "test" },
+ )
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ this.channel = "test"
+ channelSerial = "sync2:cursor"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS))
+ },
+ expectedEvents = listOf("SYNCING", "SYNCED"),
+ ),
+ Scenario(
+ name = "re-sync on new ATTACHED",
+ trigger = { _, mockWs ->
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ this.channel = "test"
+ channelSerial = "sync3:cursor"
+ setFlag(ProtocolMessage.Flag.has_objects)
+ },
+ )
+ mockWs.sendToClient(buildObjectSyncMessage("test", "sync3:", STANDARD_POOL_OBJECTS))
+ },
+ expectedEvents = listOf("SYNCING", "SYNCED"),
+ ),
+ Scenario(
+ name = "ATTACHED without HAS_OBJECTS",
+ trigger = { _, mockWs ->
+ mockWs.sendToClient(
+ ProtocolMessage(ProtocolMessage.Action.attached).apply {
+ this.channel = "test"
+ channelSerial = "sync4:"
+ },
+ )
+ },
+ expectedEvents = listOf("SYNCED"),
+ ),
+ )
+
+ for (scenario in scenarios) {
+ val (_, channel, _, mockWs) = setupSyncedChannel("test")
+ val events = mutableListOf()
+ channel.`object`.on(ObjectStateEvent.SYNCING, ObjectStateChange.Listener { events.add("SYNCING") })
+ channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { events.add("SYNCED") })
+
+ scenario.trigger(channel, mockWs)
+ pollUntil(5.seconds) { events.size >= scenario.expectedEvents.size }
+
+ assertEquals(scenario.expectedEvents, events, scenario.name)
+ }
+ }
+}
diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/ValueTypesTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/ValueTypesTest.kt
new file mode 100644
index 000000000..f41aa5356
--- /dev/null
+++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/ValueTypesTest.kt
@@ -0,0 +1,339 @@
+package io.ably.lib.uts.unit.liveobjects
+
+import com.google.gson.JsonArray
+import com.google.gson.JsonObject
+import com.google.gson.JsonPrimitive
+import io.ably.lib.liveobjects.value.LiveCounter
+import io.ably.lib.liveobjects.value.LiveMap
+import io.ably.lib.liveobjects.value.LiveMapValue
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlin.test.assertContentEquals
+
+/**
+ * Derived from UTS `objects/unit/value_types.md` (RTLCV1–RTLCV4, RTLMV1–RTLMV4) — the immutable
+ * creation value types `LiveCounter` / `LiveMap` (the spec's `LiveCounterValueType` /
+ * `LiveMapValueType`) obtained from the static `LiveCounter.create(...)` / `LiveMap.create(...)`
+ * factories and the `LiveMapValue` union (mapping §6).
+ *
+ * This is a MIXED spec, and almost all of its assertions land on internal/wire-level state that
+ * ably-java's typed-SDK public API does not expose:
+ *
+ * - The value type's internal blueprint (`vt.count`, `vt.entries[...]`) has **no public accessor**
+ * — `LiveCounter`/`LiveMap` are opaque holders (see their Javadoc: "held internally ... no public
+ * accessor"). So `vt.count == 42` / `vt.entries["name"] == "Alice"` are not expressible; only
+ * construction and the abstract type identity (`is LiveCounter` / `is LiveMap`) are observable.
+ * - The `evaluate(vt)` → `ObjectMessage` generation half (COUNTER_CREATE / MAP_CREATE messages,
+ * nonce / `initialValue` / `objectId` derivation, the `*WithObjectId` wire forms, depth-first
+ * ordering, empty-entries) is internal/wire-level (mapping §13) — there is no public `evaluate`.
+ * - Wrong-typed `create` args (`LiveCounter.create("not_a_number")`, `LiveMap.create(null)`,
+ * non-String keys, unsupported value types) are rejected at **compile time** by the
+ * `create(Number)` / `create(Map)` signatures and the `LiveMapValue` union
+ * (mapping §6) — they cannot be written as runtime `40003` / `40013` assertions.
+ *
+ * What IS faithfully translatable is the public `LiveMapValue` union surface (§6): constructing a
+ * value via `LiveMapValue.of(...)` and inspecting it with `isString()`/`getAsString()` etc. The
+ * entry-value-type-mapping cases (RTLMV4d) are adapted to assert on that public union instead of on
+ * the internal generated `ObjectData`. All internal cases carry an inline `// DEVIATION` and a
+ * matching entry in deviations.md.
+ *
+ * These tests are pure construction — no mocks / `setupSyncedChannel` — so they run today.
+ */
+class ValueTypesTest {
+
+ /**
+ * @UTS objects/unit/RTLCV3/create-with-count-0
+ */
+ @Test
+ fun `RTLCV3 - LiveCounter create with initial count`() = runTest {
+ val vt = LiveCounter.create(42)
+
+ assertTrue(vt is LiveCounter) // RTLCV3b: returns the LiveCounter value type
+
+ // DEVIATION (RTLCV3b): spec asserts `vt.count == 42`, but the value type's internal count
+ // has no public accessor in ably-java (opaque immutable holder). Not expressible.
+ // See deviations.md.
+ }
+
+ /**
+ * @UTS objects/unit/RTLCV3/create-default-zero-0
+ */
+ @Test
+ fun `RTLCV3 - LiveCounter create defaults to 0`() = runTest {
+ val vt = LiveCounter.create()
+
+ assertTrue(vt is LiveCounter)
+
+ // DEVIATION (RTLCV3a1): spec asserts the omitted-count default `vt.count == 0`, but there
+ // is no public accessor for the internal count. Not expressible. See deviations.md.
+ }
+
+ /**
+ * @UTS objects/unit/RTLCV4/evaluate-generates-message-0
+ */
+ @Test
+ fun `RTLCV4 - Evaluation generates COUNTER_CREATE ObjectMessage`() = runTest {
+ // DEVIATION (RTLCV4): the spec calls the internal `evaluate(vt)` and asserts on the generated
+ // COUNTER_CREATE ObjectMessage (action, objectId prefix/derivation, nonce length,
+ // counterCreateWithObjectId.initialValue). There is no public `evaluate`; message generation,
+ // nonce/objectId derivation and the `*WithObjectId` wire form are internal/wire-level
+ // (mapping §13). Only the public construction is exercised here. See deviations.md.
+ val vt = LiveCounter.create(42)
+ assertTrue(vt is LiveCounter)
+ }
+
+ /**
+ * @UTS objects/unit/RTLCV4g5/retains-local-counter-create-0
+ */
+ @Test
+ fun `RTLCV4g5 - Evaluation retains local CounterCreate`() = runTest {
+ // DEVIATION (RTLCV4g5): asserts the evaluated message retains the local `counterCreate`
+ // (`counterCreate.count == 42`) alongside `counterCreateWithObjectId`. Both the evaluation
+ // and the retained CounterCreate are internal/wire-level — not reachable through the public
+ // value type. See deviations.md.
+ val vt = LiveCounter.create(42)
+ assertTrue(vt is LiveCounter)
+ }
+
+ /**
+ * @UTS objects/unit/RTLCV4a/evaluate-validates-count-0
+ */
+ @Test
+ fun `RTLCV4a - Evaluation validates count type`() = runTest {
+ // DEVIATION (RTLCV4a): spec passes a non-Number (`LiveCounter.create("not_a_number")`) and
+ // expects evaluation to fail with 40003. ably-java's `LiveCounter.create(@NotNull Number)`
+ // rejects a String at compile time (mapping §6), so the runtime 40003 assertion is not
+ // expressible. See deviations.md.
+ }
+
+ /**
+ * @UTS objects/unit/RTLCV4/evaluate-zero-count-0
+ */
+ @Test
+ fun `RTLCV4 - Evaluation with count 0`() = runTest {
+ // DEVIATION (RTLCV4): asserts the evaluated message's `counterCreate.count == 0`. The
+ // evaluation and generated CounterCreate are internal/wire-level (mapping §13). Only the
+ // public construction with count 0 is exercised. See deviations.md.
+ val vt = LiveCounter.create(0)
+ assertTrue(vt is LiveCounter)
+ }
+
+ /**
+ * @UTS objects/unit/RTLMV3/create-with-entries-0
+ */
+ @Test
+ fun `RTLMV3 - LiveMap create with entries`() = runTest {
+ val vt = LiveMap.create(
+ mapOf(
+ "name" to LiveMapValue.of("Alice"),
+ "age" to LiveMapValue.of(30),
+ ),
+ )
+
+ assertTrue(vt is LiveMap) // RTLMV3b: returns the LiveMap value type
+
+ // DEVIATION (RTLMV3b): spec asserts `vt.entries["name"] == "Alice"` / `vt.entries["age"] == 30`,
+ // but the value type's internal entries have no public accessor (opaque immutable holder).
+ // Not expressible. See deviations.md.
+ }
+
+ /**
+ * @UTS objects/unit/RTLMV3/create-no-entries-0
+ */
+ @Test
+ fun `RTLMV3 - LiveMap create with no entries`() = runTest {
+ val vt = LiveMap.create()
+
+ assertTrue(vt is LiveMap) // RTLMV3a1: omitted entries still returns a LiveMap value type
+ }
+
+ /**
+ * @UTS objects/unit/RTLMV4/evaluate-generates-message-0
+ */
+ @Test
+ fun `RTLMV4 - Evaluation generates MAP_CREATE ObjectMessage`() = runTest {
+ // DEVIATION (RTLMV4): spec calls internal `evaluate(vt)` and asserts on the generated
+ // MAP_CREATE ObjectMessage (action, objectId `map:` prefix, nonce length,
+ // mapCreateWithObjectId.initialValue). There is no public `evaluate`; message generation and
+ // the `*WithObjectId` wire form are internal/wire-level (mapping §13). Only the public
+ // construction is exercised. See deviations.md.
+ val vt = LiveMap.create(mapOf("name" to LiveMapValue.of("Alice")))
+ assertTrue(vt is LiveMap)
+ }
+
+ /**
+ * @UTS objects/unit/RTLMV4j5/retains-local-map-create-0
+ */
+ @Test
+ fun `RTLMV4j5 - Evaluation retains local MapCreate`() = runTest {
+ // DEVIATION (RTLMV4j5): asserts the evaluated message retains the local `mapCreate`
+ // (`mapCreate.semantics == "LWW"`, `mapCreate.entries["name"].data.string == "Alice"`)
+ // alongside `mapCreateWithObjectId`. Evaluation and the retained MapCreate are
+ // internal/wire-level. See deviations.md.
+ val vt = LiveMap.create(mapOf("name" to LiveMapValue.of("Alice")))
+ assertTrue(vt is LiveMap)
+ }
+
+ /**
+ * @UTS objects/unit/RTLMV4d/entry-value-types-0
+ *
+ * The spec asserts the value-type → `data.*` field mapping on the generated `MapCreate` entries
+ * (internal/wire-level). Adapted to assert the public `LiveMapValue` union surface (mapping §6):
+ * each input wraps to a `LiveMapValue` whose `is*` discriminant and `getAs*` accessor match.
+ */
+ @Test
+ fun `RTLMV4d - Entry value type mapping`() = runTest {
+ val str = LiveMapValue.of("hello")
+ assertTrue(str.isString)
+ assertEquals("hello", str.asString) // RTLMV4d4: String -> data.string
+
+ val num = LiveMapValue.of(42)
+ assertTrue(num.isNumber)
+ assertEquals(42, num.asNumber.toInt()) // RTLMV4d5: Number -> data.number
+
+ val bool = LiveMapValue.of(true)
+ assertTrue(bool.isBoolean)
+ assertEquals(true, bool.asBoolean) // RTLMV4d6: Boolean -> data.boolean
+
+ val jsonArr = JsonArray().apply {
+ add(1)
+ add(2)
+ add(3)
+ }
+ val arrValue = LiveMapValue.of(jsonArr)
+ assertTrue(arrValue.isJsonArray)
+ assertEquals(jsonArr, arrValue.asJsonArray) // RTLMV4d3: JsonArray -> data.json
+
+ val jsonObj = JsonObject().apply { add("key", JsonPrimitive("value")) }
+ val objValue = LiveMapValue.of(jsonObj)
+ assertTrue(objValue.isJsonObject)
+ assertEquals(jsonObj, objValue.asJsonObject) // RTLMV4d3: JsonObject -> data.json
+
+ val bytes = byteArrayOf(1, 2, 3)
+ val binValue = LiveMapValue.of(bytes)
+ assertTrue(binValue.isBinary)
+ assertContentEquals(bytes, binValue.asBinary) // RTLMV4d7: Binary -> data.bytes
+
+ // DEVIATION (RTLMV4d): the spec inspects the generated `MapCreate.entries[k].data.`
+ // (internal/wire-level). The public union inspection above is the equivalent observable
+ // surface; the generated message itself is not reachable. See deviations.md.
+ }
+
+ /**
+ * @UTS objects/unit/RTLMV4d1/nested-value-types-0
+ */
+ @Test
+ fun `RTLMV4d1, RTLMV4d2 - Nested value types produce depth-first ObjectMessages`() = runTest {
+ // DEVIATION (RTLMV4d1/RTLMV4d2/RTLMV4k): the spec evaluates a nested value-type tree and
+ // asserts on the generated, depth-first-ordered COUNTER_CREATE/MAP_CREATE messages, their
+ // `objectId` prefixes, and the cross-referencing `entries[k].data.objectId` linking nested
+ // creates. Evaluation, objectId derivation and message ordering are internal/wire-level
+ // (mapping §13). Only the public nested construction is exercised. See deviations.md.
+ val innerCounter = LiveCounter.create(10)
+ val innerMap = LiveMap.create(mapOf("nested_count" to LiveMapValue.of(innerCounter)))
+ val outer = LiveMap.create(mapOf("child" to LiveMapValue.of(innerMap)))
+
+ assertTrue(outer is LiveMap)
+ }
+
+ /**
+ * @UTS objects/unit/RTLMV4a/evaluate-validates-entries-0
+ */
+ @Test
+ fun `RTLMV4a - Evaluation validates entries type`() = runTest {
+ // DEVIATION (RTLMV4a): spec passes `LiveMap.create(null)` and expects evaluation to fail with
+ // 40003. ably-java's `LiveMap.create(@NotNull Map)` rejects null at
+ // compile time (mapping §6), so the runtime 40003 assertion is not expressible.
+ // See deviations.md.
+ }
+
+ /**
+ * @UTS objects/unit/RTLMV4b/evaluate-validates-keys-0
+ */
+ @Test
+ fun `RTLMV4b - Evaluation validates key types`() = runTest {
+ // DEVIATION (RTLMV4b): spec passes a non-String key (`{ 123: "value" }`) and expects 40003.
+ // ably-java's `create(Map)` enforces String keys at compile time
+ // (mapping §6); a non-String key cannot be constructed. Not expressible. See deviations.md.
+ }
+
+ /**
+ * @UTS objects/unit/RTLMV4c/evaluate-validates-values-0
+ */
+ @Test
+ fun `RTLMV4c - Evaluation validates value types`() = runTest {
+ // DEVIATION (RTLMV4c): spec passes an unsupported value (a function) and expects 40013.
+ // ably-java's `LiveMapValue` union only constructs from the supported types (Boolean, byte[],
+ // Number, String, JsonArray, JsonObject, LiveCounter, LiveMap), so an unsupported value is
+ // rejected at compile time (mapping §6). Not expressible. See deviations.md.
+ }
+
+ /**
+ * @UTS objects/unit/RTLMV4e2/empty-entries-0
+ */
+ @Test
+ fun `RTLMV4e2 - Empty entries produces MapCreate with empty entries`() = runTest {
+ // DEVIATION (RTLMV4e2): asserts the evaluated message's `mapCreate.entries == {}` for a
+ // no-entries value type. Evaluation and the generated MapCreate are internal/wire-level
+ // (mapping §13). Only the public empty construction is exercised. See deviations.md.
+ val vt = LiveMap.create()
+ assertTrue(vt is LiveMap)
+ }
+
+ /**
+ * @UTS objects/unit/RTLMV4d/map-set-all-types-table-0
+ *
+ * Spec table asserts every supported value type maps to the correct generated `data.*` field.
+ * Adapted to the public `LiveMapValue` union (mapping §6): each input wraps and is inspected via
+ * its `is*` discriminant + `getAs*` accessor. The generated `MapCreate` `data` is internal.
+ */
+ @Test
+ fun `RTLMV4d - Table-driven MAP_SET value type mapping`() = runTest {
+ // string -> isString / "hello"
+ LiveMapValue.of("hello").let {
+ assertTrue(it.isString)
+ assertEquals("hello", it.asString)
+ }
+ // number 42 / 3.14 / 0 / -1 -> isNumber
+ listOf(42, 3.14, 0, -1).forEach { n ->
+ val v = LiveMapValue.of(n)
+ assertTrue(v.isNumber)
+ assertEquals(n.toDouble(), v.asNumber.toDouble())
+ }
+ // boolean true / false -> isBoolean
+ listOf(true, false).forEach { b ->
+ val v = LiveMapValue.of(b)
+ assertTrue(v.isBoolean)
+ assertEquals(b, v.asBoolean)
+ }
+ // json array [1, "a", null] -> isJsonArray
+ val arr = JsonArray().apply {
+ add(1)
+ add("a")
+ add(com.google.gson.JsonNull.INSTANCE)
+ }
+ LiveMapValue.of(arr).let {
+ assertTrue(it.isJsonArray)
+ assertEquals(arr, it.asJsonArray)
+ }
+ // json object { "k": "v" } -> isJsonObject
+ val obj = JsonObject().apply { add("k", JsonPrimitive("v")) }
+ LiveMapValue.of(obj).let {
+ assertTrue(it.isJsonObject)
+ assertEquals(obj, it.asJsonObject)
+ }
+ // bytes([1,2,3]) -> isBinary (spec's "AQID" is the base64 of the generated wire bytes)
+ val bytes = byteArrayOf(1, 2, 3)
+ LiveMapValue.of(bytes).let {
+ assertTrue(it.isBinary)
+ assertContentEquals(bytes, it.asBinary)
+ }
+
+ // DEVIATION (RTLMV4d): the spec asserts on the generated `MapCreate.entries["test_key"]
+ // .data[field]` (internal/wire-level), including the base64 "AQID" wire encoding of the
+ // binary. The public union inspection above is the equivalent observable surface; the
+ // generated message and its base64 encoding are not reachable. See deviations.md.
+ }
+}