Skip to main content

Scaffolding — three modes, per-feature decay

The shortest version

Every Hub user has a UI mode — Beginner, Intermediate, or Expert. The mode is a global default for how much help (tooltips, hints, explanatory copy) the UI surfaces. On top of that, every scaffoldable feature in Hub tracks per-user, per-feature usage: how many times the user has fired it, when they last fired it, whether they've dismissed its tooltip.

Tooltips fade as the user gets comfortable with a feature, and restore when the user hasn't touched it in a while. A user is never globally an expert. They're an expert at the features they use often and a beginner at the features they use rarely — even if their global mode is Expert.

The daily heuristic suggests mode changes when the signals warrant. It never forces.

Shape of the system

chassis defaults ──┐
(per frequency) │
├──→ effective decay thresholds ──→ show / hide
tenant overrides ──┤

user mode multiplier ─┘


per-user, per-feature state
(use_count + last_used_at +
tooltip_dismissed_at + force_show)

The decision engine is getEffectiveTooltipState(env, userId, featureKey) in src/service/scaffolding/state.ts. It applies the rules in this exact order:

  1. tenant.force_off → hide unconditionally.
  2. tenant.force_on → show unconditionally.
  3. user.tooltip_force_show → show (user explicitly asked for help back).
  4. inactivity restorenowS() - last_used_at >= effective_decay_days → show.
  5. user.tooltip_dismissed_at → hide.
  6. use_count < effective_decay_uses → show.
  7. Otherwise → hide.

The three modes

ModeMultiplierEffect
Beginner3.0×Tooltips persist three times as long. A high-frequency feature that fades after 15 uses normally now fades after 45.
Intermediate1.0×Chassis defaults apply as-is. The shipped baseline.
Expert0.3×Tooltips fade roughly three times faster. A high-frequency feature fades after ~5 uses.

The multiplier applies uniformly to both decay_uses and decay_days. There is no per-mode rule beyond the multiplier — the same arithmetic produces every mode's behavior.

Frequency classes

Every feature in feature_registry is tagged with one of four frequency classes. The class determines the chassis-default decay thresholds:

Classdefault_decay_usesdefault_decay_daysExample
high1530sales.add_line_item — touched many times a shift
medium3060inventory.adjust — daily-ish
low8120customer.merge_duplicates — weekly-ish
rare4180consignment.intake — a few times a year

Note the inverse relationship for rare features: they get a low use-count threshold (the operator only needs a few reminders) paired with a long day window (because they'll forget between sessions). This is the central insight — rare features need their tooltips to stay available for the long tail.

Per-feature decay — the central claim

The system stops pretending expertise is a person-level attribute. Concretely:

  • Marie is a five-year veteran on the till. sales.add_line_item fades for her after ~15 uses (Intermediate mode); she stops seeing its tooltip on her second day.
  • That same Marie has touched consignment.intake four times since the feature shipped. She still sees the consignment tooltip every time — because rare features intentionally hold their scaffolding.
  • If Marie's global mode flips to Expert, her thresholds collapse uniformly, but the shape stays the same: she's still further from the consignment threshold than the till threshold.

This is the contract: frequent use earns silence at the feature level.

Tenant overrides

Each tenant can override the chassis defaults per feature via the tenant_tooltip_overrides table:

  • override_decay_uses — replaces the chassis number (or NULL to defer to chassis).
  • override_decay_days — same shape.
  • force_off — hide for every user at this tenant. Useful for "we don't ever want this hint."
  • force_on — always show. Useful for "this is critical and operators must always read it."

force_off and force_on are mutually exclusive (DB CHECK + Zod refine).

The heuristic

evaluateModeForUser is called for each active user by the daily cron handler (03:17 UTC nightly). It reads user_feature_state aggregates and returns a SuggestionDecision if a mode change is warranted:

CurrentTriggerSuggests
Beginner14+ active days, > 30 uses/day, dismissal rate > 0.5Intermediate
Intermediate30+ active days, > 80 uses/day, dismissal rate > 0.7, no force-shows in 14dExpert
Expert30+ days idle OR 3+ force-shows in last 14 daysIntermediate (demote)

When a suggestion fires, it's queued in mode_suggestions with status='pending'. The UI surfaces it as a non-blocking banner. The user can:

  • Accept → mode changes, ui_mode_set_by = 'heuristic_accepted'.
  • Dismiss → suggestion marks dismissed, no mode change.
  • Stop suggesting → dismisses + flips users.suggestions_enabled = 0.

Only one pending suggestion per user at a time. The next sweep skips users with a pending one.

Audit trail

Every state change writes to audit_events via the existing tamper-chain writer:

  • user.mode_set — manual or heuristic-accepted.
  • user.mode_suggestions_toggled — opt-out.
  • scaffolding.tooltip_dismissed — per-feature dismissal.
  • scaffolding.tooltip_force_shown — user asking for help back.
  • scaffolding.tooltip_dismissals_reset — "Reset all hints" button.
  • scaffolding.tenant_override_changed / _removed.
  • scaffolding.suggestion_created / _accepted / _dismissed.

recordFeatureUse is not audited. The volume would flood the chain; use_count is the record. This is a deliberate exception documented here.

What's NOT in v1

Tracked in principles/scaffolding.md and roadmap.md:

  • Per-module mode (e.g. Beginner-on-consignment, Expert-everywhere-else)
  • Mode-aware keyboard shortcut surfacing
  • A/B testing of tooltip copy
  • Multi-language tooltip content
  • Group-level mode policies
  • Tenant-authored tooltip content
  • Mobile-specific scaffolding patterns

Cross-references

  • In-situ editing — the broader UX philosophy this composes with. The helm-editable chassis and the scaffolding system are orthogonal: helm-editable is for "I want to change this," scaffolding is for "I want to learn this."
  • Audit log — every scaffolding mutation rides the tamper chain.
  • migrations/005_scaffolding.sql — schema source of truth.
  • src/seed/feature-registry.ts — the canonical feature list.
  • src/service/scaffolding/ — service modules (registry, state, mode, overrides, heuristic).