ADR-0068: The watermark is the integer seq on REST and every v1 surface
| Status | Proposed |
| Date | 2026-10-14 |
| Issue | #63 |
| Pull request | (this PR) |
| Related | ADR-0006 (its opaque-watermark part is superseded), ADR-0024, ADR-0050, ADR-0052 |
Context and problem statement
ADR-0006 made the client-facing watermark an
opaque token: URL-safe Base64 of seq:<n>. ADR-0024
then defined Converger Protocol v1 as a superset of mekik/1, where the watermark is the integer
seq itself, and planned the migration in section 6.5 of the spec:
v3.0 emits the integer in every v1 frame and in REST activitySet.watermark, keeps the string on
the legacy Phoenix activitySet, and accepts every form for one release.
The v1 frames (native WebSocket, gRPC, SSE) already carried integers. REST still returned the
opaque string, so a client that read history over REST and resumed over a v1 socket had to convert
between two encodings of the same number (every SDK shipped a decoder for it). The tenant API
(meta.watermark of GET /api/v1/conversations/:id/activities) used the same opaque form and the
section 6.5 table did not mention it.
Decision drivers
- One watermark encoding across REST, v1 frames, SSE and gRPC, identical to mekik/1.
- No client breaks during the window: a stored opaque watermark must keep working.
- Operators need to find the clients still sending opaque watermarks before the forms are dropped.
- The legacy Phoenix binding is frozen until it is removed; changing its payloads now helps no one.
Considered options
- Integer on REST (Converger and tenant API) and v1, string on the legacy Phoenix binding; every input accepts all forms, opaque ones reported as deprecated
- Integer on the Converger REST API only, tenant API unchanged
- Emit both (
watermarkinteger plus alegacyWatermarkstring) during the window
Pros and cons of the options
Option 1
- Good, because a single type (
integer) describes the watermark in the whole OpenAPI document except the legacy binding, and the migration ends with "integer everywhere" for every surface. - Good, because clients that pass the watermark back unchanged keep working: the server accepts what it used to emit.
- Bad, because typed clients that declared the field as a string must widen it (a breaking change for strict deserialisers, acceptable in the v3.0 major release).
Option 2
- Good, because tenant integrations see no change now.
- Bad, because the tenant API would then need its own migration later, and the two APIs would disagree on the type of the same position.
Option 3
- Good, because no deserialiser breaks.
- Bad, because it doubles the field, and the string would have to be removed again one release later, which is the same break, deferred.
Decision
Chosen option: "Option 1", because it reaches the end state of section 6.5 for every surface in one step, without breaking clients that treat the watermark as a token to hand back.
- Emitted: the integer seq of the last activity served, a JSON number, in
activitySet.watermark(GET /api/v1/converger/conversations/:id/activities, both directions andformat=frames), inmeta.watermarkof the tenant API, and (as before) in every v1 frame. On an empty page the server returns the position the client sent, as an integer whatever form it came in (a legacy activity-id watermark is resolved to that activity's seq);nullwithout one. - Legacy Phoenix binding (
/socket/converger,ConvergerChannel):activitySet.watermark, thepostActivityreply and thereplay_unavailableerror keep the opaque string for one release. Its join payload,ackandreadaccept the integer as well. - Accepted on every input (
ConvergerWeb.Protocol.parse_watermark/2): the integer, its decimal string, base64url ofseq:<n>and the base64url legacy activity UUID. - Deprecation: an opaque watermark sent to a surface that emits integers (Converger REST,
tenant API, v1
hello/sync/hello.watermarks, SSE) logs a warning and emits[:converger, :deprecated, :use]withsurface: :opaque_watermarkandapi:(:converger,:tenant,:protocol,:sse). NoDeprecationresponse header: the endpoint itself is not deprecated. The legacy Phoenix binding does not report it, because it still emits the string. - OpenAPI: response
watermarkfields areinteger, nullable;watermarkquery parameters areoneOfinteger or string (the string branch marked deprecated).
Consequences
Positive
- REST and socket positions are the same number: a client can resume a v1 socket from a REST page and page REST from a v1 frame without decoding anything.
- The section 6.5 end state ("integer only") is now a pure removal of input forms.
Negative and trade-offs
- Breaking for typed clients: SDKs and integrations that declared
watermarkas a string must accept a number. The SDKs inpackages/accept both and pass the value back unchanged. - A client of the legacy Phoenix binding that pages the rest of a truncated replay over REST sends the binding's opaque watermark, which is reported as deprecated although the server issued it. The binding is itself deprecated, so the report still points at a client to migrate.
Follow-ups
- The release that drops the opaque input forms (and switches the legacy Phoenix binding to the integer, or removes it) is announced in the changelog one release ahead.
Implementation
ConvergerWeb.Protocol.parse_watermark/2(accepts every form;deprecation:reports opaque ones) andwatermark_seq/2(the integer for an empty page).ConvergerWeb.ConvergerAPI.ActivityControllerandConvergerWeb.ActivityControlleremit the integer;ConvergerWeb.Deprecationhas the:opaque_watermarksurface.ConvergerWeb.ConvergerChannelandConvergerWeb.ConversationSignalsparse inputs withparse_watermark/2;Converger.ConvergerAPI.Watermark.encode/1is used only by the legacy binding.- Tests:
test/converger_web/pagination_api_test.exs(integer emitted on both REST APIs, the three input forms, deprecation telemetry, legacyactivitySetunchanged),test/converger_web/protocol_socket_test.exs,test/converger_web/controllers/event_stream_controller_test.exs, and the shared REST conformance cases inpackages/conformance/rest.json.