Skip to content

SelectionModelExportV1 — envelope format

The full selection model — factors, factor options, option→attribute mappings, option exclusions, rulesets, rules (with conditions and outcomes), ruleset memberships, tracks, and track display rules — as one versioned, deterministic JSON envelope (#1962). Implementation: src/lib/server/selection-model-export.ts; generalizes the ProfileExportV1 pattern (#1819).

Operator workflow (export → experiment → promote): promote-selection-model.

Surfaces

SurfaceWhat it does
/admin/modelInstance summary, export download button, import with mandatory dry-run diff
/admin/model/exportGET download (edit_rules-gated, audit-logged)
npm run model:exportCLI export to a file (local dev D1 by default, -- --remote for prod)
npm run model:importCLI import — dry-run by default, -- --apply to write
npm run model:promoteLocal→remote promotion: exports local, dry-runs against remote, applies on confirm

Envelope shape

jsonc
{
  "schemaVersion": 1,
  "exportedAt": "2026-08-11T06:57:08.928Z", // source metadata
  "instance": "nanaselect.iwpi.com",
  "factors": [{ "id": "geography", "name": "…", "educationMediaAssetId": null, "retiredAt": null /* … */ }],
  "factorOptions": [{ "id": "geo-coastal", "factorId": "geography" /* … */ }],
  "factorOptionAttributes": [{ "factorOptionId": "geo-coastal", "attributeId": "corrosion-resistance", "operator": "=", "expectedValue": "<enum value id>" }],
  "factorOptionExclusions": [{ "optionAId": "…", "optionBId": "…" }],
  "ruleSets": [{ "id": "default", "name": "Default", "status": "active" }],
  "rules": [
    {
      "id": "coastal-prefers-sl73",
      "rationale": "…",
      "priority": 0,
      "effect": "recommend",
      "enabled": true,
      "retiredAt": null,
      "conditions": [{ "kind": "option", "factorOptionId": "geo-coastal" /* … */ }],
      "outcomes": [{ "systemId": "sl73", "role": "preferred", "rank": 0 }]
    }
  ],
  "ruleSetMembers": [{ "ruleSetId": "default", "ruleId": "coastal-prefers-sl73", "priority": null }],
  "tracks": [{ "id": "<uuid>", "slug": "homeowner", "factorRefs": ["geography"], "preseededAnswers": {}, "firstPublishedAt": "…" /* … */ }],
  "trackDisplayRules": [{ "id": "<uuid>", "trackId": null, "targetType": "factor" /* … */ }]
}

Determinism rules (what makes exports diffable):

  • JSON keys are sorted recursively; every collection has a stable sort (entities by id; conditions/outcomes by content; junctions by composite key).
  • Storage artifacts never travel: no autoincrement ids (conditions/outcomes nest under their rule), no createdAt/updatedAt.
  • Track JSON columns (factorRefsJson, preseededAnswersJson) travel parsed (factorRefs, preseededAnswers).

Import semantics

Import is an authoritative replace with a mandatory dry-run diff (creates / updates / retires / unchanged per entity) shown before apply. Admin tier (#1585/#569): any unresolvable reference is a loud, named error that refuses the whole import — there is no partial silent import. Apply writes in bounded atomic db.batch groups.

Entity kindPresent in envelopeAbsent from envelope (but on target)
factors, rulesupsert by idsoft-retire (retiredAt = now)
rulesets, tracksupsert by idstatus = 'retired'
options, mappings, exclusions, memberships, display rulesupsert/replace-setdeleted
rule conditions/outcomesrewritten wholesale per imported rule(kept for retired rules)

Reference resolution on the target:

  • system_id (rule outcomes) and attribute_id (conditions, mappings) must exist on the target — else the error names the offending rule/mapping.
  • Enum-value refs (attribute-condition thresholds, mapping expected values, for valueType = 'enum' attributes) resolve through the alias tables per the vocabulary-identity contract (#1851): current value ids resolve to themselves, legacy labels resolve via attribute_enum_value_aliases, and the resolved canonical id is what gets written. Never label-derived.
  • Media refs (educationMediaAssetId) are soft by spec: absent on the target → warning + imported as NULL. R2 objects are out of scope.

Track slug semantics

A track's slug is locked after first publish (firstPublishedAt latch, same as the Tracks admin). On import:

  • Same track id, target already published, envelope slug differs → the target's published slug is kept, with a warning (a published URL never rots).
  • An envelope track claiming a slug owned by a different track id on the target → loud error, import refused. Resolve by renaming before export.

Prior art / migration

scripts/dump-recommendation-fixture.ts (the #1849 parity fixture) remains the parity gate's raw-row snapshot; the round-trip spec (selection-model-roundtrip.spec.ts) proves the envelope path produces engine-identical output from the same fixture, tying into the #1964 golden suite.