Appearance
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:
- a condition set — when does this rule apply?
- an ordered outcome — what does it recommend, and in what order?
- 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):
| Table | Holds | Key columns |
|---|---|---|
rule_sets | the grouping entity | id (kebab key), name, status (draft|active|retired), created_at, updated_at |
recommendation_rules | the rule core | id (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_conditions | a rule's condition set | rule_id, kind (option|attribute), factor_option_id, attribute_id, operator, threshold |
rule_outcomes | a rule's ordered outcome | rule_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 specificfactor_option_idin the guided flow. The attribute influence an option implies is authored separately infactor_option_attributes(#20); a rule condition just names the option. Theattribute_id/operator/thresholdcolumns areNULLfor this kind.attribute— the product-side hook. Matches when a productattribute_idsatisfiesoperator threshold, e.g. acoustic STC>=50.operatoris one of>=,<=,>,<,=,!=,includes.thresholdis stored as text and interpreted by the matching engine (#60) against the attribute's typed value (see value typing). Thefactor_option_idcolumn isNULLfor 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 carriespriority = NULL, i.e. the rule's own priority). Migration0076backfills 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--, soisCloneRuleIdis sound without a schema column. - Removal blocked.
removeRuleFromRuleSetrefuses 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 isactive; 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 anaudit_logentry. - 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
0066backfills exactly one membership per rule into thedefaultruleset (COALESCE(rule_set_id, 'default'),priority = NULL) before dropping the oldrule_set_idcolumn, and the rule seed grows an explicitdefaultmembership per seed rule. Both are idempotent (INSERT OR IGNORE). With every rule a member of only the default, resolving todefaultadmits 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 throughtoRuleCoreRow, 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 byrulesets-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 — isgetRules(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.):
NULLmeans "inherit the current default." Every existing track migrates withNULL, 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 aNULLtrack 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 asNULL— inherit 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 sameedit_datacapability 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 theNULL→ default fallback automatically. - Soft retire: the ruleset row survives (so the FK never fires), so the admin surface (#1268) calls
repointTracksToDefault(db, ruleSetId)— withcountTracksAssignedTofor the warn-count — to re-point assigned tracks toNULLbefore 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):
- The buyer flow (
SelectionFlow) sends the active track's slug on the recommendation fetch:GET /api/recommendation?options=…&track=<slug>. The default (trackless) flow omitstrack. - The endpoint resolves the ruleset with
resolveTrackRuleSetId(db, trackSlug)(#1270) —slug → track → rule_set_id, collapsing missing/unknown/trackless/retired todefault. - It threads the resolved id through
getRecommendation(db, optionIds, { ruleSetId })→loadEngineInputs(db, { ruleSetId })→getRules(db, { ruleSetId }). getRulesselects the fire-set with the pureruleInResolvedRuleSetpredicate (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 todefaultadmits 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:
| Part | Value |
|---|---|
id | high-stc-prefer-acoustical |
| condition | attribute · 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.
Related
- #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
- #20 —
factor_option_attributes, the selection→attribute bridge - Data access layer — the
getRulesread API - Structure graph — the read-only
/admin/structuremap of factors → answers → rules → systems (#1727)