{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "https://traffical.io/schemas/config-bundle.json",
  "title": "ConfigBundle",
  "description": "The complete configuration bundle for a Traffical project/environment. This is what the SDK fetches and caches.",
  "type": "object",
  "required": ["version", "orgId", "projectId", "env", "hashing", "parameters", "layers"],
  "properties": {
    "version": {
      "type": "string",
      "format": "date-time",
      "description": "ISO timestamp for cache invalidation / ETag generation"
    },
    "orgId": {
      "type": "string",
      "description": "Organization ID"
    },
    "projectId": {
      "type": "string",
      "description": "Project ID"
    },
    "env": {
      "type": "string",
      "description": "Environment (e.g., 'production', 'staging')"
    },
    "hashing": {
      "$ref": "#/definitions/BundleHashingConfig"
    },
    "parameters": {
      "type": "array",
      "items": {
        "$ref": "#/definitions/BundleParameter"
      },
      "description": "All parameters for this project/env with defaults and layer membership"
    },
    "layers": {
      "type": "array",
      "items": {
        "$ref": "#/definitions/BundleLayer"
      },
      "description": "All layers with their policies"
    }
  },
  "definitions": {
    "BundleHashingConfig": {
      "type": "object",
      "required": ["unitKey", "bucketCount"],
      "properties": {
        "unitKey": {
          "type": "string",
          "description": "The context field name to use as the unit key (e.g., 'userId', 'deviceId')"
        },
        "bucketCount": {
          "type": "integer",
          "minimum": 1,
          "description": "Total number of buckets for allocation (e.g., 1000, 10000)"
        },
        "algorithm": {
          "type": "string",
          "enum": ["sha256-v2"],
          "description": "Internal assignment-hash algorithm marker for forward compatibility. Not user-facing; there is no FNV-1a fallback. When omitted, SDKs assume 'sha256-v2'. New bundles always emit 'sha256-v2'."
        }
      }
    },
    "BundleParameter": {
      "type": "object",
      "required": ["key", "type", "default", "layerId", "namespace"],
      "properties": {
        "key": {
          "type": "string",
          "description": "Parameter key (e.g., 'ui.primaryColor', 'pricing.discount')"
        },
        "type": {
          "type": "string",
          "enum": ["string", "number", "boolean", "json"],
          "description": "Value type"
        },
        "default": {
          "description": "Default value when no policy overrides apply"
        },
        "layerId": {
          "type": "string",
          "description": "The layer this parameter belongs to"
        },
        "namespace": {
          "type": "string",
          "description": "Namespace for organizational purposes"
        }
      }
    },
    "BundleLayer": {
      "type": "object",
      "required": ["id", "policies"],
      "properties": {
        "id": {
          "type": "string",
          "description": "Layer ID"
        },
        "unitKey": {
          "type": "string",
          "description": "Optional per-layer unit key override. When set, the SDK hashes on this context field instead of hashing.unitKey for this layer only. Used for multi-entity randomization (e.g. merchantId layer in a userId-primary project)."
        },
        "policies": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/BundlePolicy"
          },
          "description": "Policies within this layer (evaluated in order)"
        }
      }
    },
    "BundleContextLogging": {
      "type": "object",
      "required": ["allowedFields"],
      "properties": {
        "allowedFields": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Context fields to include in exposure events (allowlist). Only these fields will be logged for contextual bandit training."
        }
      },
      "description": "Configuration for context logging in exposure events. Used for contextual bandit training while protecting PII."
    },
    "BundleEntityConfig": {
      "type": "object",
      "required": ["entityKeys", "resolutionMode"],
      "properties": {
        "entityKeys": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Context field key(s) identifying the entity dimension(s) this policy randomizes over (e.g. ['productId'], ['merchantId'])."
        },
        "resolutionMode": {
          "type": "string",
          "enum": ["bundle", "edge"],
          "description": "Where per-entity allocation weights are resolved: 'bundle' = weights shipped in the config bundle; 'edge' = resolved per-request at the edge worker."
        },
        "dynamicAllocations": {
          "type": "object",
          "required": ["countKey"],
          "properties": {
            "countKey": {
              "type": "string",
              "description": "Context field key whose value gives the number of dynamic allocations to generate at resolution time (variable-arm policies)."
            }
          },
          "description": "Optional configuration for policies whose allocation count is determined dynamically from context rather than a fixed allocations array."
        }
      },
      "description": "Multi-entity / per-entity randomization configuration for a policy."
    },
    "BundlePolicy": {
      "type": "object",
      "required": ["id", "state", "kind", "allocations", "conditions"],
      "properties": {
        "id": {
          "type": "string",
          "description": "Policy ID for tracking and analytics"
        },
        "key": {
          "type": "string",
          "description": "Stable policy identifier, emitted on events as `layers[].policyKey` and joined on in warehouse assignment data. Same identity rule as `BundleAllocation.key`: optional only for bundles produced before it existed, resolve as `key ?? id` for the event field, and never treat a display label as an identifier."
        },
        "state": {
          "type": "string",
          "enum": ["draft", "running", "paused", "completed"],
          "description": "Current state of the policy"
        },
        "kind": {
          "type": "string",
          "enum": ["static", "adaptive"],
          "description": "Policy kind: 'static' for fixed allocations, 'adaptive' for learning-based"
        },
        "allocations": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/BundleAllocation"
          },
          "description": "Bucket ranges mapped to parameter overrides"
        },
        "conditions": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/BundleCondition"
          },
          "description": "Context predicates that must all match for eligibility"
        },
        "stateVersion": {
          "type": "string",
          "format": "date-time",
          "description": "For adaptive policies: version of the optimization state"
        },
        "eligibleBucketRange": {
          "type": "array",
          "items": {
            "type": "integer"
          },
          "minItems": 2,
          "maxItems": 2,
          "description": "Optional policy-level eligibility bucket range [start, end] inclusive. Units whose assignment bucket falls outside this range are not eligible for this policy (holdback / partial rollout). When omitted, all buckets are eligible."
        },
        "entityConfig": {
          "$ref": "#/definitions/BundleEntityConfig",
          "description": "Optional multi-entity / per-entity randomization configuration for this policy."
        },
        "contextLogging": {
          "$ref": "#/definitions/BundleContextLogging",
          "description": "For adaptive policies: context fields to log in exposure events for contextual bandit training"
        },
        "contextualModel": {
          "$ref": "#/definitions/BundleContextualModel",
          "description": "For linear_contextual policies: trained model for SDK-side scoring. When present, the SDK computes personalized allocation probabilities instead of using bucket-based assignment."
        },
        "algorithm": {
          "type": "string",
          "enum": ["linear_contextual", "thompson_bernoulli", "epsilon_greedy", "ucb1"],
          "description": "Optional server-provided informational metadata naming the adaptive optimization algorithm behind this policy. Telemetry/labeling only: SDKs MUST NOT use it as the resolution discriminator. The SDK-side discriminator for contextual scoring is the PRESENCE of `contextualModel` (a trained model) — an SDK never needs `algorithm` to resolve a policy. Typically absent on non-adaptive (static) policies."
        }
      }
    },
    "BundleAllocation": {
      "type": "object",
      "required": ["name", "bucketRange", "overrides"],
      "properties": {
        "id": {
          "type": "string",
          "description": "Optional stable allocation ID. Used by edge/per-entity resolution and analytics to reference the allocation independent of its display name."
        },
        "name": {
          "type": "string",
          "description": "Human-readable variant name (e.g. 'Control', 'Treatment A'). DISPLAY ONLY — never use `name` as an identifier. It is author-editable, may contain spaces and punctuation, and is not guaranteed to equal `key`. Emitted on events as `layers[].allocationName` for readability; anything that must JOIN uses `key`."
        },
        "key": {
          "type": "string",
          "description": "Stable identifier for this allocation — THE identifier the SDK must key on. It is the value written to the warehouse `allocation_name` column, the value emitted as `layers[].allocationKey`, and the key of every allocation-keyed map in this bundle (notably `BundleContextualModel.coefficients`). Optional only for backward compatibility with bundles produced before `key` existed: when absent, and ONLY then, fall back to `name`. Resolution is therefore `key ?? name`, never `name` alone."
        },
        "bucketRange": {
          "type": "array",
          "items": {
            "type": "integer"
          },
          "minItems": 2,
          "maxItems": 2,
          "description": "Bucket range [start, end] inclusive"
        },
        "overrides": {
          "type": "object",
          "additionalProperties": true,
          "description": "Parameter overrides for units in this bucket range"
        }
      }
    },
    "BundleCondition": {
      "type": "object",
      "required": ["field", "op"],
      "properties": {
        "field": {
          "type": "string",
          "description": "Context field to evaluate"
        },
        "op": {
          "type": "string",
          "enum": ["eq", "neq", "in", "nin", "gt", "gte", "lt", "lte", "contains", "startsWith", "endsWith", "regex", "exists", "notExists"],
          "description": "Comparison operator"
        },
        "value": {
          "description": "Single value for binary operators"
        },
        "values": {
          "type": "array",
          "description": "Multiple values for 'in'/'nin' operators"
        }
      }
    },
    "BundleContextualModel": {
      "type": "object",
      "required": ["gamma", "actionProbabilityFloor", "defaultAllocationScore", "coefficients"],
      "properties": {
        "generatedAt": {
          "type": "string",
          "format": "date-time",
          "description": "ISO 8601 timestamp of when this contextual model's coefficients were generated/trained. SDKs emit this as the exposure event layers[].modelVersion (preferred over the legacy modelVersion label)."
        },
        "modelVersion": {
          "type": "string",
          "description": "Legacy model version label. Deprecated in favor of generatedAt; retained only as the fallback source for layers[].modelVersion when generatedAt is absent. SDKs must NOT fall back further to policy.stateVersion."
        },
        "gamma": {
          "type": "number",
          "minimum": 0,
          "description": "Softmax temperature (0-1). Lower values make selection more deterministic."
        },
        "actionProbabilityFloor": {
          "type": "number",
          "minimum": 0,
          "maximum": 1,
          "description": "Minimum probability for any allocation (ensures continued exploration)."
        },
        "defaultAllocationScore": {
          "type": "number",
          "description": "Default score for allocations without trained coefficients."
        },
        "coefficients": {
          "type": "object",
          "additionalProperties": {
            "$ref": "#/definitions/BundleAllocationCoefficients"
          },
          "description": "Coefficients per allocation, keyed by allocation `key` (falling back to `name` only for allocations that carry no `key`). Resolving these by display `name` misses every allocation whose name differs from its key, silently scores those arms with `defaultAllocationScore`, and collapses the model to a uniform softmax — see the `bundle_contextual_key_differs` conformance vector."
        }
      },
      "description": "Contextual bandit model for SDK-side scoring. Contains trained coefficients used to compute personalized allocation probabilities via softmax."
    },
    "BundleAllocationCoefficients": {
      "type": "object",
      "required": ["intercept", "numeric", "categorical"],
      "properties": {
        "intercept": {
          "type": "number",
          "description": "Base score (bias term)."
        },
        "numeric": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/BundleNumericCoefficient"
          },
          "description": "Numeric feature coefficients."
        },
        "categorical": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/BundleCategoricalCoefficient"
          },
          "description": "Categorical feature coefficients."
        }
      },
      "description": "Trained coefficients for a single allocation."
    },
    "BundleNumericCoefficient": {
      "type": "object",
      "required": ["key", "coef", "missing"],
      "properties": {
        "key": {
          "type": "string",
          "description": "Context field key."
        },
        "coef": {
          "type": "number",
          "description": "Coefficient value."
        },
        "missing": {
          "type": "number",
          "description": "Value to add when the field is missing from context."
        }
      },
      "description": "Numeric coefficient: score += coef * contextValue, or missing when absent."
    },
    "BundleCategoricalCoefficient": {
      "type": "object",
      "required": ["key", "values", "missing"],
      "properties": {
        "key": {
          "type": "string",
          "description": "Context field key."
        },
        "values": {
          "type": "object",
          "additionalProperties": {
            "type": "number"
          },
          "description": "Map of category values to their coefficients."
        },
        "missing": {
          "type": "number",
          "description": "Value to add when the field is missing or has an unknown value."
        }
      },
      "description": "Categorical coefficient: score += values[contextValue], or missing when absent/unknown."
    }
  }
}

