Files
purchases-ios/Sources/Networking/CheckpointRules.swift
T
Facundo MenzellaandClaude Opus 5 9380458788 Ingest the checkpoints config topic (#7370)
* Ingest the checkpoints config topic

Adds the `checkpoints` topic to the remote-config read facade, parsing models for
a checkpoint's ordered rules, and a CheckpointsConfigProvider to read them.

Rules are parsed only, not evaluated. Items are keyed by checkpoint name, so a
checkpoint resolves with a single blobData read: no topic-index scan, and
blobData already resolves a blob whether its bytes arrived inlined with the
config response or have to be downloaded, and already invalidates on an identity
change. This matches the split WorkflowsConfigProvider uses, where an item's
content is metadata and its blob is the payload.

Schema follows the "Storing and serving checkpoint rules" proposal: rules arrive
already in priority order, and a rule carries an audience reference rather than
an inline predicate (the audience's own predicate is expected in a separate
topic).

Parsing is tolerant, since the proposal notes the schema may still shift before
V1: unknown fields are ignored and a malformed rule is skipped rather than
failing the checkpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN

* Add the checkpoints files to RevenueCat.xcodeproj

Danger flagged the legacy project as out of sync: the new files were added via
Tuist/SPM, which doesn't touch it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN

* Prune obvious comments

Drops comments restating the code, the Foundation snake_case explanation (the
keys are literal in the code and pinned by a test), and the AnyObject constraint
on CheckpointsConfigProviderType, which nothing needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN

* Fix the topic name, require an audience, drop the frequency cap

The backend serves this topic as `checkpoint_rules`, not `checkpoints`
(khepri #23594 registers `TopicName('checkpoint_rules')`), so we were reading a
topic that doesn't exist.

An absent audience no longer means "targets everyone": the rule is skipped
instead, matching `audience_id NOT NULL` in the schema. It's typed `Int` because
the publisher serves `rule.audience_id`, a BigInteger.

Frequency capping isn't used yet, so parsing it only adds ways to fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN

* Decode checkpoint rules through the topic manager's typed helper

Follows Rick's review: the models are Decodable and read through
blobData(for:itemKey:as:) instead of hand-parsing AnyDecodable, with the
tolerance kept in the Codable implementation.

Two behaviour changes fall out of using JSONDecoder.default. Its date strategy
throws on an unparseable date, so a schedule bound that's present but invalid now
drops the whole rule instead of reading as nil, which had widened the window
(Bugbot and vegaro both flagged this). And a rule that fails to decode for any
reason is skipped by SkippableRule rather than failing its checkpoint.

CheckpointRuleSet no longer carries `identifier`: it's the topic item key, which
the caller already passes in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN

* Type the audience reference as a public id

khepri now resolves the rule's internal audience and workflow ids to their
rc_public_ids before serving them (khepri #23594), so `audience` is a string like
`workflow_id`, not the BigInteger the first draft of that publisher sent.

That PR also emits UTC-marked timestamps and only publishes active or scheduled
rules, so the schedule parsing and the fail-closed date handling here now line up
with what's served.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN

* Drop the schedule, it isn't V1

Same reasoning as the frequency cap: parsing a field nothing reads only adds ways
to fail.

Note for whoever wires up evaluation: khepri publishes rules in `scheduled` state
as well as `active`, so the window has to be parsed and honoured then, or a rule
will fire before its start date.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN

* Drop rules with an empty audience or workflow reference

The synthesized Decodable accepted an empty string where the hand-written parser
had treated empty the same as missing, so those rules would have reached workflow
resolution instead of being dropped at ingest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN

* Pin the checkpoints topic wire name

The provider doc still said `checkpoints` after the rename, and no test pinned
the literal: every fixture derives the key from the enum, so a wrong wire name
would read as an absent topic forever without failing anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN

* Read the audience as `audience_id` and drop the checkpoint id

Both from vegaro's review, matching what Android is doing: the backend is
renaming the rule's `audience` key to `audience_id`, which convertFromSnakeCase
maps for free, and the checkpoint's own id is unused and easy to confuse with the
item key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSMmcX6kq7C4Q6sKYsnCpN

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:03:09 +00:00

101 lines
2.9 KiB
Swift

//
// CheckpointRules.swift
// RevenueCat
//
// Created by Facundo Menzella.
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
import Foundation
/// One checkpoint's rules, which decide whether it resolves to a workflow. Parsed only, not evaluated.
struct CheckpointRuleSet: Equatable, Sendable {
/// Served in priority order, first match wins. Never re-sorted here.
let rules: [CheckpointRule]
init(rules: [CheckpointRule]) {
self.rules = rules
}
}
struct CheckpointRule: Equatable, Sendable {
let id: String?
/// Reference to the targeted audience, whose predicate arrives in a separate topic. Required, so a rule
/// can never end up targeting everyone by omission.
let audienceId: String
let workflowId: String
init(id: String? = nil, audienceId: String, workflowId: String) {
self.id = id
self.audienceId = audienceId
self.workflowId = workflowId
}
}
// MARK: - Decodable
extension CheckpointRuleSet: Decodable {
private enum CodingKeys: String, CodingKey {
case rules
}
/// A rule the SDK can't make sense of is skipped rather than failing the checkpoint, so one bad rule
/// can't take out the rest.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.rules = (try container.decodeIfPresent([SkippableRule].self, forKey: .rules) ?? [])
.compactMap(\.rule)
}
}
extension CheckpointRule: Decodable {
private enum CodingKeys: String, CodingKey {
case id
case audienceId
case workflowId
}
/// An empty reference is treated the same as a missing one: it can't resolve to anything, so the rule is
/// better dropped at ingest than left to fail later.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decodeIfPresent(String.self, forKey: .id)
self.audienceId = try container.decode(String.self, forKey: .audienceId)
self.workflowId = try container.decode(String.self, forKey: .workflowId)
for (key, value) in [(CodingKeys.audienceId, self.audienceId), (.workflowId, self.workflowId)]
where value.isEmpty {
throw DecodingError.dataCorruptedError(
forKey: key,
in: container,
debugDescription: "'\(key.rawValue)' is empty"
)
}
}
}
/// Turns a rule that fails to decode into `nil` instead of failing its enclosing array.
private struct SkippableRule: Decodable {
let rule: CheckpointRule?
init(from decoder: Decoder) throws {
do {
self.rule = try CheckpointRule(from: decoder)
} catch {
Logger.warn(Strings.remoteConfig.checkpointRuleSkipped(reason: "\(error)"))
self.rule = nil
}
}
}