Remote Config v2: client core, transport and experimental API (release train) - #761
Open
shameondev wants to merge 47 commits into
Open
Remote Config v2: client core, transport and experimental API (release train)#761shameondev wants to merge 47 commits into
shameondev wants to merge 47 commits into
Conversation
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
marked this pull request as ready for review
August 14, 2026 23:38
Contributor
Author
|
@coderabbitai review |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Консолидированный 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.