This document is the source of truth for client <-> server delivery contracts that are difficult to capture in OpenAPI / Swagger alone.
Scope:
- static delivery resources consumed by the client SDK
- JSON file schemas that are not served by the control-plane API
- artifact and directory-shape contracts shared by CLI, server, and client
- opaque JSON blobs carried through the control-plane API whose per-variant schema cannot be expressed as one OpenAPI schema
- behavioral rules that must stay stable across implementations
Out of scope:
- ordinary control-plane REST endpoints that belong in API spec / Swagger
- internal worker implementation details that do not affect the client-visible contract
If this document diverges from package-owned specs, align the package docs to this file.
All paths below are relative to the configured download base URL. The download base URL points at the static delivery origin — typically a public object storage endpoint, optionally fronted by a CDN.
This origin is named independently on each side but must hold the same value: the client SDK's CodemagicPatchDownloadBaseUrl native resource equals the server's PUBLIC_BASE_URL (byte-equal apart from an optional trailing slash). Because it is a storage/CDN origin, the value may include a bucket or path prefix (for example /codemagic-patch); the client appends the paths below to it. The separate CodemagicPatchApiUrl resource instead points at the API server origin (the server's SERVER_URL): the SDK appends /v1/... to it, so it takes the bare origin with no path.
- Manifest (primary, OTA-active device):
/{deployment_key}/{binary_version}/{current_package_hash}/manifest.json - Manifest (fallback, embedded-bundle device):
/{deployment_key}/{binary_version}/manifest.json - Deployment metadata:
/{deployment_key}/meta.json - Full bundle artifact:
/{deployment_key}/{binary_version}/{target_package_hash}/bundle.tar.zst - Patch artifact:
/{deployment_key}/{binary_version}/{target_package_hash}/patches/{from_package_hash}.zst
Definitions:
deployment_key: deployment identifier used by the client SDKbinary_version: path-safe app-store binary version token such as1.0.0,1.2, or2024.06; not required to be semver. Ordered by numeric-dotted precedence for the store-update hint (seemeta.json› Version Comparison); opaque tokens are incomparable.current_package_hash: deterministic hash of the package currently active on the devicetarget_package_hash: deterministic hash of the effective OTA target releasefrom_package_hash: source package hash used when generating a patch artifact
The client never asks the control-plane API whether an update exists. Update checks are fully driven by static delivery through deterministic paths.
- Exactly one
meta.jsonexists per deployment path:/{deployment_key}/meta.json. - It is deployment-scoped, not binary-version-scoped.
{
"latest_binary_version": "2.1.0"
}latest_binary_versionis the highest binary version, by the precedence rule below, across the effectively published OTA release targets in the deployment. When every target is an opaque (incomparable) token, the server selects one deterministically (lexically greatest).- This file is informational only. It provides a store-update hint and must not change OTA applicability — the client must never infer OTA applicability from it.
- The client fetches it in parallel with
manifest.json.
Both the client (deciding whether to surface a hint) and the server (selecting the token to publish) compare binary versions with the same precedence rule. binary_version tokens are path-safe and not required to be semver, so the rule orders any consistent numeric-dotted scheme (semver, MAJOR.MINOR, calver such as 2024.06) and treats anything else as incomparable.
A token is comparable iff it matches:
^\d+(\.\d+)*(-<dot-separated identifiers>)?(+<dot-separated identifiers>)?$
where each identifier is [0-9A-Za-z-]+ (non-empty). To compare two comparable tokens:
- Build metadata (everything from the first
+) is dropped — it never affects precedence. - The numeric release segments before the optional
-are compared left to right; a missing trailing segment counts as0(so1.2equals1.2.0). Leading zeros are allowed and not significant (2024.06equals2024.6,01.0.0equals1.0.0). Numeric segments are compared by magnitude, not lexically (1.10.0is higher than1.2.0), with no width limit. - If the release segments are equal, semver prerelease precedence applies: a token with a prerelease is lower than one without; per identifier, all-digit identifiers compare numerically and rank below alphanumeric identifiers, which compare lexically; a longer identifier set wins when all shared identifiers are equal.
If either token is not comparable (a non-numeric lead such as latest/v2, an empty or malformed identifier such as 1.0.0-, an empty string, etc.), the two are incomparable.
- Compare
latest_binary_versionwith the app's ownbinary_versionusing the precedence rule above. - Surface
isStoreUpdateAvailable = trueonly whenlatest_binary_versionis strictly higher. When it is equal, lower, or incomparable, setisStoreUpdateAvailable = false. latestBinaryVersionreports the token the server selected whenevermeta.jsonwas readable — including when no hint is surfaced.- If the fetch fails, returns
404, or is malformed (not a JSON object with a stringlatest_binary_version):- set
isStoreUpdateAvailable = false - set
latestBinaryVersion = null - do not fail or delay the OTA flow
- set
Two manifest path patterns are defined. The client selects one based on whether an OTA package is currently active on the device.
Primary — OTA-active device:
/{deployment_key}/{binary_version}/{current_package_hash}/manifest.json- Keyed by the device's current package hash, not by the target hash.
- The server pre-generates these manifests for known
current_package_hashvalues in a given deployment/binary-version chain, enabling per-baseline patch selection. - A 404 on this path signals "no pre-generated manifest for this specific hash" and the client retries with the fallback path.
Fallback — embedded-bundle device or primary 404:
/{deployment_key}/{binary_version}/manifest.json- Keyed only by
deployment_keyandbinary_version; nocurrent_package_hashsegment. - The embedded binary bundle is not hash-identified to the server. Devices running the embedded bundle (fresh install, or reverted-to-embedded) use this path directly without attempting the primary.
- Response contract: the server returns the latest healthy OTA for
{deployment_key, binary_version}using the same selection logic as the primary path (rollout, disabled-release handling,previous_package_infosemantics). - Responses on this path MUST omit
patch_url(both on the root target and withinprevious_package_info). The server has no baseline to diff against, so the client MUST apply a full-bundle update. Anypatch_urlpresent on a fallback-path response is a protocol violation and must be ignored by the client. - A 404 on this path signals "no healthy OTA exists for this
binary_versionin this deployment" and is treated by the client as a no-op (stay on the current package). - If no healthy OTA exists, the server may instead return
{ "target_package_hash": null }to explicitly mark the binary bundle as authoritative — same sentinel semantics as the primary path.
target_package_hashis always present.target_package_hash = nullmeans "revert to the embedded binary bundle".release_labelis present whentarget_package_hashis non-null and omitted when the target is the embedded binary.patch_urlis optional and may be absent because:- no patch was generated for this
current_package_hash - the hash is outside the configured patch window
- the selected target is the embedded binary
- no patch was generated for this
full_bundle_urlis the fallback artifact for the selected OTA target.full_bundle_sizeis required wheneverfull_bundle_urlis present. Ifprevious_package_infois present, itsfull_bundle_sizeis required as well.patch_sizeis optional and may be omitted when no patch is advertised or when the server does not know the patch size yet.signatureis present only when the effective target release carries a code-signing signature.previous_package_infois present only when the effective latest target has a published, non-disabled predecessor in the same deployment + binary-version chain.
previous_package_inforefers to the immediate predecessor of the latest effective release, not the predecessor ofcurrent_package_hash.- If
target_package_hash == current_package_hash, the client must treat the manifest as a no-op. - If rollout excludes the latest target and
previous_package_infoexists, the client may useprevious_package_infoas the fallback candidate. previous_package_infomirrors the root manifest descriptor so the client can use it without making an extra request.is_mandatoryis already resolved by the server. The server may convert an optional target to mandatory if any intermediate release in the upgrade chain is mandatory.patch_sizeis informational in MVP and must not be treated as an install blocker.
The logical OTA payload is the package root contents tree only.
- Package hash is computed from normalized file entries inside the payload tree.
- The payload tree includes the bundle file AND all OTA asset files (images, fonts, etc.).
- Transport containers such as ZIP, tar, zstd output, and manifest JSON are not part of the package hash.
update.jsonis not part of the hashed payload.
The package hash is a deterministic SHA-256 digest over the payload tree contents. This contract must be implemented identically by the CLI, server, and client SDK (Android + iOS).
Canonicalization Rules:
- Start from the package root
contents/. The directory name itself is not part of the hash. - Include regular files only (no directories, symlinks).
- Convert all path separators to
/. - Reject absolute paths.
- Reject paths containing
..after normalization. - Reject duplicate normalized paths.
- Ignore:
__MACOSX/**- any
.DS_Storefile
Hash Algorithm:
For every included file:
fileHash = sha256(file bytes)
entry = "<normalizedRelativePath>:<fileHash>"
entries = sort(all entry strings, lexicographic by UTF-8 byte order)
packageHash = sha256(JSON.stringify(entries))
The result is a lowercase hex-encoded SHA-256 string.
What is NOT part of the hash input:
- ZIP / tar / zstd container bytes
- manifest JSON served from the static delivery origin
- release metadata stored on the server
- client-local metadata such as
update.json - signing metadata (signatures are stored outside the payload tree)
Cross-platform conformance is enforced with shared fixture directories and expected-hash test vectors. Each implementation (CLI, server, Android, iOS) must produce the identical hash for the same fixture payload.
codemagic-patch/
packages/
{package_hash}/
contents/
...bundle files...
update.json
state/
state.json
downloads/
tmp/
Rules:
- Downloaded or patched bundle content ultimately lands in
packages/{package_hash}/contents/. update.jsonlives next tocontents/, outside the hashed subtree.state/state.jsonis a single JSON file holding the on-device package lifecycle state; it is rewritten atomically as a whole file (write-to-temp-then-rename) on every change. Its field schema is owned by the client on-device storage spec.- Hash verification and patch application operate on
contents/only.
bundle.tar.zstrepresents the payload tree that becomespackages/{target_package_hash}/contents/.- No wrapper directory name is semantically part of the payload.
- After untar, the client verifies the deterministic package hash against the extracted
contents/subtree only.
bundle.tar.zst is a single standard zstd frame wrapping a POSIX ustar
tar stream. This is a deliberately narrow, load-bearing wire contract: the
server writer and the client C extractor each implement and enforce exactly the
subset below, so a producer that switches to a general-purpose tar library
(which typically emits directory entries, PAX records, or non-zero mtime) would
produce archives every client rejects. Any change to the writer must preserve
this profile or update all decoders together.
Tar profile (512-byte blocks, ustar magic at offset 257, version 00 at
offset 263):
-
Regular files only. The type flag (offset 156) is
'0'(0x30) — a NUL byte is also accepted as a regular file. The only other accepted type is the GNU long-name record ('L') defined under Long paths below. Decoders must reject every other type, including directory ('5'), symlink, hardlink, and PAX/GNU extension records ('x','g','K'). There are no directory entries; parent directories are implied by file paths and created on extraction. -
Zeroed metadata.
mode=0644,uid=0,gid=0,mtime=0.uname/gname/devmajor/devminorare left empty. This keeps the archive byte-identical regardless of the build host. -
Long paths via
ustarprefix splitting. A path longer than 100 bytes is split at a/boundary intoname(≤100 bytes, offset 0) andprefix(≤155 bytes, offset 345); decoders reconstructprefix + "/" + name. -
Unsplittable paths via GNU long-name records (
'L'). A path that cannot be split to fit both fields (e.g. a single path segment longer than 100 bytes, as produced by Metro's flattened Android asset names) is encoded as a GNU long-name sequence, emitted only when prefix splitting fails so that archives without such paths remain byte-identical to the pre-'L'profile:- A header block with
name=././@LongLink, type flag'L', the same zeroed metadata as regular entries, emptyprefix, andsize= byte length of the full UTF-8 path + 1 (a single trailing NUL). - Data blocks containing the path bytes + one NUL, zero-padded to the next 512-byte boundary.
- The entry's regular header (
'0'), whosenamefield carries the first 100 bytes of the path as an opaque deterministic placeholder (it may end mid-codepoint) and whoseprefixis empty, followed by file data as usual. Decoders replace this header's path with the long-name path.
Decoder rules: the long-name path is the data bytes before the trailing NUL; it must be non-empty, at most 4096 bytes, and passes the same path normalization/validation as reconstructed split paths. An
'L'record not immediately followed by a regular-file header (end of archive, zero block, or another'L') is an error. Writers must reject paths longer than 4096 bytes.Producer path limits. The wire format can express paths no device can materialize, so the server rejects them before publishing rather than shipping a release that fails every install: a path segment longer than 255 bytes exceeds
NAME_MAXon Android ext4/f2fs and iOS APFS (mkdir/openfail withENAMETOOLONG). The server additionally caps each relative path at 512 bytes to reserve roughly half of iOSPATH_MAXfor the absolute application container and package directories. Both limits are enforced at bundle ingest and again in the archive writer.Compatibility: client SDKs earlier than
0.1.4reject'L'records, so a release whose payload contains an unsplittable path installs only on devices running SDK ≥ 0.1.4; the CLI warns at publish time when a bundle needs long-name encoding. - A header block with
-
Standard checksum. Header checksum is the unsigned sum of all 512 header bytes with the checksum field taken as eight spaces, written at offset 148 as six octal digits + NUL + space.
-
Block padding. Each entry's data is zero-padded to the next 512-byte boundary; the archive ends with two 512-byte zero blocks.
Deterministic entry order. Entries are emitted in ascending
byte-lexicographic order of the manifest line "{path}:{sha256_hex(file)}"
— the same ordering used by the Package Hashing Contract.
Combined with the zeroed metadata, this makes bundle.tar.zst byte-deterministic
for a given payload tree. Entry paths follow the same normalization as the
package hash (relative POSIX paths, no leading / or ..; .DS_Store and
__MACOSX/ excluded).
zstd constraints. The outer layer is one standard zstd frame. The encoder
must keep the zstd window within the decoder's default limit: windowLog ≤ 27
(≤ 128 MiB). The client decompresses with a default ZSTD_DStream, which
rejects frames whose declared window exceeds 128 MiB
(frameParameter_windowTooLarge); it does not raise ZSTD_d_windowLogMax.
Default-level zstd compression stays well under this bound, so no explicit
parameter is required today — but a future high-window encoder would break
decompression on every client.
- Patch files are self-contained HDiffPatch directory-diff containers (
hdiffzdirectory diff, single-compressed-diff layout, internal zstd codec, fadler64 checksum). The container is not a standalone zstd frame: it begins with the HDiffPatch directory-diff magic, and the zstd-compressed diff stream lives inside the container. The.zstsuffix on the patch path therefore denotes this internal codec, not an outer zstd wrapper. - The client applies the container directly with
hpatchz(built with the zstd decompression plugin). There is no separate external zstd-decompression of the artifact before patch application; the internal zstd stream is decompressed insidehpatchzas part of applying the patch. (Contrast: the full-bundle artifactbundle.tar.zstis a real zstd frame and is zstd-decompressed before untar.) - Source tree: the current package
contents/directory. - Target tree: the target package payload tree.
- The client applies the patch into
tmp/{target_package_hash}/contents, then verifies the resultingcontents/subtree hash. - On hash mismatch or patch failure, the client falls back once to the full bundle.
- The payload may contain plain JS bundles, Hermes bytecode, and assets.
- The server and client treat the payload as an opaque directory tree.
- Compatibility is determined by
binary_version, fingerprint expansion, and package hash, not by bundle file extension.
A Failed metric event names which failure class occurred through
attributes.reason (taxonomy owned by client/specs/metrics/Spec.md). The
reason alone is too coarse to act on: a deployment reporting 78 network
failures gives an operator no way to tell a CDN 403 apart from a device that
lost connectivity mid-download. attributes.payload carries the detail that
closes that gap.
This contract lives here rather than in OpenAPI because the payload travels as
an opaque JSON string inside attributes, which OpenAPI can describe only as
"a string"; because what its fields mean is qualified by the value of a sibling
field (attributes.reason); and because the server must accept payloads
produced by SDK versions older than any rule written below.
Once a payload shape ships, every later change to it must stay backward compatible. This is stricter than the usual API-evolution courtesy, because two things make an incompatible change unrecoverable rather than merely disruptive:
- The old shape is already in the database. Metric events are an
append-only historical record: every payload a released SDK ever sent is
sitting in
metric_event, and it recorded what that SDK actually observed. Rewriting those rows to a new shape would not be a migration — it would be fabricating measurements nobody took. - The old shape keeps arriving. A client SDK version lives on real devices for as long as users decline to update, so a shape that shipped once keeps being emitted indefinitely. There is no flag day and no way to force one.
A field's name, type, and meaning are immutable once released. New semantics take a new field name; they never reuse an existing one. This is the mechanism that replaces payload versioning — there is no version marker, because a field that never changes meaning does not need one.
| Change | Allowed | Why |
|---|---|---|
| Add an optional field | yes | Readers already treat every field as optional |
| Stop sending a field | yes | Stored rows keep it; readers must still tolerate it |
| Add a value to a field's value space | yes | Readers must already tolerate values they do not recognize |
| Rename a field | no | Splits one fact across two names inside a single dataset |
| Change a field's type | no | No query can separate the two shapes after the fact |
| Redefine what a value means | no | Silently merges two different measurements under one key |
The last row is the dangerous one because it fails quietly. Had code been
released carrying "HTTP_403"-style names and then redefined to hold bare HTTP
statuses, both value spaces would now live in one column with nothing marking
which SDK produced which — the breakdown would read as though a single value
space had always been in use.
Readers carry the matching obligation: treat every payload field as optional and every value as possibly older than the current schema. A dashboard or query that assumes a field is present, or that a value space is closed, breaks the first time it meets a row an older SDK wrote.
The same reasoning governs every stored envelope field, not just payload.
attributes.payloadis a JSON string, not a nested JSON object. Every otherattributesvalue is a string, and keeping the map homogeneous lets each platform's native bridge carry it without a nested-container code path.- The string MUST decode to a JSON object. Arrays, scalars, and
nullare protocol violations. - The encoded string MUST NOT exceed 4096 bytes of UTF-8. Clients truncate their own fields before serializing rather than emitting an oversized payload.
payloadis optional on every event. It is meaningful only onFailedevents; apayloadpresent on any otherevent_nameis ignored.
Every reason uses the same payload shape. There is no per-reason schema and no discriminator field: one object, three optional fields, identical for a download failure and a crash rollback alike.
{
"code": "403",
"message": "AccessDenied: Access Denied.",
"android_previous_process_exit": "REASON_USER_REQUESTED"
}| Field | Type | Meaning |
|---|---|---|
code |
string, optional | The failure's machine-readable discriminator, and the key every breakdown groups on. What it enumerates is per-reason; see the table below. |
message |
string, optional | Failure text relayed from whatever reported the failure. Never composed by the client. |
android_previous_process_exit |
string, optional | Why this app's previous process ended. Android only; see below. |
The uniformity is a decision about the reader as much as the writer. One
shape means one server column, one aggregation query, and one dashboard
drill-down (reason → code → detail) that reads identically whichever failure
it is pointed at. A reason that gains detail later needs no new storage, no new
endpoint, and no new screen. Per-reason shapes would have bought
tighter-fitting fields at the cost of N of each.
Every field is optional, and a payload with nothing to put in any of them is
not sent at all: payload is omitted rather than serialized as {}. A crash
rollback has no status and no text to relay, but does have the platform's
account of how the previous process died; a download failure on iOS has the
first two and never the third. Requiring any field would force one of those
cases to invent a placeholder, and a placeholder is worse than an absence
because nothing afterwards can tell it apart from a real value.
A payload may carry fields beyond these three; a server MUST retain unknown fields verbatim rather than reject the event.
The shape is fixed; what code and message mean is the reason's business.
code must stay enumerable and stable within a reason because every
breakdown groups on it, so it must never become free text.
reason |
code |
message |
|---|---|---|
network |
HTTP status of the failed response, or "0" — defined below |
relayed origin or platform error text — defined below |
| every other reason | not yet populated | not yet populated |
Reasons other than network currently carry
android_previous_process_exit alone and aggregate under the no-code bucket.
When one of them gains a discriminator, it takes this same code field
under the same rules rather than a field of its own — that is what "one shape"
buys, and abandoning it for the second reason would forfeit all of it.
Why this app's previous process ended, as an Android ApplicationExitInfo
reason constant name: REASON_CRASH, REASON_CRASH_NATIVE, REASON_ANR,
REASON_LOW_MEMORY, REASON_SIGNALED, REASON_USER_REQUESTED,
REASON_EXIT_SELF, and the rest of that enum. A constant a client's SDK
version does not recognize — one added by a later platform release — is
reported as its decimal number rather than folded into an existing name.
Omitted on iOS and on Android 10 and older, where no platform API can
answer the question.
This is a fact lookup, not an inference: it is the only way to tell a launch
that crashed apart from one the OS reclaimed under memory pressure. It is sent
on every reason, not only the ones it obviously bears on, because the same
distinction changes what a failure means everywhere — most sharply on
install_fail, where it separates a genuinely broken package from a rollback
the OS provoked.
Exactly one exit reason is reported: the most recent. Older records describe launches the failure cannot be about.
code is the HTTP status code of the failed response, in decimal, as a
string. "0" when the request produced no HTTP response at all: a connect or
read timeout, a DNS failure, a TLS handshake failure, a connection dropped
mid-transfer, no network interface, or a cancelled request. Nothing else may
appear here — the value space is the enumerable set of HTTP statuses plus
"0".
message is failure text relayed from whatever reported the failure, at
most 512 bytes (clients truncate longer text). The client never composes
it. Where it is relayed from follows code:
-
code = "0"— the platform's raw error text, unedited:"The request timed out.","Unable to resolve host …". With no status to group on, this string is the only thing separating a timeout from a DNS failure. -
code= an HTTP status — the origin's error document. OTA artifacts are served by object storage or a CDN, never by the API server, and those answer a non-2xx with a document naming the cause. From an S3-compatible origin the client relays"{Code}: {Message}"—"NoSuchKey: The specified key does not exist."for an artifact that was never uploaded,"AccessDenied: Access Denied."for a bucket policy,"SignatureDoesNotMatch: …"for signing. That distinction is the entire diagnostic value of a 403 or 404, and only the origin can supply it.Only
CodeandMessagemay be relayed. The same document also carriesKeyandResource, which embed the deployment key and package hash; relaying the body wholesale would put those into telemetry, which is a data hygiene question rather than an aesthetic one. Fields that merely vary per request — S3'sRequestIdandHostId, or the request id Azure appends insideMessageitself — are not a reason to edit anything:codeis the aggregation key, so a message that is unique per occurrence costs nothing and is often what an operator hands to the provider's support. Clients read a bounded prefix of the body (4096 bytes) — a CDN answering with its own HTML error page must not be held in memory to extract two tags from it.
message is omitted when nothing could be relayed: the origin returned a
body with neither tag, or none at all. That is not a protocol violation but the
honest report that the origin said nothing, and the server and dashboard treat
it as absent rather than listing it. A client must never fill the gap with text
of its own.
In no case does the client add anything of its own — no URL, no package hash, no timestamp. That follows from relaying rather than composing, not from any concern about how many distinct messages result. Text the platform or origin itself produced is relayed as it stands, hostnames and request ids and all.
The exact wording is not part of this contract; breakdowns list the distinct
messages under each code rather than matching on them.
Known gap. Every transport-level failure shares code = "0", so a timeout
is not distinguishable from a DNS failure by the aggregation key alone — only
the free-text message separates them today. A dedicated
transport-classification field alongside code is deferred.
The payload is best-effort observability and must never cost the server a
Failed event:
- The server parses
attributes.payloadand stores the decoded object in its own column, so breakdowns aggregate onpayload -> 'code'without re-parsing text per row. - If
payloadis absent, is not a string, exceeds the byte cap, fails to parse, or does not decode to a JSON object, the server stores no decoded payload and still persists the event. A malformed detail blob must never turn into a droppedFailedevent — the count matters more than the detail. attributesis persisted verbatim, including the rawpayloadstring, so a payload the server could not decode stays recoverable for debugging.- The server does not validate a payload against the reason that carried it. It
stores what decoded and lets the breakdown surface whatever
codearrived, so a reason starting to populatecodenever requires a server release first. - Events whose decoded payload has no
code, and events with no payload at all, aggregate under the same no-code bucket. They are indistinguishable bycodealone and are not meant to be: both report a failure that named no discriminator.
- The update-check protocol is static-delivery-first. OpenAPI cannot adequately express that the client builds deterministic download paths and resolves update availability by hash comparison.
404onmanifest.jsonmeans "no manifest exists for this current hash". It is a no-op and is not equivalent totarget_package_hash = null.target_package_hash = nullis an explicit binary-fallback instruction, not an absence of manifest.- The client must never construct an alternative manifest path after a manifest fetch failure.
- Rollout stickiness uses
md5(installation_id + "-" + release_label) % 100 < rollout_percentage. The% 100operand is computed by a fixed digest→integer reduction that every implementation must match: take the lowercase hex MD5 digest, read its first 8 hex characters (the first 4 digest bytes, big-endian) as an unsigned 32-bit integer, then apply% 100to obtain a bucket in[0, 99]. A device is eligible when its bucket is< rollout_percentage. A naive reading that takes the full 128-bit digest modulo 100 produces a different bucket and must not be used. - The client does not perform a second rollout evaluation on
previous_package_infoin MVP. meta.jsonis fire-and-forget and must not block or retry-gate the manifest path.
New SDK emission and ingestion use Downloaded, Ready, Applied, Failed,
and Active. Downloaded is unchanged. Ready means the artifact is unpacked
and SDK state is prepared for the next session according to the activation
policy. Applied means the new update runs successfully, confirmed by
notifyAppReady(); it remains once per device and package.
Historical Installed and Success rows are read only as
compatibility aliases for Ready and Applied. Do not rename or backfill stored
events. Existing failure supersession still applies: either historical Success
or new Applied prevents late failures from counting against an already
confirmed device/package.
SDK releases before the rename (@codemagic/react-native-patch 0.4.x and
earlier) still emit Installed and Success. Ingestion accepts those two names
transitionally and stores them as Ready and Applied, with the same
delivery_type and supersession rules, so upgrading the server does not drop
metrics from apps already in the field.
Metrics response keys installed and success remain stable for existing API
consumers; they count Ready and Applied respectively, including historical
aliases. Display labels use Ready and Applied. The application success rate
remains success / (success + failed), not Applied divided by Ready.
The SDK retains success_reported_at on disk (and successReportedAt in its
metadata model) so SDK upgrades preserve the once-per-package reporting marker.
{ "target_package_hash": "sha256-hex-string | null", "release_label": "v3", "patch_url": "https://downloads.example.com/.../patches/{from_package_hash}.zst", "patch_size": 102400, "full_bundle_url": "https://downloads.example.com/.../bundle.tar.zst", "full_bundle_size": 524288, "is_mandatory": false, "release_notes": "Bug fixes and performance improvements", "rollout_percentage": 100, "signature": "jwt-string", "previous_package_info": { "release_label": "v2", "package_hash": "sha256-hex-string", "patch_url": "https://downloads.example.com/.../patches/{from_package_hash}.zst", "patch_size": 81920, "full_bundle_url": "https://downloads.example.com/.../bundle.tar.zst", "full_bundle_size": 491520, "is_mandatory": false, "release_notes": "Initial release", "rollout_percentage": 100, "signature": "jwt-string" } }