Model dispatch and targeting

A single policy can have more than one active bundle. We use dispatch targeting to choose which bundle a given device receives at update time, so you can:

  • Ship a region-specific model (e.g. UK-tuned forest e-bike detector).
  • Pin an older bundle for legacy app versions while newer apps get the latest one.
  • Roll out a new model to 10% of users as a canary while the rest stay on production.
  • A/B test two candidate models in shadow mode.

All bundle selection happens server-side in selectBundleForRequest() and is keyed on stable inputs the SDK sends along with its update check. The device never has to know about the targeting rules.

Targeting fields

Each row in verify_ai_model_bundles carries the existing policy_id + bundle_version + artifacts along with these dispatch columns (added in 20260620_model_dispatch_targeting.sql):

| Column | Type | Purpose | | ------------- | ------- | ---------------------------------------------------------------------------------- | | targeting | jsonb | Match rules. Empty {} means "matches every request". | | bundle_tier | text | production (default), canary, or shadow. | | weight | int | Bucket allocation 0–100 within a tier. Tiers are evaluated together. | | priority | int | Tiebreaker. Higher wins when multiple rows match the same bucket. | | is_active | bool | Master switch — flip to false to remove a bundle from rotation without deleting. |

The targeting JSON can include any of:

json
{
  "asset_types": ["scooter", "e_bike"],
  "regions": ["GB", "US-CA"],
  "min_app_version": "1.4.0",
  "max_app_version": "2.0.0",
  "customer_ids": ["cus_forest", "cus_acme"]
}

Any unset field is wild-card: omit regions and the bundle is considered in every region. min_app_version / max_app_version compare with compareSemver() (so 1.10.0 > 1.9.0).

Endpoint

The dispatch endpoint is the same one mobile SDKs already poll, but it now accepts targeting query params:

bash
GET /api/v1/models/latest
  ?policy=pol_forest1
  &platform=ios
  &asset_type=e_bike
  &region=GB
  &app_version=1.7.2

Inside the route, pickBundleFromRows(rows, ctx) does the work (selectBundleForRequest() wraps it with the Supabase fetch):

  1. Load all is_active = true bundles for the policy.
  2. Drop the shadow tier — those are consumed offline by the drift evaluator, never returned to a caller.
  3. Apply targeting against the request context.
  4. Compute a sticky bucket via stableBucket(customer_id, device_id) (SHA-256, first 8 hex chars mod 100 → 0–99).
  5. For canary rows, keep them only if bucket < weight. If canary bucketing excludes everything, fall back to the production tier.
  6. Sort by priority DESC, then weight DESC, then created_at DESC and return the top row.

Because the bucket is hashed off customer_id + device_id (SHA-256, first 8 hex chars mod 100), a device stays on the same bundle across update checks — no flicker between canary and production on consecutive polls.

SDK side

The SDK's ModelManager.checkForUpdates() already passes policy and platform. Targeting params are passed through opts and forwarded as query string fields:

ts
// React Native
const manager = new ModelManager({ apiKey });
await manager.checkForUpdates("pol_forest1", "ios", {
  assetType: "e_bike",
  region: "GB",
  appVersion: Application.nativeApplicationVersion ?? "0.0.0",
  deviceId: stableInstallId,
});
dart
// Flutter
await modelManager.checkForUpdates(
  'pol_forest1',
  'ios',
  assetType: 'e_bike',
  region: 'GB',
  appVersion: packageInfo.version,
  deviceId: stableInstallId,
);

The customer ID is inferred from the API key. deviceId is the opaque per-install id the host app passes in — it's what makes the canary bucket sticky, so any stable value (an install UUID, an account user id, etc.) is fine. Omit it and the bucket is randomised per request, which defeats canary stickiness.

Canary rollout pattern

The full rollout dance for a new model:

sql
-- 1. Train + register the new bundle (still inactive)
insert into verify_ai_model_bundles
  (policy_id, bundle_version, ontology_version, schema_version,
   policy_ast, artifacts, bundle_tier, weight, is_active)
values
  ('pol_forest1', 42, 'v3', '1.0.0',
   '{...}'::jsonb, '[...]'::jsonb,
   'canary', 10, true);
 
-- 2. Watch metrics for 24-72h via drift detection + recent inferences.
-- 3. If healthy, promote: flip the new row to production weight=100
--    and drop the old one.
update verify_ai_model_bundles
  set bundle_tier = 'production', weight = 100
  where policy_id = 'pol_forest1' and bundle_version = 42;
 
update verify_ai_model_bundles
  set is_active = false
  where policy_id = 'pol_forest1' and bundle_version = 41;

Rollback is a single update ... set is_active = false on the bad bundle — the next /models/latest poll lands every device back on the remaining production row.

What's next

Get in Touch

Questions about pricing, integrations, or custom deployments? We'd love to hear from you.