@traffical/sdk-spec
v0.7.0
Published
Language-agnostic specifications for Traffical SDKs - schemas and test vectors
Downloads
330
Maintainers
Readme
Traffical SDK Specification
Language-agnostic specifications for implementing Traffical SDKs.
Overview
This repository contains:
- JSON Schemas - Type definitions for configuration bundles and events
- Test Vectors - Deterministic test fixtures for validating SDK implementations
All Traffical SDKs must implement these specifications to ensure consistent behavior across languages.
Schemas
config-bundle.schema.json
The complete configuration bundle that SDKs fetch and cache. Contains:
- Organization and project identifiers
- Hashing configuration (unit key, bucket count)
- Parameters with defaults and layer membership
- Layers with policies and allocations
- Conditions for targeting
events.schema.json
Event payloads sent to the Traffical API:
- Exposure events (when a user is assigned to a variant)
- Decision events (parameter resolutions, with per-layer metadata)
- Track events (custom user actions)
Exposure and decision events carry an optional top-level configVersion — the
config bundle version the SDK evaluated against. Per-layer entries
(ExposureLayerInfo) carry optional propensity fields:
probability— propensity of the CHOSEN allocation at decision time, in(0, 1]. Forlinear_contextualpolicies this is the floored-softmax probability of the chosen allocation; for other adaptive policies (thompson_bernoulli/epsilon_greedy/ucb1) it is the chosen allocation's bucket-range share ((bucketRange[1] - bucketRange[0] + 1) / bucketCount); for per-entity dynamic allocations it is the weight the SDK actually used. Static policies omit the field entirely.modelVersion— only forlinear_contextual: the model timestamp of the coefficients used (trainingSummary.generatedAtor the bundle model's equivalent).
See test-vectors/fixtures/events_conformance.json for payload conformance
vectors covering these fields.
traffical-config.schema.json
Local project configuration file (.traffical/config.yaml) schema.
Test Vectors
The test-vectors/ directory contains deterministic test fixtures for validating SDK implementations.
Purpose
All Traffical SDKs must produce identical results for the same inputs:
- Hashing consistency - The SHA-256 v2 assignment hash produces the same bucket across all implementations
- Resolution correctness - Parameter resolution follows the layered priority system
- Condition evaluation - Context predicates are evaluated identically
Fixture Structure
test-vectors/
├── fixtures/
│ ├── bundle_*.json # Config bundles to test against
│ └── expected_*.json # Expected outputs for each bundle
└── README.mdRunning Tests
Each SDK implementation should:
- Load the bundle JSON
- For each test case:
- Compute buckets and verify against
expectedHashing - Resolve parameters and verify against
expectedAssignments
- Compute buckets and verify against
Hash Function Reference
Traffical uses the SHA-256 v2 assignment hash for bucket computation. This
replaced FNV-1a, which passed single-layer uniformity but failed
cross-experiment independence with realistic UUID/ULID unit keys and lay_*
layer IDs (assignment in one layer could predict assignment in another).
Bucket assignment contract
input = "traffical:assignment:v2|u:<unitLen>:<unitKeyValue>|l:<layerLen>:<layerId>"
digest = SHA256(UTF-8 bytes of input)
hashInt = first 64 bits of digest, unsigned big-endian
bucket = hashInt % bucketCount- Versioned (
v2) and domain-separated (traffical:assignment) so the contract is explicit and cannot collide with other hashes. - Length-framed:
<unitLen>and<layerLen>are the number of UTF-8 bytes of each field value (not UTF-16 code units or grapheme clusters), so values containing:or|cannot create ambiguous inputs and every SDK frames identically across languages. - UTF-8 byte domain: the digest is computed over the UTF-8 bytes of the
framed input. See
test-vectors/fixtures/bundle_unicode.jsonfor conformance fixtures that lock UTF-8 byte framing of both unit keys and layer IDs.
Example
- Input:
"traffical:assignment:v2|u:8:user-abc|l:8:layer_ui" - SHA-256 first 64 bits (big-endian):
3044655943265667177 - Bucket (mod 1000):
177
Weighted selection
Per-entity and contextual selection use the same SHA-256 primitive on a seed
string ("<entityId>:<unitKeyValue>:<policyId>" or
"ctx:<unitKeyValue>:<policyId>"): the first 64 bits of the digest are reduced
to a uniform value in [0, 1) via (hashInt mod 2^53) / 2^53, which is then
used to walk the cumulative weights.
Normative behavior (0.7.0)
The sections below are the normative cross-SDK behavior contract introduced
in spec 0.7.0 (drift-remediation). Every Traffical SDK MUST implement them
exactly; they are testable against the conformance vectors in test-vectors/.
The key words MUST, MUST NOT, SHOULD, and MAY are used per RFC 2119.
A few of these rules deliberately contradict the current reference JS engine
(js-sdk/packages/core), which is the 1-of-4 outlier on those points; the
engine is aligned to this spec in a follow-up ("Phase 2"). Such cases are called
out inline so implementers do not "match JS" where JS is wrong.
Condition evaluation
A policy's conditions are context predicates that gate eligibility. All
conditions in the array are AND-ed: a policy is eligible only when every
condition matches. An empty (or absent) conditions array always matches.
Field lookup (dot-notation, nested)
condition.field is resolved against the evaluation context using
dot-notation nested lookup:
- The field is split on
.into path segments. - Each segment indexes into the current value only when that value is a
(non-null) object; array elements are addressed by their numeric-string
index (
tags.0). - If any segment is reached on a value that is
null,undefined, or a non-object primitive, the lookup yieldsundefined(the "field is absent" signal). SDKs MUST NOT throw on a missing path.
Examples: user.plan → context.user.plan; tags.0 → the first element of
context.tags.
iOS today does flat lookup only; it is aligned to nested lookup in Phase 2, and iOS device-info fields will be emitted as typed values.
Strict typing (no coercion)
Comparisons are strictly typed. There is no "42" == 42 coercion anywhere.
eq/neq— strict identity. The types must match:"42"does not equal42.neqis the exact negation.gt/gte/lt/lte(relational) — match only when the resolved context value is a number and the condition'svalueis a number. If the context value is not numeric (missing, string, boolean, object), the condition does not match.contains/startsWith/endsWith/regex(string ops) — match only when both the context value andvalueare strings.regexcompilesvalueas a regular expression and matches against the context string; an invalid pattern does not match (SDKs MUST NOT throw). Regex dialect is the host platform's (ECMAScript / PCRE / ICU / Pythonre); authors SHOULD keep patterns to the common subset.in/nin— membership by strict equality againstcondition.values. Ifvaluesis not an array,indoes not match andnindoes match.exists/notExists—existsmatches when the resolved value is neitherundefinednornull;notExistsis its negation. Novalueis read.
iOS today uses loose coercion (e.g. string device-info
appBuildNumbermatching a numericgte 500). It is aligned to strict typing in Phase 2.
Omitted condition value (never-match)
For the relational operators gt, gte, lt, and lte, if the condition
carries no value (the key is absent), the condition MUST NEVER match,
regardless of the context value. SDKs MUST NOT coerce a missing threshold to
0 (or any other default).
Python currently coerces a missing threshold to
0.0; it is the outlier and is fixed in Phase 2.
Conformance vectors: test-vectors/fixtures/bundle_conditions_omitted.json /
expected_conditions_omitted.json place an omitted-value gte policy ahead of a
valid gte 100 policy and prove the omitted policy is always skipped.
Operator reference
eq, neq, in, nin, gt, gte, lt, lte, contains, startsWith,
endsWith, regex, exists, notExists. An unknown operator MUST fail safe
(no match).
Known gap: version-string comparison
Comparing version strings (semver, e.g. appVersion gte "2.10.0") is not
yet specified. Under the strict rules above, a relational op against a string
context value does not match, so semver comparison currently has no defined
operator. A dedicated version-comparison operator is a future addition and
is intentionally out of scope for 0.7.0; do not emulate it via lexical string
comparison.
Unit key values
The unit key value is the context field named by hashing.unitKey (or a
layer's unitKey override), read from the evaluation context and fed to the
assignment hash.
Canonical stringification (S2)
A unit key value read from context MUST be stringified with a single canonical
rule: ECMAScript Number::toString (equivalently, String() applied to the
value parsed from JSON). This guarantees a numeric unit key produces the same
string — and therefore the same bucket — on every SDK.
- Integers render without a decimal point or exponent within their integer
range:
42→"42",100→"100". A JSON42.0parses to the number42and renders"42". - Non-integers render minimally:
1.5→"1.5",0.1→"0.1". - Very large / very small magnitudes use exponential form exactly as
Number::toStringspecifies:1e21→"1e+21",1e-7→"1e-7". -0renders"0". Booleans render"true"/"false".
Python's engine/strings.py is the reference port of this rule for non-JS
SDKs; other SDKs MUST match its output byte-for-byte.
iOS today stringifies numeric keys via
String(Int64(n)), which is trapping — it can crash on values ≥ 2^63 and truncates fractionals. It is replaced with the canonical rule in Phase 2.
Conformance vectors: test-vectors/fixtures/bundle_numeric_unit_key.json /
expected_numeric_unit_key.json lock the canonical form for 9007199254740993
(2^53 + 1 → "9007199254740992") and 1e21 (→ "1e+21").
Empty layer unit-key override (skip the layer) (S1)
A layer MAY carry a unitKey override to bucket on a different context field
(multi-entity randomization). If a layer's unitKey override is empty or
whitespace-only, the override is invalid and the SDK MUST:
- Skip the layer — emit its resolution row with
bucket = -1, match no policy, and leave the layer's parameters at their defaults. - Record no exposure for that layer.
- NOT fall back to the project-level
hashing.unitKeyfor that layer. - NOT crash and NOT reject the whole bundle — every other layer resolves normally.
This is distinct from a valid override naming a field that happens to be
absent from the context: that layer is likewise skipped (bucket -1), but for
the missing-value reason. The empty/whitespace override string itself is the
invalid-configuration case S1 governs.
The current reference JS engine treats an empty-string layer override as falsy and falls back to the project unit key — this is WRONG per this spec. JS is the 1-of-4 outlier and is corrected in Phase 2.
Conformance vectors: test-vectors/fixtures/bundle_empty_unit_key.json /
expected_empty_unit_key.json (hand-authored to the decided skip semantics —
both an empty "" and a whitespace " " override are skipped with bucket
-1, while the project-keyed layer resolves normally).
Contextual Model Resolution
Policies with algorithm: "linear_contextual" ship a trained model in the bundle via the contextualModel field on BundlePolicy. When present, the SDK uses this model to compute personalized allocation probabilities instead of the standard bucket-based assignment.
Discriminator note. The
algorithmfield (linear_contextual,thompson_bernoulli,epsilon_greedy,ucb1) is an optional, server-provided informational label — it is defined onBundlePolicyinconfig-bundle.schema.jsonpurely as telemetry/metadata and carries no resolution semantics. The SDK-side discriminator for contextual scoring is the presence ofcontextualModel: if a policy carries a trainedcontextualModel, the SDK runs the scoring pipeline below; otherwise it falls through to bucket-based resolution (see Graceful Degradation). An SDK never needs to readalgorithmto resolve a policy.
Scoring Pipeline
- Linear score per allocation:
score = intercept + SUM(coef_i * feature_i). For each allocation, look up itsBundleAllocationCoefficients. If an allocation has no coefficients, usedefaultAllocationScore. - Softmax: Convert raw scores to probabilities using temperature
gamma. Lower gamma is more deterministic (exploitative), higher gamma is more uniform (explorative). - Probability floor: Enforce
actionProbabilityFlooras a minimum probability for any allocation to ensure continued exploration. Clamp below-floor entries and renormalize. - Deterministic selection: Use
weightedSelection(probabilities, seed)with seed"ctx:" + unitKeyValue + ":" + policyIdto deterministically select an allocation via the SHA-256 v2 hash.
Feature Types
- Numeric:
score += coef * contextValue. When the context field is missing or non-numeric,missingis added instead. - Categorical:
score += values[contextValue]. When the context field is missing or the value is not in thevaluesmap,missingis added instead.
Graceful Degradation
If contextualModel is absent on a policy (no trained model yet), the SDK falls through to standard bucket-based resolution using the allocation bucket ranges. This means newly-created contextual policies serve uniform traffic until the first training run publishes coefficients.
Numerical guards (S6)
The softmax and floor steps have two mandatory guards so that all SDKs produce the same probabilities at the edges of the parameter space:
safeGamma = max(gamma, 1e-10)— the softmax temperature used for scaling issafeGamma, never the rawgamma. Agammaof0(or any value below1e-10) is clamped to1e-10, yielding a near-deterministic-but-defined distribution. SDKs MUST NOT divide by a zero temperature, and MUST NOT substitute an argmax shortcut or reset the temperature to1.effectiveFloor = min(floor, 1/n)— wherefloorisactionProbabilityFloorandnis the number of allocations. The floor applied per allocation iseffectiveFloor, so flooring can never demand more than 100% of the probability mass. Each probability is raised toeffectiveFloor, then the vector is renormalized. Whenfloor <= 0, flooring is skipped.
iOS today maps
gamma = 0to temperature1(then argmax) and omits themin(floor, 1/n)cap. It is aligned to these guards in Phase 2.
Conformance vectors: bundle_contextual_gamma_zero.json /
expected_contextual_gamma_zero.json lock the gamma = 0 near-argmax
distribution ([0.909091, 0.045455, 0.045455]), and
bundle_contextual_high_floor.json / expected_contextual_high_floor.json lock
the floor = 0.5, n = 3 capped distribution (effectiveFloor = 1/3 →
[0.524953, 0.237524, 0.237524], NOT a raw-0.5 floor).
Model version sourcing (S7)
When an SDK emits an exposure/decision event for a linear_contextual
selection, layers[].modelVersion MUST be sourced as:
modelVersion = contextualModel.generatedAt ?? contextualModel.modelVersiongeneratedAt (an ISO 8601 date-time, added to BundleContextualModel in Part
A) is the canonical source; the legacy modelVersion label is the only
fallback. There is no further fallback to policy.stateVersion — if both
generatedAt and modelVersion are absent, the SDK MUST omit
modelVersion entirely rather than emit a wrong label.
The current reference JS engine still falls back to
policy.stateVersion(resolution/engine.ts); that fallback is removed in Phase 2.
Test Vectors
See test-vectors/fixtures/bundle_contextual.json and expected_contextual.json for conformance test cases covering numeric features, categorical features, missing context fields, and unknown categorical values. Near-gridline softmax behavior is locked by bundle_contextual_boundary.json / expected_contextual_boundary.json, and the S6 numerical guards (safeGamma, effectiveFloor) by bundle_contextual_gamma_zero.json and bundle_contextual_high_floor.json.
Exposure event contract
trackExposure() records that a unit actually saw the parameters resolved
by a decide(). The exposure stream feeds treatment-on-the-treated metrics and
carries the 0.6.0 propensity rows (layers[].probability / modelVersion), so
its shape MUST be identical across SDKs.
Canonical shape (S4)
- One exposure event per
trackExposure()call. SDKs MUST NOT emit one event per layer. - The event's
layers[]contains only newly-exposed, non-attributionOnlylayers:attributionOnlylayers — layers resolved for attribution but whose parameters were not requested by this decision — MUST be excluded (they would inflate exposure for experiments the unit never saw).- Layers already exposed for this unit in the current session (see dedup below) MUST be excluded.
- If, after that filtering, no layers remain, the SDK MUST emit no event.
- Each surviving layer row carries its propensity fields (
probability,modelVersion) exactly as produced by resolution.
This is the shape the Node SDK emits today. js-client and iOS currently emit
one event per newly-deduped layer, each carrying the full unfiltered
layers array (including attributionOnly); php and python emit a single
event with no dedup. All four are aligned to the canonical shape in Phase 2.
Conformance vectors: test-vectors/fixtures/exposure_shape.json gives, for each
decision (resolved layers + alreadyExposed dedup state), the exact
expectedEvents trackExposure() must emit — one event with attributionOnly and
already-exposed layers filtered out, or zero events when nothing survives.
Session deduplication (on by default)
Exposure dedup is on by default. A (unit, layer, allocation) combination
already exposed within the current session is suppressed from subsequent
events.
- Client SDKs (browser / mobile): a persisted 30-minute session window
(default TTL), surviving across
decide()/trackExposure()calls and page loads, cleared when the unit changes (identify). - Server SDKs: a bounded in-memory LRU with a TTL (never an unbounded map), so long-lived processes cannot leak memory.
- Dedup is configurable: an opt-out flag (
deduplicateExposures) and a TTL knob (exposureSessionTtlMs) MUST exist on every SDK.
SDK-side dedup is a volume optimization only. The ingestion pipeline is authoritative for final deduplication and counting; SDKs MUST NOT assume their local dedup is exact across processes or devices.
SDK runtime behavior
The spec defines data and math; this section defines the network and lifecycle behavior every SDK MUST implement so runtime semantics don't drift.
Fail-open (including malformed bundles) (S8)
An SDK MUST never crash or throw out of decide() / getParams() because
configuration is unavailable or bad. It degrades to the best available source in
this order: last-good cached bundle → localConfig (build-time baked bundle, if
provided) → caller-supplied defaults.
Fail-open applies to both:
- Fetch failures — HTTP 404, network error, or timeout on the config request.
- Malformed bundles — a fetched bundle that fails schema validation, has
bucketCount < 1, or is missinghashing.unitKey. A malformed bundle MUST be discarded (keep the last-good bundle, or fall through tolocalConfig/ defaults); it MUST NOT replace a good cached bundle and MUST NOT crash the SDK.
The empty layer unitKey override (S1) is handled at the layer level (skip that
layer), not by failing the whole bundle.
Caching, refresh, and jitter
- The SDK caches the last-good bundle and refreshes on an interval (default 60s).
- The refresh interval MUST be applied with jitter (±10% recommended) to avoid synchronized thundering-herd refetches across clients.
- When the config response carries
suggestedRefreshMs, the SDK MUST honor it in place of the default interval. (PHP/iOS currently parse-and-ignore it; aligned in Phase 2.)
Event delivery
Event delivery standardizes on the Python SDK's model:
- Bounded queue — events are buffered in a bounded in-memory queue. When the queue is full the SDK drops (oldest-first) rather than growing without limit. Unbounded requeue-on-failure is forbidden.
- Batching — flush when the batch reaches the batch size (default 10) or when the flush interval (default 30s) elapses, whichever comes first.
- Retry with backoff — transient delivery failures (network, 5xx, timeout) are retried with exponential backoff, bounded in attempts; events stay in the bounded queue between attempts.
- Auth kill-switch — on an HTTP 401, the SDK MUST permanently disable event delivery (stop retrying, stop buffering) for the process lifetime rather than spin on a credential that will never succeed.
Mandatory timeouts
SDKs MUST set explicit request timeouts and MUST NOT rely on platform defaults:
| Request | Default timeout | |-------------------|-----------------| | Config fetch | 10s | | Event delivery | 10s | | Server resolve | 5s |
Server evaluation mode
In server-evaluated mode the SDK MUST resolve each call with the per-call
context passed to that decide() / getParams() / resolve call — not a
snapshot captured at initialization, and not an empty context. A unit key is
required per call. (JS server-mode currently resolves once with an empty
context; corrected in Phase 2.)
SDK Implementations
| Language | Repository | |----------|------------| | JavaScript/TypeScript | traffical/js-sdk | | Go | Coming soon | | Java | Coming soon |
License
MIT License - see LICENSE for details.
