Why repositories return dictionaries.
Every repository method returns Dictionary<string, object?>, not a typed DTO. It looks like a regression but it is a deliberate choice that keeps schema migrations rare, JSON shapes evolvable, and the frontend in charge of contracts. Here is the why, and what it costs.
DbContext
backend/src/AgenticIT.Data/AgenticITDbContext.cs — ~95 lines
Around 38 DbSet<T> properties, grouped:
- Identity: Tenants, Users, OAuthAccounts, Sessions
- Domain: Projects, ProjectMembers, ProjectInvitations, Chats, ChatMessages, ChatCompressionStates, ChatViews, ProjectMemories, UserMemories, Artifacts, Folders, Pins, Skills, SkillAttachments
- Features: Suggestions, Reminders, SyncConflicts, ScheduledJobs, GitSyncs, SharePointSyncs
- Integrations / tokens: VBoxTokens, VBoxOrganizationsCache, UserAzureConfigs, JiraTokens, JiraOAuthClients, MsgraphTokens, GitHubUserTokens, SharePointTokens, GitCredentials, ClaudeCodeCredentials, OpenCodeCredentials, UserMcpConnections, UserMcpToolToggles
- Config: ConfigTemplates, ConfigAgents, ConfigTools, ConfigSearches, TenantToolAccess
- Audit: AuditLog, UsageRecords, UserPreferences
OnModelCreating applies all IEntityTypeConfiguration<T> from the assembly, registers a JSON-document converter for the in-memory test provider, and adds a unique filtered index on tenants.name WHERE is_deleted = false (soft-delete awareness).
No migrations
EnsureCreatedAsync() on startup creates any missing tables. New columns need a different approach because EnsureCreatedAsync() does not alter existing tables — so Program.cs contains a stretch of inline ALTER TABLE … ADD COLUMN IF NOT EXISTS statements, one per added field, run on every boot.
This pattern is intentionally low-ceremony: no migration tooling, no version table, no failed-migration recovery. The cost is that every additive change must be remembered as inline SQL alongside the entity update. Drops/renames are not safe; we have not needed them yet. See §09.
The dictionary repository pattern
Every repository surface uses Dictionary<string, object?> (or a list thereof) instead of typed DTOs. The pattern is uniform across all 40+ repositories.
// idiomatic shape
public interface IChatRepository
{
Task<Dictionary<string, object?>?> CreateAsync(Guid tenantId, Guid userId, …);
Task<List<Dictionary<string, object?>>> ListAsync(Guid tenantId, Guid? projectId, …);
Task UpdateAsync(Guid id, Dictionary<string, object?> patch);
Task<List<Dictionary<string, object?>>> LoadHistoryAsync(Guid chatId);
}
Why
- Cheap evolution
- Adding a JSON field is one entity property + one inline ALTER. No DTO regen, no contract change.
- Envelope versioning
- JSONB envelope versioning is owned by §06·Envelopes →.
- Loose frontend coupling
- The TypeScript layer is the type authority for HTTP contracts; backend doesn't double-define the same shapes.
- Snake-case alignment
- Dictionary keys are explicit strings, so
JsonNamingPolicy.SnakeCaseLowerdoesn't have to translate names. The dictionary keys are the wire format.
What it costs
- No compile-time guarantee that a field name is correct. Typos surface as runtime 400s.
- IDE autocomplete is gone for response shapes — the price of dynamism.
- Refactors are tedious; every key string needs to be searched in plain text.
- Performance: object-boxing slightly slower than a struct DTO. Negligible at our scale.
The platform offsets these by:
- Strict TypeScript interfaces on the frontend that mirror endpoint shapes.
- Integration tests that assert exact JSON keys for every response that the UI consumes.
Append-only history with concurrency safety
ChatRepository.SaveHistoryAsync()
The chat-message table is append-only. The save is wrapped in a transaction that compares the incoming history length to the row count for that chat_id; only the new tail is inserted. Two concurrent saves that have diverged by one message will collide on the unique index — the loser detaches its stale entry and retries the wrap.
This dance is what keeps two browser tabs from corrupting each other's view of a chat — or worse, from doubling messages when the server retries on socket reconnect.
Multi-tenancy isolation
There are no EF Core global query filters. Tenant scoping is explicit in every repository query, e.g.:
where c.ProjectId == projectId && c.TenantId == tenantId
Why? Because global filters silently apply, and a missed filter is invisible. Explicit filtering forces every new query to acknowledge tenancy in code review. Cascade deletes are wired for project-scoped entities; tenant deletion is soft (is_deleted) to prevent orphan cascade.
Storage details by entity family
- Token entities
- VBoxTokens, GitHubUserTokens, MsgraphTokens, JiraTokens, SharePointTokens — all encrypted at rest via
ITokenEncryptorwith the sharedTOKEN_ENCRYPTION_KEY(also used by the MCP service for decryption). - Artifact files
- Metadata in DB, bytes on disk under
{_dataDir}/projects/{projectId}/{artifactId}{ext}. Theappdatavolume is mounted at/datain both API and MCP containers. - Skills attachments
- Inline
byteainskill_attachments.content— no external blob storage. The expected payloads are small (markdown helpers, sample inputs). - Usage records
- Append-only. Driven by
ICreditsService.RecordUsageAsync(). - Audit log
- Append-only.
AuditLogEntityrows for every tool call:tool_name,tool_input(JSON),tool_result(JSON),approval_status.
EF Core 10 quirks we work around
- Ghost entries after concurrency exceptions —
DbUpdateConcurrencyExceptionhandlers must call_db.ChangeTracker.Clear()before retrying.ArtifactRepository.SaveAsync()(line ~66) is a representative example. - InMemory provider and JSON columns — JSONB doesn't exist in InMemory, so converters round-trip via string. The configuration applies in both providers but only Postgres enforces partial indexes.
- "Second operation on this context" — always traceable to a
Task.Runthat captured a scoped service instead of opening a fresh DI scope.