Skip to content

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).

ColumnMeaning
idOpaque UUID.
track_idFK → 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_idThe hidden factor id (target_type='factor') or option id ('option').
source_factor_idFK → factors. The earlier factor whose answer is tested.
operator'in' | 'not_in'.
value_option_ids_jsonJSON 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:

OperatorMatches when…
inthe source factor's chosen option is among value_option_ids.
not_inthe 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 targetHidden when…
nonenever (default — shown)
onethat rule matches
manyall 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:

  • operator and target_type are well-formed;
  • source_factor_id is 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_ids is 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 keytarget_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_id is 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).