MILTON API (MILTON)
Overview
MILTON is the primary ASP.NET Core Web API that acts as the entry point for the frontend (MILTON.Client). It exposes REST endpoints via FastEndpoints and hosts SignalR hubs for real-time notifications. The project is responsible for routing commands and queries down to MILTON.BackendCore or publishing Wolverine messages for worker sagas like MILTON.DocumentGenerator.
Real-time Sync (SignalR)
The MILTON application hosts the DocumentHub at /hubs/document to push live state updates directly to the connected MILTON.Client browser instances. Connections manage visibility to relevant scopes to minimize traffic and maintain privacy by joining specialized SignalR groups.
Document and Project Groups
The hub implements two primary joining patterns:
-
Document Groups (
JoinDocumentGroup):- Clients call
JoinDocumentGroup(Guid documentId)when viewing a specific document detail page. - The backend pushes granular logs (e.g., “Starting LLM request”, “Formatting snippet”), partial document updates, and completion/error flags specifically to the group named
doc-{documentId}.
- Clients call
-
Project Groups (
JoinProjectGroup):- Clients call
JoinProjectGroup(int projectId)when viewing list pages or dashboards. - The backend pushes higher-level updates, such as when a document’s overall status changes or when document sections summarize, to the group named
project-{projectId}.
- Clients call
Component Subscription & Memory Leak Prevention
Subscriptions in the client must be carefully managed. When Blazor components are opened, they should invoke StartAsync on the SignalR connection and join the necessary group via JoinDocumentAsync or JoinProjectAsync in the client’s abstractions.
To avoid memory leaks associated with abandoned callbacks, components must explicitly implement IDisposable or IAsyncDisposable. In the destruction logic, they must unsubscribe from StateService events (OnProgress, OnCompleted, etc.) and invoke the corresponding LeaveDocumentAsync or LeaveProjectAsync to halt stream forwarding from the backend DocumentHub.
Caching (HybridCache)
The API uses .NET 10 HybridCache (in-process L1 + optional Redis L2) to short-circuit its hot read paths: project config/presets (config:{tenant}:{project}), full document reads (doc:{tenant}:{documentId}), the repo file browser (repo:{tenant}:{project}:{prefix}), template blueprints (template:{tenant}:...), the project-documents summary (project-docs:{tenant}:{project}), the per-user project list (projects:{tenant}:{user}), and the raw encrypted LLM presets (presets:{tenant}:{project}). Registration is centralized in MILTON.Platform (AddMiltonHybridCache) and wired in Program.cs; Redis is provided by the Aspire cache container (ConnectionStrings__cache). Redis is optional — without a connection string the cache runs L1-only and the app still works (offline-first).
Key/Tag conventions
All keys are tenant-scoped and go through MILTON.Shared.Caching.CacheKeys so the read side and the invalidation side cannot drift:
| Cache | Key | Invalidation tag | TTL |
|---|---|---|---|
| Project config | config:{tenantId}:{projectId} | same as key | 5 min |
| Document (sections+blocks) | doc:{tenantId}:{documentId} | same as key | 5 min |
| Repo browse | repo:{tenantId}:{projectId}:{prefix} | repo:{tenantId}:{projectId} (whole project) | 10 min |
| Template list (project) | template:{tenantId}:{projectId} | same as key | 5 min |
| Template list (tenant-wide) | template:{tenantId}:all | same as key | 5 min |
| Single template | template:{tenantId}:{templateId} | same as key | 5 min |
| Project documents summary | project-docs:{tenantId}:{projectId} | same as key | 1 min |
| Per-user project list | projects:{tenantId}:{userId} | key + one project-meta:{tenantId}:{projectId} per project | 5 min |
| Raw (encrypted) LLM presets | presets:{tenantId}:{projectId} | config:{tenantId}:{projectId} (reused) | 5 min |
Invalidation rules
Every write path removes tags after a successful SaveChangesAsync:
- Config writes (
UpdateProjectConfigHandler): removeconfig:{tenant}:{project},repo:{tenant}:{project}, andproject-meta:{tenant}:{project}(refreshes every user’s project list; theconfigtag also evicts thepresets:{tenant}:{project}entry, which deliberately reuses it). - Document writes (add/update/reorder block, add/reorder section, update section source files, delete document, PDF generation,
Apply*GenerationHandler,InstantiateDocumentHandler): removedoc:{tenant}:{documentId}andproject-docs:{tenant}:{projectId}(resolving the owning project with a tiny indexed query when only the document/block/section id is known). - Template writes (
CreateTemplateEndpoint,UpdateTemplateEndpoint,DeleteTemplateEndpoint): removetemplate:{tenant}:{projectId}(when the template is project-scoped),template:{tenant}:{templateId}, andtemplate:{tenant}:all.PreviewTemplateEndpointrenders in-memory and is never cached. - Project creation (
CreateProjectHandler): removeprojects:{tenant}:{userId}for the creating user (a new project appears in their list). - Clone completion (
RepositoriesClonedMessagefromMILTON.GitOperations): removerepo:{tenant}:{project}so every prefix is refreshed.
The per-user access checks (GetProjectConfigHandler, ListAllProjectsHandler) are deliberately not cached (authorization freshness); only the loads themselves are cached after the filter runs, with the user id part of the project-list key so no user ever reads another user’s list. The doc/config keys cache the DTO only — no secrets are stored (presence booleans like HasApiKey/HasGitToken are safe to cache), and the presets key caches only the raw encrypted LlmPresetsJson string (decryption + Vault substitution always run after the cache read; a plaintext API key never enters the cache).