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:
{
"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:
GET /api/v1/models/latest
?policy=pol_forest1
&platform=ios
&asset_type=e_bike
®ion=GB
&app_version=1.7.2Inside the route, pickBundleFromRows(rows, ctx) does the work
(selectBundleForRequest() wraps it with the Supabase fetch):
- Load all
is_active = truebundles for the policy. - Drop the
shadowtier — those are consumed offline by the drift evaluator, never returned to a caller. - Apply
targetingagainst the request context. - Compute a sticky bucket via
stableBucket(customer_id, device_id)(SHA-256, first 8 hex chars mod 100 → 0–99). - For
canaryrows, keep them only ifbucket < weight. If canary bucketing excludes everything, fall back to theproductiontier. - Sort by
priority DESC, thenweight DESC, thencreated_at DESCand 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:
// 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,
});// 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:
-- 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
- Detecting model drift — what to watch on canary cohorts before promoting.
- Training custom models — building the bundle that this guide dispatches.