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:
- tenant.force_off → hide unconditionally.
- tenant.force_on → show unconditionally.
- user.tooltip_force_show → show (user explicitly asked for help back).
- inactivity restore —
nowS() - last_used_at >= effective_decay_days→ show. - user.tooltip_dismissed_at → hide.
- use_count < effective_decay_uses → show.
- Otherwise → hide.
The three modes
| Mode | Multiplier | Effect |
|---|---|---|
| Beginner | 3.0× | Tooltips persist three times as long. A high-frequency feature that fades after 15 uses normally now fades after 45. |
| Intermediate | 1.0× | Chassis defaults apply as-is. The shipped baseline. |
| Expert | 0.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:
| Class | default_decay_uses | default_decay_days | Example |
|---|---|---|---|
high | 15 | 30 | sales.add_line_item — touched many times a shift |
medium | 30 | 60 | inventory.adjust — daily-ish |
low | 8 | 120 | customer.merge_duplicates — weekly-ish |
rare | 4 | 180 | consignment.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_itemfades for her after ~15 uses (Intermediate mode); she stops seeing its tooltip on her second day. - That same Marie has touched
consignment.intakefour 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 (orNULLto 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:
| Current | Trigger | Suggests |
|---|---|---|
| Beginner | 14+ active days, > 30 uses/day, dismissal rate > 0.5 | Intermediate |
| Intermediate | 30+ active days, > 80 uses/day, dismissal rate > 0.7, no force-shows in 14d | Expert |
| Expert | 30+ days idle OR 3+ force-shows in last 14 days | Intermediate (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).