Skip to content

feat(remote-config): expose the v2 snapshot API - #865

Closed
shameondev wants to merge 3 commits into
codex/rc-v2-transportfrom
codex/rc-v2-public-api
Closed

feat(remote-config): expose the v2 snapshot API#865
shameondev wants to merge 3 commits into
codex/rc-v2-transportfrom
codex/rc-v2-public-api

Conversation

@shameondev

Copy link
Copy Markdown
Contributor

What

Stacked on #864. The client-facing contract as an opt-in experimental surface (@ExperimentalQonversionApi — new @RequiresOptIn marker; dormant unless setRemoteConfigV2Config is supplied; no default-on, no prod constants):

  • Qonversion.remoteConfigSnapshots() facade: fetch(timeoutMs?), activate, fetchAndActivate, immutable current, fallbackRemoteConfigValue, subscribeOnConfigUpdate
  • snapshot reads return QRemoteConfigValue{value, source(Server|Cache|Fallback), variationUid, applyPolicy, metadataJson}; rawValue/jsonValue/typed value(decoder)
  • update listener with changed-keys diff; immediate policy semantics per the accepted atomic-swap decision
  • identity: scope flips before the transport scope (no cross-identity admission window), identify() that keeps the uid still refreshes targeting via a new bridge; device install date survives logout
  • assembled by a self-contained factory (RedemptionManager precedent), not Dagger

Verification

  • ./gradlew :sdk:test detektAll: BUILD SUCCESSFUL, 429 tests (agent runs ×3 incl. --rerun-tasks + independent re-run by the orchestrating session); assembleRelease OK
  • two adversarial review passes fixed: a main-thread deadlock in listener delivery, the cross-identity admission window, identify()-same-uid never refreshing v2, "null" metadata string, TimedOut snapshot semantics, distinguishable refused-snapshot logging, plus a latent ordering flake in the pre-existing coordinator test

Cross-repo follow-up (both SDK slices hit this independently)

context_fingerprint is computed by configurator with a server secret, so the SDK cannot derive it — it is temporarily an explicit QRemoteConfigV2Config parameter. The gateway session-bootstrap response should return it; then the parameter disappears. Until then cold-start integration requires the hand-off — consistent with the surface being dormant.

Adds the customer-facing Remote Config v2 surface on top of the internal
snapshot core, fetch coordinator and gateway transport landed in the
previous slices. Nothing here re-implements them: the public types are
thin, immutable adapters over the resolution ladder, the read guard and
the coordinator's waiter model.

The surface (all marked @ExperimentalQonversionApi, so it is not yet a
stability promise):

- Qonversion.remoteConfigSnapshots() -> QRemoteConfigSnapshots, with
  fetch(timeoutMs?), activate(), fetchAndActivate(), an immutable
  `current` snapshot, a synchronous bundled-fallback getter and
  subscribeOnConfigUpdate().
- Reads return {value, source}: raw JSON, an opaque JSON tree, or a
  caller-decoded type. The decoder is the per-key validator seam, so a
  rejected value falls to the previously activated release (cache) and
  then to the bundled defaults.
- fetch's timeout bounds the wait, not the request: the completion
  reports the best available snapshot while the request keeps running and
  is still admitted when it lands.
- Activation stays a whole-release atomic swap; an immediate-policy
  release performs that same swap on admission and notifies subscribers
  with the changed-key diff and per-key metadata.
- Identity changes switch the scope synchronously (the previous
  identity's release is never readable afterwards) and force a fetch; an
  identify that only attaches an external id re-reads targeting without
  dropping the served release.

The pipeline is dormant unless the app passes a QRemoteConfigV2Config:
without it no store, thread, HTTP client or base URL is constructed, and
every fetch completes with NotConfigured. There is no default endpoint.

Tests cover the contract end to end against a real MockWebServer with the
real core, guard and coordinator: timeout semantics, change detection,
all three ladder positions, raw vs typed reads, the pre-activate fallback
getter, subscription diffs, immediate auto-activation, the identity
switch (old snapshot excluded, per-identity session, install date pinned
against a real PackageManager) and main-thread delivery.

Claude-Session: https://claude.ai/code/session_018pHXqfbxkMQJFzUZ3jW4A8
QRemoteConfigV2Config demanded a `contextFingerprint`: the app had to hand the
SDK the fingerprint the gateway resolves for it, and an admitted snapshot had to
carry exactly that value. Nobody could supply it correctly, because it is not an
app-level constant at all.

configurator computes it in BuildResolvedSnapshotContextFingerprint
(internal/domain/remoteconfigv2/resolved_snapshot.go) by hashing the canonical
user uid, randomization id, platform, country, app version, OS version, SDK
version, locale, device model, media source / campaign, install and created
timestamps, purchases, active experiment uids and custom user properties. It is
a per-response tag over mutable targeting inputs, not an identity binding: it
rotates on any app or OS update, a language switch, a purchase, a property edit
or an experiment enrollment.

So the fingerprint is treated as what it is:

- it is gone from the public QRemoteConfigV2Config and from the internal
  RemoteConfigSnapshotEnvelopeExpectation — nothing configures it and nothing
  compares it against a previous response;
- the parser keeps validating its *shape* (64 lowercase hex, required member) and
  carries it through as an opaque per-response tag; a value that changes between
  two admissions in the same scope is normal and admitted;
- it is still stored with the release, as informational data for logs and bug
  reports, and it still participates in the release content digest;
- the KDoc on the public config, the expectation and the release all state the
  rule verbatim, so the "pin it across fetches" idea does not get re-invented:
  pinning it would freeze an identity's config until logout the first time the
  user updated the app or changed their language.

Identity isolation is unchanged and stays where it already lives: each snapshot
read travels on a session token minted for exactly one identity, the gateway
routes on that session, and the snapshot / session / fetch-policy stores address
each identity through its own salted scope digest.

No compatibility shim: the surface is @ExperimentalQonversionApi and has never
shipped as stable, so the constructor parameter is simply removed.

Tests: a rotated fingerprint is admitted end to end through the public API over
MockWebServer (and at the core, where the previously admitted release keeps its
own tag), every malformed or missing fingerprint is still refused by the parser,
and the project / environment admission boundaries are unchanged.

Claude-Session: https://claude.ai/code/session_018pHXqfbxkMQJFzUZ3jW4A8
The numeric project id a v2 snapshot is admitted against was supplied by
the app through QRemoteConfigV2Config, but the app is not its source: the
SDK is told it by the gateway's session bootstrap. Asking for it added a
public value that could only ever be typed wrong.

BREAKING (experimental surface): QRemoteConfigV2Config no longer takes
projectId. Callers drop the argument; nothing else changes for them.

The id is now learned and pinned per project key + environment by
RemoteConfigProjectIdRegistry, durably, next to the session state: the
first bootstrap establishes it, every later session must agree, and one
that does not is refused as the typed RemoteConfigFetchResponse
.ProjectMismatch before a snapshot is ever read — never re-learned. The
in-memory pin is authoritative for the process, so a storage failure
cannot downgrade a conflict into a silent re-learn. A malformed id is
reported apart from a conflict and stays an ordinary failure.

Because the pin is established mid-fetch, the envelope expectation moved
from the admission claim to the admission itself: beginAdmission takes
only the scope, admitCandidate takes the project id the response was
served for, and the environment is read from the admitting scope rather
than restated. RemoteConfigFetchBinding was exactly a scope plus that
expectation, so it is gone and the coordinator binds to the scope.

A mismatch feeds the failure backoff. It is permanent until the gateway
is fixed and costs a bootstrap round trip each time, and forced fetches
bypass the minimum interval but not the backoff gate, so an
identify/logout loop cannot turn a misrouted gateway into a request
storm.

The check this buys is server-vs-server consistency plus trust on first
bootstrap, not proof that a snapshot belongs to the project the
developer meant to target; QRemoteConfigV2Config's KDoc says so.
@shameondev

Copy link
Copy Markdown
Contributor Author

Консолидировано в release-train PR (один PR на репозиторий). Ветка сохранена.

@shameondev shameondev closed this Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant