Appearance
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
| Surface | What it does |
|---|---|
/admin/model | Instance summary, export download button, import with mandatory dry-run diff |
/admin/model/export | GET download (edit_rules-gated, audit-logged) |
npm run model:export | CLI export to a file (local dev D1 by default, -- --remote for prod) |
npm run model:import | CLI import — dry-run by default, -- --apply to write |
npm run model:promote | Local→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 kind | Present in envelope | Absent from envelope (but on target) |
|---|---|---|
| factors, rules | upsert by id | soft-retire (retiredAt = now) |
| rulesets, tracks | upsert by id | status = 'retired' |
| options, mappings, exclusions, memberships, display rules | upsert/replace-set | deleted |
| rule conditions/outcomes | rewritten wholesale per imported rule | (kept for retired rules) |
Reference resolution on the target:
system_id(rule outcomes) andattribute_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 viaattribute_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.