Skip to content

Right-sizing rule representation

The Recommendation & Right-Sizing engine (#4) turns what a buyer needs into a recommendation: a preferred NanaWall system plus cost-effective alternatives that still meet the requirement, with a short rationale. The unit it evaluates is a rule — data, not code, so rules are admin-editable (#22) without a deploy.

This doc defines that rule representation: its fields, its semantics, and a worked example. It is the design-start half of #4's "coherent explanation" success metric. For the staff-facing editing screens built on it, see managing-rules.md.

Shape at a glance

A rule is three things:

  1. a condition set — when does this rule apply?
  2. an ordered outcome — what does it recommend, and in what order?
  3. a rationale — one buyer-facing sentence explaining the recommendation.
rule
├─ conditions[]   (ALL must hold for the rule to fire)
│   ├─ option     → the buyer selected a specific factor option
│   └─ attribute  → a product attribute meets  operator threshold   (e.g. STC ≥ 50)
├─ outcomes[]     (ordered by rank)
│   ├─ preferred    → the system(s) to lead with
│   └─ alternative  → cost-effective options that still meet the requirement
└─ rationale      (shown to the buyer)

Persistence

Five D1 tables (Drizzle schema in src/lib/server/db/schema.ts, migrations drizzle/0006_* for the rule core, drizzle/0064_* for rulesets, drizzle/0066_* for the shared-library junction):

TableHoldsKey columns
rule_setsthe grouping entityid (kebab key), name, status (draft|active|retired), created_at, updated_at
recommendation_rulesthe rule coreid (kebab key), name, rationale, priority (eval order), enabled (soft on/off), retired_at (soft-delete), created_at
rule_set_members*rule ↔ ruleset (M:N)rule_set_id, rule_id (composite PK), priority (nullable per-ruleset override)
rule_conditionsa rule's condition setrule_id, kind (option|attribute), factor_option_id, attribute_id, operator, threshold
rule_outcomesa rule's ordered outcomerule_id, system_id, role (preferred|alternative), rank

* rule_set_members (#1326) is the shared-library junction: a rule can be a member of many rulesets. It replaced the former single recommendation_rules.rule_set_id FK (#1267). See Rulesets below.

Child rows cascade-delete with their rule (ON DELETE cascade), and each condition/outcome also cascades from the factor option / attribute / system it references, so a retired-then- hard-deleted referent never leaves a dangling condition. rule_set_members cascades from both sides: deleting a ruleset or a rule drops its membership rows (never the other side of the relationship).

Field semantics

Conditions

A rule fires only when every condition holds (logical AND). Each condition is one kind:

  • option — the selection-side hook. Matches when the buyer picked a specific factor_option_id in the guided flow. The attribute influence an option implies is authored separately in factor_option_attributes (#20); a rule condition just names the option. The attribute_id / operator / threshold columns are NULL for this kind.
  • attribute — the product-side hook. Matches when a product attribute_id satisfies operator threshold, e.g. acoustic STC >= 50. operator is one of >=, <=, >, <, =, !=, includes. threshold is stored as text and interpreted by the matching engine (#60) against the attribute's typed value (see value typing). The factor_option_id column is NULL for this kind.

Outcome

An ordered list of systems. role splits the preferred system(s) from cost-effective alternatives; rank orders them within the outcome (0 first). A valid rule has at least one preferred system.

Cost-tier awareness is not duplicated onto the outcome — it is read from the joined system budget_tier (Budget < Mid-range < Premium < Ultra Premium). This keeps the tier in one place (systems.budget_tier) and lets the cost-tier right-sizing work (#61) surface a cheaper alternative that still clears the requirement, without the rule re-stating what a system costs.

Lifecycle: enabled vs retired

Two independent states, mirroring the rest of the knowledge base:

  • enabled — a reversible on/off toggle. A disabled rule does not fire but is otherwise intact; #22's list view flips it without deleting.
  • retired_at — a soft-delete timestamp. A retired rule is preserved and restorable but never fires.

getRules(db) returns only rules that are enabled AND not retired — exactly what the engine should evaluate. The admin editor passes { includeRetired: true, includeDisabled: true } to see every rule.

Rulesets

A ruleset (#1267) is the grouping entity for rules. Rules are a shared library (#1326): a rule is authored once and can be a member of many rulesets via the rule_set_members M:N junction — this superseded #1267's original one-rule-one-ruleset FK. Rulesets are the storage spine the rest of the right-sizing capability builds on — an admin surface to list/create/rename/retire/restore them (#1268, restore #1738 — retired → active, no track re-pointing back), copy (#1269), track → ruleset assignment (#1270), and engine resolution that fires a session's track-resolved ruleset (#1271).

Membership is explicit and many-to-many. A rule fires in exactly the rulesets it is a member of. Two consequences:

  • Reuse vs. fork. Adding an existing rule to another ruleset creates a membership — the same authored rule now fires in both, and editing it (core/conditions/outcome) is reflected in every ruleset that uses it. Copy (#1269) is the deliberate opposite: it deep-clones into fresh, independent rules so a ruleset can diverge. You get both reuse (membership) and independence (copy).
  • Orphan semantics. A rule with zero memberships is library-only (a draft) and fires in no ruleset. There is no implicit "NULL → default" fallback — membership is always explicit.

Master — the complete canonical catalog (#1639)

The seeded ruleset (stable id default — unchanged, so junction rows, track FKs, seeds and eval scripts keep working) displays as Master and holds a completeness invariant:

  • Auto-membership. Placing a canonical rule in any ruleset also grants it Master membership (enforced at the single membership-create choke point, addRuleToRuleSet; the Master mirror carries priority = NULL, i.e. the rule's own priority). Migration 0076 backfills the invariant for pre-existing rules.
  • Clones excluded. Copy-flow clones (#1269) never auto-join — Master is the canonical catalog, not a pile of near-duplicates. Provenance is the clone id namespace (<source>--<ruleset>): user-authored kebab ids can never contain --, so isCloneRuleId is sound without a schema column.
  • Removal blocked. removeRuleFromRuleSet refuses the Master scope (and the admin surface hides the Remove action there) — a published rule leaves circulation by being retired, never by a Master membership edit. Library-only rules (zero memberships) stay out of Master until published into a set.
  • Master can never be retired — it is the catalog and the last-resort resolution anchor.

The default designation — a movable pointer (#1639)

Default-ness is a designation, not the Master row's identity. The app_settings key rulesets.default names the ruleset every unresolved assignment inherits — a single key, so exactly one default exists by construction. Seeded to Master (migration 0077, INSERT OR IGNORE, so an operator-moved pointer survives re-runs).

  • Read (public tier — degrades): getDefaultRuleSetId(db) returns the pointed set when it exists and is active; a missing key, dangling id, or draft/retired target degrades to Master (which is unretirable, so resolution stays total).
  • Write (admin tier — fails loud): setDefaultRuleSet(db, id) refuses a nonexistent or non-active target. The admin surface (/admin/right-sizing) exposes Set as default on each eligible set with a blast-radius confirm (countTracksInheritingDefault) and writes an audit_log entry.
  • Retire guard: retiring the current default is refused until the designation moves; Master's own retire refusal is independent and absolute.

Effective priority per ruleset. rule_set_members.priority is a nullable per-ruleset override: NULL means "use the rule's own recommendation_rules.priority". Within a resolved ruleset the engine tie-break ranks by member.priority ?? rule.priority, so the same shared rule can rank differently in different rulesets. (This gates only ranking within a ruleset, never which rules fire — see Engine resolution.)

The default-fallback guarantee (zero regression)

Moving to the shared library changed nothing about what the engine recommends. Two properties hold:

  • Every existing rule keeps its home. Migration 0066 backfills exactly one membership per rule into the default ruleset (COALESCE(rule_set_id, 'default'), priority = NULL) before dropping the old rule_set_id column, and the rule seed grows an explicit default membership per seed rule. Both are idempotent (INSERT OR IGNORE). With every rule a member of only the default, resolving to default admits exactly the pre-capability fire-set at each rule's own priority.
  • Ruleset membership never leaks into the output. getRules(db) with no ruleset filter returns the full library. It projects each DB row through toRuleCoreRow, and since membership lives in a separate junction table it structurally cannot appear in the core row — a rule's ruleset can never leak into the recommendation output. This is pinned by rulesets-guardrail.spec.ts: the recommendation for a fixed selection is byte-for-byte unchanged regardless of which ruleset(s) a rule belongs to. Ruleset-scoped resolution — the engine choosing a fire-set by membership — is getRules(db, { ruleSetId }) (see Engine resolution); it scopes which rules fire and their effective priority, but the projected rows still omit membership, so the fire-set choice never becomes visible in the output.

Track → ruleset assignment (#1270)

A track (#149) can be assigned the ruleset its buyers are right-sized against. The assignment lives on a nullabletracks.rule_set_id column — the nullable-scope shape of track_display_rules.track_id (#905). (A track still points at a single ruleset; only the rule↔ruleset side became many-to-many in #1326.):

  • NULL means "inherit the current default." Every existing track migrates with NULL, so the column is additive and zero-regression — no track's resolved ruleset changes on introduction. Since #1639 the default is a movable designation, so what a NULL track resolves to follows the pointer.
  • N:1 by design. Several tracks MAY share one ruleset; there is no uniqueness constraint, and resolution keys only on the assigned id, so sharing is well-defined.
  • The track editor (/admin/tracks/[id], Details tab) offers a Ruleset picker whose "Default (currently: {name})" sentinel submits an empty value that the write layer (normalizeRuleSetId) stores as NULLinherit the designation, wherever it moves. The picker lists every active ruleset (Master and the current default included): pinning a track to a specific set is genuinely different from inheriting once the default can move, so no set is omitted. Assignment is gated on the same edit_data capability as the rest of the track editor.

Resolution + the never-dangling guarantee. resolveTrackRuleSetId(db, trackSlug) (src/lib/server/admin/rulesets.ts) resolves a session's recorded track slug (#153) to a live ruleset id: slug → track → rule_set_id, then through the pure resolveRuleSetId(assignedId, activeIds, defaultId) core, threaded with the current default (getDefaultRuleSetId). The assignment wins only when it names a currently-active ruleset; a NULL, a draft/retired ruleset, an unknown id (hard-deleted out from under the track), or an unknown/trackless slug all collapse to the current default — and a bad pointer itself degrades to Master. Resolution is therefore total — a track can never resolve to a dangling ruleset — and this is pinned by rulesets.spec.ts.

Retire/delete safety. Two mechanisms keep an assigned ruleset from stranding its tracks:

  • Hard delete: the FK is ON DELETE SET NULL, so a deleted ruleset's tracks return to the NULL → default fallback automatically.
  • Soft retire: the ruleset row survives (so the FK never fires), so the admin surface (#1268) calls repointTracksToDefault(db, ruleSetId) — with countTracksAssignedTo for the warn-count — to re-point assigned tracks to NULL before retiring. Either way, resolution's default-fallback is the backstop.

The engine that actually fires a track-resolved ruleset consumes resolveTrackRuleSetId in #1271 (below); #1270 owns assignment + resolution only, and left getRules/recommend untouched.

Engine resolution (#1271)

This is where per-track right-sizing actually takes effect: the engine fires the session's track-resolved ruleset instead of the flat global rule set.

The resolution path (track_slug → ruleset → fire-set):

  1. The buyer flow (SelectionFlow) sends the active track's slug on the recommendation fetch: GET /api/recommendation?options=…&track=<slug>. The default (trackless) flow omits track.
  2. The endpoint resolves the ruleset with resolveTrackRuleSetId(db, trackSlug) (#1270) — slug → track → rule_set_id, collapsing missing/unknown/trackless/retired to default.
  3. It threads the resolved id through getRecommendation(db, optionIds, { ruleSetId })loadEngineInputs(db, { ruleSetId })getRules(db, { ruleSetId }).
  4. getRules selects the fire-set with the pure ruleInResolvedRuleSet predicate (src/lib/server/admin/rulesets.ts) — a rule fires iff the resolved ruleset is in its membership set (#1326); a rule with no memberships fires nowhere. It also applies each member's effective priority (member.priority ?? rule.priority) so a shared rule can rank differently per ruleset. Only the rows read (and their in-ruleset ranking) change; the systems, typed values, and tier ladder are loaded identically.

Invariant — a ruleset scopes the fire-set (and per-ruleset ranking), NOT the merge model. Ruleset resolution changes which rules enter the engine and their effective priority within that ruleset, and nothing else. The merge model downstream is byte-for-byte the same: every rule in the resolved ruleset that matches the selection co-fires, and priority only tie-breaks the ranking (all matching rules contribute — not first-match-wins). So two guarantees hold together:

  • Zero-regression: with every rule a member of only default (the post-migration state, at each rule's own priority), resolving to default admits the exact pre-capability fire-set — a fixed selection recommends the identical system.
  • Divergence: two tracks on different rulesets right-size the same selection to different systems, because their fire-sets (and effective priorities) differ — not because the merge model changed.

Both are pinned by src/lib/server/ruleset-scoping.spec.ts (which composes the real predicate with the real engine) and verified end-to-end against /api/recommendation.

Preview parity (#1271 AC-05): the admin rule preview (rule-preview.ts) takes the same { ruleSetId }. The right-sizing rule editor's Preview tab offers a "Preview as track" selector; picking a track resolves its ruleset the same way, so an author tunes against exactly the fire-set a buyer on that track sees.

Reading rules

One typed read serves both the matching engine and the admin editor — see getRules in the data-access layer:

ts
const rules = await getRules(db); // fire-eligible rules, ordered by priority
// rule.conditions: (| { kind:'option'; factorOptionId }
//                    | { kind:'attribute'; attributeId; operator; threshold })[]
// rule.outcomes:   { systemId; role:'preferred'|'alternative'; rank; budgetTier }[]  (rank-ordered)

Writes go through src/lib/server/admin/rules.ts (createRule / updateRule / setRuleEnabled / retireRule / restoreRule), which validates and persists a rule and replaces its conditions/outcomes wholesale.

Worked example — a high acoustic requirement

A buyer needs high sound isolation (a high STC requirement). Prefer NW Acoustical 645; surface NW Aluminum 640 and SL45 as cost-effective alternatives that still perform.

As a rule:

PartValue
idhigh-stc-prefer-acoustical
conditionattribute · acoustic-rating-stc-oitc · >= · 50
outcome (rank)0 nw-acoustical-645 preferred (Premium) · 1 nw-aluminum-640 alternative (Mid-range) · 2 sl45 alternative (Budget)
rationale"For a high acoustic-isolation requirement NW Acoustical 645 gives the top STC; NW Aluminum 640 and SL45 are cost-effective options that still meet a strong requirement."

The preferred system leads on the requirement (top STC); the alternatives are ordered by rank and read their affordability from budget_tier, so the engine can later prefer the cheapest option that still clears STC ≥ 50. This example is exercised end-to-end in admin/rules.spec.ts (pure round-trip) and verified against local D1.

  • #4 — parent capability (Recommendation & Right-Sizing)
  • #60 — the matching engine that evaluates these rules
  • #61 — cost-tier right-sizing / alternative surfacing
  • #22 — the admin rule editor
  • #20factor_option_attributes, the selection→attribute bridge
  • Data access layer — the getRules read API
  • Structure graph — the read-only /admin/structure map of factors → answers → rules → systems (#1727)