Appearance
Track conditional display
A selection track can gate its own questions: an answer to one factor can hide a later factor — or a single option within a later factor — for the rest of that buyer's flow (#472). It extends the track model beyond subset + order + pre-seed (#144) into conditional display, so a track can express "if they picked coastal, don't ask about heavy snow."
Two invariants frame the whole feature:
- Display-only. A rule controls what is shown. It never sets a derived or default answer — a hidden factor simply contributes nothing (see partial context below).
- Subtractive. Everything shows by default (the pre-#472 behavior). A rule only ever hides. A track with no rules behaves exactly as before — zero regression.
Data model
Rules live in a small normalized table, track_display_rules (src/lib/server/db/schema.ts), kept separate from the recommendation recommendation_rules / rule_conditions tables (#258) — a different concern with a different lifecycle — but sharing their set-membership predicate (see shared predicate).
| Column | Meaning |
|---|---|
id | Opaque UUID. |
track_id | FK → tracks (ON DELETE CASCADE). NULL = a global factor-level dependency (#905), which this track-scoped layer never loads. |
target_type | 'factor' | 'option' — hide a whole later factor, or one of its options. |
target_id | The hidden factor id (target_type='factor') or option id ('option'). |
source_factor_id | FK → factors. The earlier factor whose answer is tested. |
operator | 'in' | 'not_in'. |
value_option_ids_json | JSON array of source-factor option ids — the set the answer is tested against (OR within one rule). |
value_option_ids is a JSON leaf inside an otherwise-normalized row — the same hybrid shape as rule_conditions. The parsed domain shape is DisplayRuleRecord (src/lib/server/admin/track-display-rules.ts); the client-safe mirror is TrackDisplayRule (src/lib/track-flow.ts).
Semantics
A rule matches (and therefore hides its target) as follows:
| Operator | Matches when… |
|---|---|
in | the source factor's chosen option is among value_option_ids. |
not_in | the source factor has an answer and it is not among value_option_ids. |
The not_in "has an answer" clause is the subtractive stance made precise: an unanswered source hides nothing under either operator, so a target only ever disappears once the buyer has actually made the gating choice — never before.
Multiple rules on one target combine with AND. A target is hidden only when it has at least one rule and every rule on it matches. One rule that fails to match leaves the target visible.
| Rules on target | Hidden when… |
|---|---|
| none | never (default — shown) |
| one | that rule matches |
| many | all of them match |
Validation
Integrity is enforced at write time, pure core in validateDisplayRules (src/lib/server/admin/track-display-rules.ts), DB-backed wrapper validateTrackDisplayRules. Every rule must satisfy:
operatorandtarget_typeare well-formed;source_factor_idis a factor the track asks (so its answer can gate);- the target resolves into the track — a target factor is in the ask-list; a target option exists and its owning factor is in the ask-list;
- earlier-only: the source factor is strictly before the target's factor in the track's ask order. A factor cannot gate itself, and it cannot reference a later answer — this rules out forward and circular references, and is what makes runtime evaluation a single forward pass.
value_option_idsis non-empty and every id is an option of the source factor.
Each message names the offending rule (1-based) and the invariant it broke, so the admin editor can point at exactly what to fix.
Runtime evaluation
Evaluation is a pure, client-safe forward pass, applyDisplayRules (src/lib/track-flow.ts), consumed by the guided-selection stepper. Given the ordered factor list, the rules, and the buyer's live answers it returns the effective step list: hidden factors dropped, each surviving factor's options filtered to the visible ones.
A single pass is correct precisely because validation guarantees a rule's source is asked before its target — so by the time a factor is judged, every answer its rules test has already been accumulated. SelectionFlow.svelte derives steps from this and routes every stepper decision through it — current step, progress label, navigation bounds, the engine SelectionContext, the dot-strip and the overview — so a hidden question genuinely does not exist for the flow. The full factor list is retained only to parse stored answers, so an answer to a now-hidden factor survives and reappears intact if an earlier change un-hides it.
Partial context and right-sizing
Only answers to still-visible factors/options feed later evaluation and the engine context. A hidden factor therefore contributes nothing — the buyer's flow degrades to a partial SelectionContext, which the recommendation matching engine and the cost-tier right-sizing layer already tolerate by design (fewer answers = fewer constraints, never an error). Conditional hiding is treated as intentional scoping, so the static right-sizing coverage warning (#151) is unaffected — a conditionally-shown factor still counts as covered (it can appear), and no new warning is raised.
Shared predicate
The membership test at the heart of both a display rule and a recommendation-rule option condition — "did the buyer choose one of these options?" — is authored once as selectionIncludesAny (src/lib/predicate.ts), a client-safe primitive both applyDisplayRules and the engine's ruleMatchesContext (src/lib/server/recommend.ts) call. Match logic lives in exactly one place.
Referential integrity (#1869)
target_id and value_option_ids_json reference factor options with no foreign key — target_id is a plain text column (it holds a factor id or an option id, depending on target_type) and the value list is JSON. Deleting an option therefore used to leave rules pointing at a target that no longer existed, silently.
Since #1869 that is guarded from the other side: removeOption refuses to delete an option any display rule targets or tests for, and the refusal names the rule and its track. The single lookup is optionReferences in $lib/server/admin/factor-option-refs — the delete guard, the operator-facing refusal, and the orphan report all read it, so there is only ever one definition of "references this option".
Two details that matter when reading that code:
- The JSON is parsed, not
LIKE-matched. A substring probe would report an option as referenced because a different option's value contains it as a prefix, and a false block on a delete is as damaging as a missed one. track_idis nullable, and NULL means a global factor-level rule (#905). Those references are reported with a null track rather than a fabricated name, so the operator is told "a global display rule" and not sent to a track page that has nothing to do with it.
Pre-existing dangling references are reported by npm run verify:option-refs and on the Structure graph. Report only — no auto-repair.
Coexistence with factor-level dependencies
Since #910, track rules are the curation layer on top of the global factor-dependency layer: global rules evaluate first, track rules evaluate over the dependency-filtered answers, and hiding is the union of both. Track authors see globally-imposed rules read-only on the Display rules tab; nothing in this document's semantics changed — a track-scoped rule behaves exactly as specified above.
Authoring
Rules are edited on the Display rules tab of the track edit screen — see Managing tracks › Display rules. The whole set is submitted as one displayRulesJson field, validated with the core track fields, and persisted by replaceTrackDisplayRules (full replace per track).