Skip to content

Remote Config v2: client core, transport and experimental API (release train) - #761

Open
shameondev wants to merge 47 commits into
mainfrom
release/rc-v2
Open

Remote Config v2: client core, transport and experimental API (release train)#761
shameondev wants to merge 47 commits into
mainfrom
release/rc-v2

Conversation

@shameondev

Copy link
Copy Markdown
Contributor

Консолидированный PR программы Remote Config v2 (qonversion-ios-sdk)

Один PR вместо стека #753#760. Поверхность спит (QON_EXPERIMENTAL, configureWithBaseURL: никто не зовёт), прод-константы не тронуты:

Верификация: harness'ы public 123/123, transport 169/169, coordinator 57/57, read-guard 69/69 (счётчики честные — захардкоженные вскрыты и заменены), мутационные пробы, adversarial reviews; hosted CI (pod lint и др.) зелёный на инкрементах. Заменяет и закрывает: #753#760.

Adds the internal Remote Config v2 transport adapter that drives the dark
gateway session and snapshot routes behind the existing fetch-policy
coordinator seam.

- bootstrap on a missing or expired session, single re-bootstrap on a
  snapshot 401, typed failure on the second 401 so no loop can form
- snapshot bytes and the strong ETag are handed to admission exactly as
  received; the If-None-Match validator is forwarded verbatim
- session tokens are persisted per identity scope, so an identity change
  can never reuse the previous identity's token
- the device install date is stored under one unscoped key and survives
  logout, keeping a long-time installation out of new-user targeting

No production constants, wiring or public API change: the base URL is
injected and the adapter is not constructed by the SDK yet.
Review follow-ups on the transport adapter:

- reject a bootstrap session issued for another environment instead of
  storing it under this scope and failing invisibly at admission
- treat Retry-After: 0 as absent so it cannot erase the coordinator's own
  exponential backoff
- build route URLs through NSURLComponents so a base URL carrying a query
  or fragment cannot swallow the path
- require session and project tokens to be sendable header values, since
  CFNetwork drops an invalid header silently and leaves the request
  unauthenticated with no error
- treat a 304 answering an unconditional request as malformed
- drop the retired identity's session token on an identity change
Adds the public API for the Remote Config v2 stack on top of the existing
snapshot core, fetch coordinator and gateway transport.

- Qonversion.shared().experimentalRemoteConfig returns a controller that
  always exists but stays dormant: with no engine installed it reads the
  bundled defaults, refuses to fetch and never touches the network
- fetch(timeout:) completes on the best available configuration and leaves
  the request running in the background; the completion still carries a
  readable snapshot, so every read reports its value source
- activate() reports whether the published configuration changed;
  fetchAndActivate() does both in one call
- reads go through the immutable current snapshot: a typed read walks
  server -> cache -> fallback through the caller's decoder, a raw read
  reports the served value without validating it
- subscribeOnConfigUpdate delivers the changed-key diff and metadata; an
  immediate apply policy swaps the whole release atomically without an
  activate call
- an identity change unbinds the retired scope synchronously, so the
  previous identity's configuration stops being readable before the call
  returns, then rebinds off main and forces a fetch; a superseded switch
  is fenced by an identity epoch and can never rebind its scope

The manager gains an unguarded snapshot accessor so the SDK's own delivery
reads cannot consume the app's one read-before-activate opportunity. The
guarded read stays the only public path, keeping the debug assertion and
the release-build one-time silent activation intact.

No production constants, defaults or wiring change: the engine is only
assembled when a caller supplies a base URL explicitly, and the identify
and logout hooks are pass-through while the surface is dormant.
A timed-out fetch read the snapshot through the read guard, so a slow
network raised the debug assertion — and consumed the release build's
one-time silent activation — for an app that had done nothing wrong.

Every SDK-internal snapshot read now goes through the unguarded accessor.
The guarded read stays exactly one path: the app's own `current`. A fetch
result therefore reports the configuration that is active at that moment
and never activates anything on the caller's behalf, which is also the
only reading consistent with `fetch` not swapping what the app reads.
Findings from an adversarial pass over the new public surface.

- the coordinator's own timeout read the snapshot through the read guard,
  which put back exactly the false positive the previous commit removed:
  the fetch-core protocol now demands the unguarded accessor, so no SDK
  path can spend the app's one read-before-activate opportunity
- binding a scope re-arms that guard, so every identify and logout used to
  accuse an app that had already activated, and on a release build left it
  reading the pre-activation state because the implicit activation is a
  lifetime budget the startup read already spent; an identity change now
  publishes the new identity's persisted state on both sides of the rebind
- a failed identify no longer tears the surface down: the canonical
  identity did not move, so there is nothing to rebind
- subscribing before the SDK is configured used to drop the handler
  silently and forever; subscriptions are now kept and replayed in order
  when the engine is installed, with tokens that survive the boundary
- the read-guard build mode is a caller decision instead of the SDK's own
  compile flavour, which describes the wrong build for a binary release
- config requests use an ephemeral URL session, so a response keyed to a
  canonical identity cannot outlive it in the app's shared cache
- a scheduler that cannot arm the deadline fails the call instead of
  silently making it wait the whole policy timeout
- the bundled fallback release is built once instead of on every read,
  including the pre-configuration reads that do the file I/O

Tests: the public-surface harness grows the dormant-subscription replay,
the real-assembly install, and the identity/read-guard regressions; the
install-date case is renamed to what it actually pins.
…sioning it

The admission expectation used to demand a `context_fingerprint` from the
caller, supplied per scope by a `bindingProvider` block. That is the wrong
shape: the gateway derives the fingerprint from the full client context, so
nothing on the device knows it before the first response arrives, and no real
caller could ever fill it in.

The fingerprint is now trust-on-first-use. The first strictly validated
envelope of an identity scope pins its fingerprint; every later admission in
that scope must match it or is refused through the existing mismatch class.
Only the very first fetch of a scope is unpinned, and that one is already
protected by session-bound routing.

- QONRemoteConfigV2EnvelopeExpectation takes a nullable fingerprint: nil means
  "not pinned yet", and the strict parser skips only that one comparison
- the manager owns the pin. It resolves it inside the same state lock that
  claims the admission, so a pin established between the parse and the commit
  cannot be overwritten, and an unpinnable scope fails closed rather than
  admitting an unverifiable release
- QONRemoteConfigV2ContextPinStore persists the pin exactly like the gateway
  session token: keyed by the full scope, identity included, and written only
  after read-back verification. A restart therefore cannot re-pin a scope that
  is already pinned; an identity change resets which pin is in force, and
  coming back to an identity restores its own
- acceptFetchedRelease honours an existing pin too, though it never pins: it
  takes an already-built release rather than a validated envelope

Breaking change on the experimental surface: QONRemoteConfigBindingProvider is
gone. installEngine and configureWithBaseURL take a projectID instead and build
the binding themselves; 0 or less leaves the surface unbindable, which is how a
caller with no project identity keeps the engine off the network.

Tests: the public harness grows the pin-store contract, the first-admission
pin, the refusal of a second context, the survival of a store re-creation and
the scope-reset re-pin. Both mutations — dropping the pin check and dropping
its durability — are caught.
…mitted

Findings from an adversarial pass over the trust-on-first-use pin.

- validation was being mistaken for admission: the pin was written before the
  release-number floor and the tombstone rebuild, so one replayed response from
  another client context — parsed, then dropped as too old — pinned the scope
  to that context and locked the install out of every later release, across
  restarts, with no recovery path. Pinning now happens after the release has
  survived every check, immediately before the state is saved
- a scope whose pin store is empty but whose disk already holds a release is
  not unpinned: that release was admitted under its own fingerprint, and that
  is the scope's real first use. Without this, an install predating the pin
  store — or one whose pin write never landed — let the next response re-pin
  it to any context at all
- acceptFetchedRelease's exemption for releases with no fingerprint is now
  stated rather than implied: that seam only ever takes locally built releases,
  which make no claim about a client context. Wire bytes go through admitBody:
- QONRemoteConfigV2ManagerTests admitted two different fingerprints into one
  scope and expected the second to be accepted, which is exactly the semantic
  this work removes. The re-render case now moves only the variation, and the
  changed fingerprint is asserted to be refused

Tests: the harness grows the floor-drop regression (fails without the ordering
fix), the persisted-state fallback (fails without it), the unpinnable-device
fail-closed path and the stated-fingerprint narrowing. The pin store is now
injectable so an unpinnable device can be modelled; the unused read counter
added with the previous commit is gone.

Harnesses: public 141/141, transport 112/112, coordinator 16/16, read guard
13/13.
The trust-on-first-use pin from the previous two commits was wrong, and the
server settles it: BuildResolvedSnapshotContextFingerprint (configurator,
internal/domain/remoteconfigv2/resolved_snapshot.go:34-73) hashes AppVersion,
OsVersion, SdkVersion, Locale, DeviceModel, Purchases, ActiveExperimentUIDs and
CustomUserProperties alongside the identity. It rotates on any app or OS
update, locale change, purchase, property write or experiment enrolment — all
of which are normal. Pinning it across fetches would freeze the scope on the
first response and refuse every later one until logout. Forever, and silently.

The fingerprint is an opaque per-response tag binding one envelope to the exact
server-side context that produced it. The client checks its shape and stores it
with the release; it never compares one response's against another's. Identity
isolation is what it always was: the per-scope session token, the server's
session-bound routing, and the per-scope storage keys.

- QONRemoteConfigV2ContextPinStore is deleted, along with its pbxproj entries.
  Nothing else referenced it
- the manager is byte-identical to its pre-pin state: no pin cache, no scope
  reset hook, no admission comparison, no acceptFetchedRelease check
- QONRemoteConfigV2ValidContextFingerprint survives because the shape check is
  real and now has production callers: the envelope parser and the expectation
  initialiser both go through it instead of the unnamed local predicate
- expectationByPinningContextFingerprint: is gone with its only caller
- the header docs carry the rationale verbatim, on the validator, on the
  expectation, on the release property and on the fetch binding, so the next
  engineer does not re-derive the pin from first principles and ship it

Kept from the earlier commits: the expectation's nullable fingerprint, which is
the shape a caller can actually produce, and the removal of the bindingProvider
block in favour of a projectID.

Tests: the pin suite is replaced by one that holds the corrected contract — a
rotation between two fetches in one scope is admitted, a rotation across a
restart is admitted, a malformed fingerprint is refused at every shape, a
stated fingerprint constrains only its own call, and identity isolation is
asserted through the scoped session-store keys and per-identity reads. A
re-introduced cross-fetch pin fails three of them. QONRemoteConfigV2ManagerTests
returns to its original form: its equal-release context re-render across two
fingerprints is correct again.

Harnesses: public 119/119, transport 112/112, coordinator 16/16, read guard
13/13. plutil -lint clean.
… caller

installEngineWithManager: and configureWithBaseURL: took a caller-supplied
numeric projectID: and treated `<= 0` as an in-band "unbindable" sentinel. That
was wrong twice over. The numeric project_id is not something an app knows —
the SDK learns it from the gateway's session bootstrap response
({"session_token","project_id","environment","expires_at"}), which is the only
party that knows it — and an in-band sentinel meant a caller could silently
disable the surface by passing a value that looked like data.

BREAKING (experimental surface, off by default, no shipping caller):

- installEngineWithManager:coordinator:projectKey:environment:scopeSink:
  scheduler:identityQueue: — projectID: removed
- configureWithBaseURL:...clientContextProvider: — projectID: removed
- QONRemoteConfigV2FetchBinding initWithScope: — projectID and the pre-built
  expectation are gone; a binding is a scope
- QONRemoteConfigV2Manager beginAdmissionForScope: — no expectation argument;
  there is none to state before the request that learns the id
- QONRemoteConfigV2Manager admitBody:strongETag:projectID:admissionToken: —
  the learned id arrives with the bytes it constrains
- QONRemoteConfigV2FetchResponse successWithBody:strongETag:projectID:
- QONRemoteConfigV2GatewayTransport initWithBaseURL:...projectIdentityStore:...

How it now works:

- the transport already parsed project_id out of the bootstrap body into
  QONRemoteConfigV2GatewaySession; it now reconciles it against a new
  QONRemoteConfigV2ProjectIdentityStore before the session is stored, and puts
  the reconciled id on the success response
- the manager builds the QONRemoteConfigV2EnvelopeExpectation at admission time
  from that id plus the admission token's own environment, so an envelope from
  another project is refused by the parser rather than admitted. projectID 0 —
  what a response that learned nothing carries — yields no expectation at all,
  which refuses the body instead of admitting it unconstrained
- first bootstrap of a project establishes the id; a later bootstrap stating a
  different one is a hard typed failure
  (QONRemoteConfigV2TransportFailureKindProjectIdentityConflict), never a
  re-learn, and the conflicting session is not persisted. A non-durable id is
  QONRemoteConfigV2TransportFailureKindProjectIdentityPersistenceFailed and also
  refuses to fetch

The ledger mirrors the session store — schema-versioned record, SHA-256 framed
key, scope_key echoed inside, write-then-read-back — with one deliberate
difference: it is keyed by project key and environment only, never by the
identity. A project_id belongs to the project, not to the user, and a per-user
ledger would let a logout and a fresh login launder a conflicting id past the
check. It also outlives the session record on purpose: a 401 drops the token and
re-bootstraps, and that is exactly the moment the learned id must still be there
to compare against.

Also: QONRemoteConfigV2GatewaySession now rejects a project_id beyond
QONRemoteConfigV2MaximumSafeInteger, the same ceiling the envelope expectation
enforces. An id that could never be checked against an envelope is not a session
worth keeping, and the bootstrap reports it as malformed.

The "unbindable" harness scenario is re-expressed honestly: an assembly with no
identity yet has no scope to bind, so it still installs, still reads its bundled
defaults, and still never reaches the network.

Tests: bootstrap establishes the id and the success carries it; a reused session
reports it without re-bootstrapping; a conflicting re-bootstrap is a typed
failure that neither re-learns nor stores the session; the record survives store
re-creation and still conflicts afterwards; the ledger key is identity-free
while the session key is not; a non-durable id fails the fetch before the
snapshot request; an out-of-range id is malformed; an envelope from another
project — and one admitted under projectID 0 — is refused and publishes nothing.
The manager's stated-fingerprint case moved to the parser, which is the only
caller that can still state one.

Harnesses: public 123/123, transport 138/138, coordinator 16/16, read guard
13/13. plutil -lint clean. The XCTest suites type-check under -fsyntax-only
against a minimal XCTest shim (no new warnings).
…otstrap teach it

Review of the previous commit found two ways the new ledger was wrong and four
claims its tests did not actually hold down.

A conflict is terminal by design — nothing re-learns the id, and nothing clears
the record — so the key has to separate everything that legitimately answers
with a different project_id. It did not: it framed only the project key and the
environment, while configureWithBaseURL: also takes a base URL and a project
token. Pointing one project key at a staging gateway and then at production, or
swapping the token, produced a real id change and would have bricked the surface
permanently on a routine environment switch. The key now frames a digest of the
base URL and the project token as well, computed once at construction so the
token is never held beside the record it keys. Schema tag bumped to v2; the old
records are simply never read again.

Second, the snapshot path called establishProjectID: with the id off a *stored*
session, so a persisted token could teach the ledger — the exact opposite of
"learn it from the bootstrap, which is the only party that knows it". Now only
the bootstrap path establishes. validSessionForScope: instead refuses to hand
back a session the ledger cannot already vouch for, dropping it so the fetch
bootstraps and learns, or conflicts, honestly. That also removes the second
ledger read every fetch was doing.

Test gaps the review proved by mutation — each of these changes now fails a
harness check where before it passed silently:

- the reuse-path check had no test at all: deleting it left every harness green.
  Covered by a stored session the ledger does not know, and by one that
  disagrees; both must re-bootstrap rather than fetch
- the coordinator's own `response.projectID > 0` guard had no test — the
  existing one called the manager directly and bypassed it. Covered in the
  coordinator harness with a success carrying 0
- `…OutcomeUnusable` was unreachable, and the "out of range" test could not tell
  it apart from the session initializer's ceiling, since both surface as
  BootstrapMalformed. Each is now pinned at its own layer, and the store's
  refusal is asserted directly
- QONRemoteConfigV2ProjectIdentityStore.m's int64 helper matched the string
  terminator, so an empty type encoding would have read as signed

Also: the coordinator and read-guard harnesses printed a hardcoded "16/16" and
"13/13" that counted scenarios, stopped counting the moment a scenario was
added, and reported a full pass for a partial run. They now count checks like
the other two harnesses do — 57 and 69.

Known and accepted: a genuine conflict, once the deployment is the same, is
terminal for the installation with no reset path. That is the specified
behaviour — re-learning is exactly what must not happen — and with the key now
carrying the deployment, the plausible false positives are gone. Retries stay
gated by the fetch policy's backoff, so a conflicting install falls back to its
bundled defaults and re-attempts at most hourly; it never admits.

Harnesses: public 123/123, transport 169/169, coordinator 57/57, read guard
69/69. clang -Wall -Wextra -Wshadow -Wconversion -Wunreachable-code clean on the
changed sources. XCTest suites type-check under the shim with no new warnings.
plutil -lint clean.
…ease

The gateway needs to know which release is actually serving, so an activation
that makes a release active now reports itself out of band:
POST v3/remote-config-v2/ack with the project token, the very session the
snapshot was read under, and {"release_number", "activated_at"}.

The ack is deliberately powerless over the config data path. It cannot block or
slow an activation or a read (every entry point is a comparison and an async
hand-off to the sender's own serial queue, which is also where the durable read
and write happen), it never calls back into the app, it never feeds the fetch
policy or its failure observer, and every failure is silent — the only trace of
a lost ack is a counter, not a log line, so a flapping gateway cannot become a
log storm.

Semantics, mirroring the Android slice:
- exactly one ack per (scope, release), which survives a restart because the
  last settled release is persisted next to the pending one;
- an implicit (read-triggered) activation is acked exactly like an explicit one,
  and the explicit activate() that follows stays silent;
- at most one ack in flight per scope, newest release wins in both directions;
- a queued ack is durable, written before the first attempt, and resumed when
  its identity is bound again;
- 2xx is delivered; a 401 buys exactly one re-bootstrap; a 404 or any other
  permanent refusal SETTLES the release, so a gateway that does not serve the
  route yet cannot become a restart storm;
- 429/5xx/transport faults get three jittered attempts (half the cap plus
  jitter), counted per process and per (scope, release), then the ack is
  abandoned in-process while the durable record keeps it owed;
- an identity change fences delivery, and the transport refuses to send under
  another identity's session without spending a retry.

The route rides the existing transport seam — same bootstrap, same
re-bootstrap-once rule — which is why the bootstrap now reports a refusal
through a block instead of writing a fetch response directly. The fetch path's
behaviour is unchanged.

Claude-Session: https://claude.ai/code/session_018pHXqfbxkMQJFzUZ3jW4A8
…the gateway

Wires the read-guard telemetry handler and the transport failure
observer (both previously nil in the production assembly) into a durable
coalescing sender modeled on the activation ack queue. Decode failures
are produced at the snapshot read site without changing the resolved
value; stale events are pruned at flush; the telemetry path never mints
a session and never drops the shared one on 401.
QONRemoteConfigV2Configuration (environment uid + optional base URL and
minimum fetch interval, validated like Android's QRemoteConfigV2Config)
plugs into QONConfiguration and brings the dormant controller online
during initWithConfig:, before anything can identify. Build mode is
compile-time DEBUG only - a debugger attached to a release binary must
never change which config values are served. The client context ships
Android's wire convention (lowercase platform, normalized locale), the
restore-driven user switch now rebinds the v2 surface alongside v1, and
the privacy manifest declares the install-date file timestamp that this
path puts on the wire.
… uid

An identity merge can attach an external id and new account properties
without changing the canonical uid. The v1 path drops its cache for
exactly this case, but the v2 bridge treated a same-uid announcement as
a no-op, so the pre-identify release kept serving forever. Same hole on
a no-op logout.

Re-announcing the bound identity now schedules a forced fetch on the
identity queue, fenced by the epoch so a real switch that overtakes it
wins. Deliberately not a scope transition: nothing is unbound, no
persisted state republished, and the forced reason still respects the
failure backoff and in-flight coalescing.

XCTest additions are type-checked only (no Xcode here) — CI must run
them.
@shameondev
shameondev marked this pull request as ready for review August 14, 2026 23:38
@shameondev

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

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