toolup-forgetoolup-forge

Architecture

Architecture

ToolUp Platform separates infrastructure (what the SDK provides) from domain (what modules provide). Composition roots wire them together.

Three-tier package shape

The Platform itself, plus every cross-tier companion (ToolUp.AI, ToolUp.RAG, ToolUp.KnowledgeBase, ToolUp.Forms, etc.), ships as a per-tier set of packages:

  • .Core — shared types + interfaces. No server or client deps. Ships both the .NET DLL and source under fable/ for Fable consumers.
  • .Server — Giraffe-over-ASP.NET-Core server implementation. Depends on .Core. DLL-only nupkg.
  • .Client — Fable + Elmish + Feliz client surface. Depends on .Core. Ships source under fable/.
  • .Build (Platform only) — FAKE pipeline targets.

This per-tier split exists so consumers pull just what they need. A serverless function consuming the platform's interfaces but providing its own runtime takes .Core only. A pure API service takes .Core + .Server. A full-stack consumer takes .Core + .Server + .Client.

The cross-tier companions repeat this shape. Provider companions (ToolUp.AIProviders.Claude, ToolUp.Storage.AwsS3, ToolUp.AuditSinks.S3Archive, etc.) are single-side packages — they implement one or more interfaces and ship as a single nupkg.

Composition roots

A consuming app has two thin composition roots (server + client) that list modules and call the run pipeline. Everything else — routing, scope resolution, auth wiring, default in-process implementations — is in the SDK.

Server

The server composition root assembles every module as a ServerModule record, composes them into an ServerApp / AIServerApp / RAGServerApp pipeline, and calls .run.

let mySalesModule =
    ServerModule.create "SalesAnalysis"
    |> ServerModule.withGuardedApi salesAnalysisApi
    |> ServerModule.withDataTypes [ salesDataType ]
    |> ServerModule.withConfig salesConfigSchema

ServerApp.empty
|> ServerApp.withConfig { ServerConfig.defaults with Port = 5000; Mode = Individual }
|> ServerApp.withAuth authProvider
|> ServerApp.withStorage blobStorage
|> ServerApp.addModules [ mySalesModule; (* … *) ]
|> ServerApp.run

ServerApp.run returns an int exit code — slot directly into [<EntryPoint>].

For AI / RAG, use the flat-superset variants:

// + AI
AIServerApp.create (aiProviderFactory, aiConfigStore)
|> AIServerApp.withConfig config
|> AIServerApp.addModules modules
|> AIServerApp.withAITools AITools.allTools
|> AIServerApp.run

// + RAG (which wraps AI)
RAGServerApp.create (aiProviderFactory, aiConfigStore, embeddingProvider)
|> RAGServerApp.withConfig config
|> RAGServerApp.addModules modules
|> RAGServerApp.withAITools AITools.allTools
|> RAGServerApp.run

AIServerApp is a flat superset of ServerApp — same fluent shape, AI-specific helpers added. RAGServerApp is a flat superset of AIServerApp. There's no "compose" magic; each tier adds its own surface.

Client

open ToolUp.Platform

let modules : ErasedModule list = [
    SalesAnalysis.ClientView.register ()
    // … other modules
]

Client.run
    { ClientConfig.defaults with AppName = "MyApp"; Mode = Individual }
    modules

For AI, wrap the shell with AIClientConfig.withAIAssistant:

let aiMode = ConfiguredAIAssistant { Name = "Aria"; Icon = "/svg/spark.svg"; ShowSidePanel = true }
AIClientConfig.run aiMode { ClientConfig.defaults with AppName = "MyApp"; Mode = Individual } modules

The Elmish shell handles sidebar navigation, module state management, file management UI (when modules declare data types), team management UI (in Team / MultiTeam modes), and config admin UI.

What the SDK auto-injects

ServerApp.run automatically adds:

  • Auth providerHeaderAuthProvider by default (trusts X-User-Id, dev-only) or whatever you withAuthed.
  • Storage scope resolver — picks one of four implementations based on ServerConfig.Mode.
  • Team store (in Team / MultiTeam mode) — default blob-backed TeamStore unless you withTeamStoreed.
  • File management API — when any registered module has data types.
  • Config admin API — when any registered module declares a ModuleConfigSchema.
  • Audit logAuditLog backed by IEventStore, sourcing events under _platform.audit.
  • Notification channelInMemoryNotificationChannel by default; replaceable.
  • Health / ready endpoints/health (liveness) + /ready (readiness).
  • Config preflight — runs every registered IConfigValidator before HTTP binds; refuses to start on Error outcomes.
  • Metrics endpoint/metrics (OpenMetrics text) when MetricsEndpoint is enabled.
  • Optional features behind explicit opt-in: job scheduler, data ingestion, entity store, encryption-at-rest, audit sinks, rate limiting, security headers, transactional notification sinks, etc.

Client.run adds:

  • The Elmish shell with sidebar navigation
  • Built-in Data Manager module (file upload + per-data-type management) — auto-injected when modules declare data types
  • Built-in Team Manager module — auto-injected in Team / MultiTeam modes
  • Built-in Platform Admin sidebar group with role-management, health monitoring, and Platform KB administration modules (gated by PlatformRole)
  • Built-in ToastCentre — fixed-position toast renderer subscribing to NotificationClient
  • Built-in AI Settings module (in non-Anonymous modes)
  • Notification client over SSE
  • Processed-data context (modules consume processed data via React.useContext)

How modules plug in

A module is a single F# project (see modules.md) with four files: SharedTypes.fs, Server.fs, ClientModel.fs, ClientView.fs. The module fsproj compiles Shared + Server; the Client files are injected into the consumer's client project via the module's .Client.props.

A module is one Elmish MVU. A page is a sidebar-visible entry rendered against that MVU. Modules can be single-page (the default, returning a tuple ReactElement * ReactElement for the left/right split-panel layout) or multi-page (declaring multiple PageConfig entries with per-page views, each returning a PageContent value picking its layout shape).

Modules declare:

  • What they areClientModule.Name, ClientModule.Icon
  • What they needNeedsData (which DataTypes their Init consumes from the processed-data context)
  • What they provideProvidesProcessedData (which DataTypes they emit), DataTypes (server-side DataType records with detect + process functions)
  • How they behaveInit / Update / View

The shell wires everything else — file uploads, persistence, scope resolution, AI tool registration, config storage, notification routing.

Scope resolution

Every request resolves a StorageScope ({ ScopeId; Container; Persist }) via the registered IStorageScopeResolver. The four shipped implementations:

  • AnonymousScopeResolver — scope per session (per-tab); not persisted; evicted after N minutes.
  • AuthenticatedEphemeralScopeResolver — scope per authenticated user; not persisted.
  • AuthenticatedScopeResolver (Individual mode) — scope per user, persisted.
  • TeamScopeResolver (Team / MultiTeam mode) — scope per active team, persisted. Caches active team lookups with 5-minute sliding expiration.

SessionFileStore uses scope.Persist to decide whether to write through to IBlobStorage. Per-scope blob containers (team-{teamId}, user-{userId}, session-{guid}) keep data isolated.

Access control

Every request resolves an AccessContext:

type AccessContext = {
    UserId: string
    TeamId: string option
    Mode: PlatformMode
    ModulePermissions: Map<string, PermissionLevel>
    PlatformRole: PlatformRole
}

Currently the SDK does not enforce per-module permissions beyond the user's choice via IPermissionStore. Module APIs are wrapped in makePermissionGuardedApi which checks ModulePermissions before each call. Empty map = unrestricted. PlatformRole.PlatformAdmin is the deployment-wide admin role.

Notifications

The SDK ships a single notification channel abstraction:

type INotificationChannel =
    abstract Publish: scopeId: string -> Notification -> Async<unit>
    abstract Subscribe: scopeId: string -> filter: (NotificationKind -> bool) -> handler: ... -> Async<Guid>
    abstract Unsubscribe: Guid -> Async<unit>

Notifications carry five kinds: SystemMessage, JobProgress, JobComplete, RefreshData, CustomNotification. Plus three transactional kinds (TransactionalEmail, TransactionalSms, MobilePush) that ride the same envelope but bypass the wire transport via DispatchingNotificationChannel so PII never crosses pub/sub topics.

The default in-memory channel works single-instance. The Redis companion (ToolUp.NotificationChannels.Redis) replaces it for distributed deployments. Per-scope topic isolation is structural (one topic per ScopeId), not a post-hoc filter.

A single SSE endpoint at /api/notifications serves all subscribers. The client-side NotificationClient opens one EventSource, routes named events to per-kind subscribers, and returns a dispose thunk.

Events + audit

IEventStore provides append-only, queryable event storage:

type IEventStore =
    abstract Write: Event -> Async<unit>
    abstract ReadByType: SourceModule: string -> EventType: string -> Async<Event list>
    abstract ReadByCorrelation: CorrelationId: Guid -> Async<Event list>

Default InMemoryEventStore for dev; PersistentEventStore (blob-backed, optional retention policy) for production. Modules emit domain events; the SDK emits platform events under _platform.* source modules.

The IAuditLog interface sits on top of IEventStore and records AuditEvent cases under SourceModule = "_platform.audit". The shipped events cover authentication (UserLoggedIn), team operations (TeamCreated, MemberAdded, etc.), file operations, encryption-key lifecycle, audit-sink delivery, health-state changes, and many more.

For compliance archival, the IAuditSink substrate replicates every _platform.audit event to one or more external sinks (Splunk HEC, Datadog Logs, S3 Object Lock archives). Replication is at-most-once steady-state, at-least-once across restart, with per-(sinkName, scopeId) cursors in IBlobStorage. See events.md.

Background jobs

Opt in via ServerConfig.JobScheduler = InProcessJobScheduler. The default scheduler is a BackgroundService ticking every minute aligned to wall clock, with per-JobId SemaphoreSlim for concurrent-tick safety. Jobs are defined by:

type JobDefinition = {
    JobId: JobId
    HandlerName: string
    Trigger: Trigger  // Cron expression | OnEvent | Manual
    Retry: JobRetryPolicy
    IdempotencyKey: IdempotencyKey option
    Precision: JobPrecision  // Minute (Second precision rejected at registration)
}

The cron parser supports * / values / commas / */N (ranges + named months deferred). OnEvent triggers fire when matching events hit the event store (via the JobNotifyEventStore decorator). Manual triggers fire on explicit TriggerOnce calls.

Five lifecycle events emit to IEventStore under _platform.jobs. Dead-letter triggers a SystemMessage-Warning notification. See jobs.md.

For multi-silo deployments, a distributed companion (Akka.NET / Orleans / Hangfire) is the future migration path; the single-instance default is fine for many deployments. The IJobSchedulerContract test pack is the conformance bar — any implementation passes the same 15 tests.

Data ingestion

Opt in via ServerConfig.DataIngestion = EnabledDataIngestion. Substrate:

  • IDataSource — connector contract (Connect / ListTables / GetSchema / Query over DataSourceCallContext).
  • IDataSourceConfigStore — per-scope connector configurations (blob-backed default).
  • IDataIngestor — orchestrator. Resolves config → matches connector by Kind → resolves credential via ISecretStore → calls Connect then Query → writes bytes through IDataObjectStore.Save(..., Versioned) so each refresh creates a new version → records IngestionRun + emits lifecycle events under _platform.dataingestion.

Triggered + scheduled refresh through the IJobScheduler (handler "_platform.dataingestion.run"). Admin API write paths gated by team role.

The default InMemoryDataSource (Kind "InMemory") ships for the contract test pack + dev harness. Real connectors (BigQuery, Redshift, GA4, Strava, etc.) are deployment-specific companions consumers write themselves.

Encryption at rest

The EncryptedBlobStorage decorator wraps any IBlobStorage and applies AES-GCM envelope encryption transparently. Envelope format: [Magic:4 "TOBL"][KeyIdLen:1][KeyId:N][Nonce:12][Tag:16][Ciphertext:M].

Two shipped key resolvers:

  • SingleKeyResolver — one platform-wide key.
  • PerScopeKeyResolver — per-tenant; IMemoryCache with 5-min sliding TTL; DestroyKey for crypto-shred (instant tenant offboarding for GDPR / contract termination).

Custom resolvers (per-(scopeId, userId), BYOK, KMS-backed) plug in against the same interface. Provider-specific preflight validators (AwsS3EncryptionAtRestValidator, AzureBlobEncryptionAtRestValidator, GcsEncryptionAtRestValidator) confirm encryption-at-rest is enabled at the bucket level.

Health + observability

  • /health — liveness; runs probes with HealthKind = Liveness.
  • /ready — readiness; runs all probes.

Probes implement IHealthCheck:

type IHealthCheck =
    abstract Name: string
    abstract Kind: HealthKind  // Liveness | Readiness
    abstract Timeout: TimeSpan
    abstract Check: unit -> Async<HealthResult>  // Healthy | Degraded of string | Unhealthy of string

Companions self-register via services.AddSingleton<IHealthCheck>(instance); deployments wire them via ServerApp.withHealthCheck. The shipped companion probes cover Redis, AI providers (Claude / OpenAI), embedding providers, HNSW vector store, storage providers (AWS / Azure / GCS), and notification sinks (SMTP / SendGrid / Twilio / WebPush).

The HealthStateTracker (opt-in via ServerConfig.HealthStateTracking = true) runs probes on a wall-clock-aligned 1-min tick and emits HealthStateChanged audit events after 3 consecutive observations of a new status (single-observation flaps absorbed). Operator surface: the SDK-built-in HealthMonitorUI admin module.

The metrics layer (IMetricsSink) emits per-request and SDK-internal metrics. Default PrometheusMetricsSink exposes /metrics in OpenMetrics text format with per-metric cardinality cap. The OpenTelemetry companion (ToolUp.Metrics.OpenTelemetry) implements the same interface over BCL System.Diagnostics.Metrics.Meter for OTLP export.

For tracing, the Logger.trace API + per-source filter via TOOLUP_TRACE_CATEGORIES env var gives selective per-source log enablement.

Dev diagnostics

The /dev/inspect endpoint (gated by ServerConfig.EnableDevEndpoints, default false in production builds; auto-enabled by the reference deployment in #if DEBUG) surfaces the caller's resolved AccessContext / StorageScope, registered modules + per-module data types, data-catalog summary, registered route handlers, IServiceCollection descriptor list, health-check snapshot, and config preflight snapshot. Caller-scope only — never enumerates across teams.

IDevDiagnosticsContributor is the extension point for companions wanting to surface their own internals (AI fast-path stats, ingestion queue depth, etc.).

What this docs site does NOT cover

  • Source-level walkthroughs of every module — that's better read from the source code with the type definitions in scope.
  • Phase-by-phase historical context — irrelevant to a new reader. Architecture is described as it stands today.
  • Commercial use cases — the SDK is sector-agnostic. Per-vertical commercial applications are downstream consumers and document themselves.

If something architectural feels under-explained, the source is the source-of-truth and the samples/HelloWorld/ reference deployment is the worked example.