Module — scaffolding
This is the operational reference for the scaffolding subsystem. Architectural reasoning lives in scaffolding; this page is the API surface.
Tables
Defined in migrations/005_scaffolding.sql:
| Table | Purpose |
|---|---|
users | Scaffolding-state-owning accounts. One row per Hub user; mirrors operators 1:1 today. Carries ui_mode, ui_mode_set_at, ui_mode_set_by, suggestions_enabled. |
feature_registry | Every scaffoldable feature. Idempotent registration via bulkRegisterFeatures. Carries frequency_class, default_decay_uses, default_decay_days. |
user_feature_state | Per-user, per-feature usage state. PK (user_id, feature_key). Carries use_count, first_used_at, last_used_at, tooltip_dismissed_at, tooltip_force_show. |
tenant_tooltip_overrides | Per-tenant override of chassis defaults. PK (tenant_id, feature_key). Carries override_decay_uses, override_decay_days, force_off, force_on. |
mode_suggestions | Queue of pending mode-change suggestions from the daily heuristic sweep. Carries suggested_mode, reason, confidence, status. |
Service surface
Imported from src/service/scaffolding/index.ts.
Registry (chassis-internal)
registerFeature(db, { feature_key, module_key, display_name, description, frequency_class })
bulkRegisterFeatures(db, features[]) // idempotent; preferred at boot
bootstrapFeatureRegistry(db) // registers everything in FEATURE_REGISTRY_SEED
getFeatureRow(db, featureKey)
listFeatures(db)
State (user-facing)
getEffectiveTooltipState(db, userId, featureKey) → TooltipState
recordFeatureUse(db, userId, featureKey)
dismissTooltip(db, userId, featureKey)
forceShowTooltip(db, userId, featureKey)
resetAllDismissals(db, userId) // settings "Reset all hints"
TooltipState:
{
show: boolean,
description: string | null,
display_name: string,
use_count: number,
threshold_uses: number,
threshold_days: number,
decided_by:
| "tenant_force_off"
| "tenant_force_on"
| "user_force_show"
| "user_dismissed"
| "use_count_under"
| "use_count_decayed"
| "inactivity_restored"
| "feature_not_registered"
}
Mode (user-facing)
getUserMode(db, userId) → UserModeView
setUserMode(db, userId, mode, setBy) // setBy ∈ {'user_explicit', 'heuristic_accepted'}
setSuggestionsEnabled(db, userId, enabled)
Tenant overrides (owner-only)
getTenantOverrides(db, tenantId?)
getTenantOverride(db, featureKey, tenantId?)
upsertTenantOverride(db, featureKey, input, updatedBy, tenantId?)
removeTenantOverride(db, featureKey, updatedBy, tenantId?)
Heuristic (cron + UI)
computeUserSignals(db, userId) → UserActivitySignals | null
decideMode(signals) → SuggestionDecision | null // pure function; takes signals
evaluateModeForUser(db, userId) → ModeSuggestionRow | null
listPendingModeSuggestions(db) → ModeSuggestionRow[]
getPendingSuggestionForUser(db, userId) → ModeSuggestionRow | null
acceptModeSuggestion(db, userId, suggestionId)
dismissModeSuggestion(db, userId, suggestionId, stopSuggesting)
runDailySweep(db) → { evaluated, suggested, skipped } // cron entry point
Route table
All routes require an authenticated session.
/scaffolding/* — per-user state
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /scaffolding/state/:featureKey | — | { feature_key, state } |
| POST | /scaffolding/use/:featureKey | — | { ok, feature_key, use_count } |
| POST | /scaffolding/dismiss/:featureKey | — | { ok, feature_key, dismissed } |
| POST | /scaffolding/force-show/:featureKey | — | { ok, feature_key, force_show } |
| POST | /scaffolding/reset-dismissals | — | { ok, cleared } |
/user/mode/* — current user's mode
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /user/mode | — | { mode, suggestion } |
| POST | /user/mode | { mode } | { ok, mode } |
| POST | /user/mode/suggestions | { enabled } | { ok, suggestions_enabled } |
| POST | /user/mode/accept-suggestion | — | { ok, new_mode } |
| POST | /user/mode/dismiss-suggestion | { stop_suggesting? } | { ok, stop_suggesting } |
/admin/tooltip-overrides/* — owner only
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /admin/tooltip-overrides | — | { overrides[] } |
| PUT | /admin/tooltip-overrides/:featureKey | TenantOverride | { ok, override } |
| DELETE | /admin/tooltip-overrides/:featureKey | — | { ok, removed } |
Schemas
Defined in src/schema/scaffolding.ts:
UI_MODE—z.enum(['beginner','intermediate','expert'])FREQUENCY_CLASS—z.enum(['high','medium','low','rare'])FEATURE_KEY— regex^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$featureRegistrationSchema—{ feature_key, module_key, display_name, description?, frequency_class }tenantOverrideSchema—{ override_decay_uses?, override_decay_days?, force_off?, force_on? }(force_off / force_on mutually exclusive via refine)userModeSchema—{ mode }userSuggestionsEnabledSchema—{ enabled }
parseOrThrow(schema, input, label) is the boundary helper; routes use it to validate request bodies and return uniform SchemaValidationError shapes.
Cron
The daily sweep is wired into worker.scheduled in src/index.ts. It fires on the 17 3 * * * cron expression (already declared in wrangler.toml):
if (event.cron === "17 3 * * *") {
const result = await runDailySweep(db);
await writeAuditEvent(db, { /* scaffolding.heuristic_sweep_completed */ });
}
A failure during the sweep writes a scaffolding.heuristic_sweep_failed audit row (severity error, outcome failure) and is swallowed — the cron handler never throws, so one bad sweep doesn't stop the next one.
Adding a new feature
- Add the entry to
src/seed/feature-registry.tsunder the right module section, with a thoughtfulfrequency_class. - On next deploy, the chassis boot helper calls
bootstrapFeatureRegistry, which upserts the new row. - In the UI, reference the feature key whenever the operator fires the primary action — call
recordFeatureUseand usegetEffectiveTooltipStateto decide whether to render the tooltip. - No migration needed; the registry is data, not schema.
Testing
test/scaffolding_state.test.ts— decision engine unit teststest/scaffolding_heuristic.test.ts—decideMode+evaluateModeForUsertest/scaffolding_flow.test.ts— multi-step lifecycle scenarios
Run with pnpm test. The in-memory SQLite harness loads 005_scaffolding.sql alongside the rest.