Skip to main content

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:

TablePurpose
usersScaffolding-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_registryEvery scaffoldable feature. Idempotent registration via bulkRegisterFeatures. Carries frequency_class, default_decay_uses, default_decay_days.
user_feature_statePer-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_overridesPer-tenant override of chassis defaults. PK (tenant_id, feature_key). Carries override_decay_uses, override_decay_days, force_off, force_on.
mode_suggestionsQueue 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

MethodPathBodyReturns
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

MethodPathBodyReturns
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

MethodPathBodyReturns
GET/admin/tooltip-overrides{ overrides[] }
PUT/admin/tooltip-overrides/:featureKeyTenantOverride{ ok, override }
DELETE/admin/tooltip-overrides/:featureKey{ ok, removed }

Schemas

Defined in src/schema/scaffolding.ts:

  • UI_MODEz.enum(['beginner','intermediate','expert'])
  • FREQUENCY_CLASSz.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

  1. Add the entry to src/seed/feature-registry.ts under the right module section, with a thoughtful frequency_class.
  2. On next deploy, the chassis boot helper calls bootstrapFeatureRegistry, which upserts the new row.
  3. In the UI, reference the feature key whenever the operator fires the primary action — call recordFeatureUse and use getEffectiveTooltipState to decide whether to render the tooltip.
  4. No migration needed; the registry is data, not schema.

Testing

  • test/scaffolding_state.test.ts — decision engine unit tests
  • test/scaffolding_heuristic.test.tsdecideMode + evaluateModeForUser
  • test/scaffolding_flow.test.ts — multi-step lifecycle scenarios

Run with pnpm test. The in-memory SQLite harness loads 005_scaffolding.sql alongside the rest.