Files
enkly-ai/ARCHITECTURE.md
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

19 KiB

Architecture

This document records the bounded-context decomposition of the enkly-ai monolith, why each boundary is drawn where it is, the communication rules enforced throughout, and the handful of places where splitting a single process into eight forced a small, deliberate change in behavior.

1. Bounded contexts (the context map)

The monolith's packages were grouped by technical layer (internal/handlers, internal/db, internal/llm, ...); this port regroups them by business capability, each with its own data and its own deployable:

                         ┌─────────────┐
                         │   gateway   │  (HTTP/HTMX edge — no DB)
                         └──────┬──────┘
             ┌──────────┬───────┼────────┬──────────┬──────────┐
             ▼          ▼       ▼         ▼          ▼          │
         ┌───────┐  ┌───────┐┌──────┐┌─────────┐┌─────────┐     │
         │  iam  │  │billing││ conv │ │workspace││knowledge│     │
         └───┬───┘  └───┬───┘└──────┘└─────────┘└────┬────┘     │
             │          │                             │          │
             │ events   │ events                      │          ▼
             ▼          ▼                              │    ┌───────┐
        ┌──────────────────┐                            └──▶│ agent │
        │   notification    │                                └───┬───┘
        └──────────────────┘                                     │
                                          (agent also calls billing,
                                           conversation, workspace,
                                           knowledge — see §3)
Context Aggregate(s) Replaces (monolith)
IAM User, Session, VerificationCode, PasswordResetToken internal/handlers/auth.go, internal/db/users.go, internal/auth/*
Billing Subscription, StripeEvent, DailyUsage internal/handlers/billing.go, internal/billing/*, internal/db/subscriptions.go
Conversation Conversation, Message, ToolCall internal/db/conversations.go
Workspace (the sandboxed filesystem itself) internal/llm/tools.go + sandbox.go, internal/handlers/files.go
Knowledge CodeChunk (pgvector document) internal/rag/*
Agent Stream (an in-flight run) — a saga coordinator, owns no table internal/llm/agent.go + client.go, the run-orchestration half of internal/handlers/chat.go
Notification (stateless consumer) the email-sending half of internal/handlers/auth.go, internal/mail/*
Gateway (BFF) (no domain state) internal/handlers/{pages,ui,deliverables,middleware}.go, internal/render/*

Why Agent is its own context, not folded into Workspace or Conversation: the agent run loop is a process that uses workspace (tool execution), knowledge (semantic search) and conversation (persistence) to do its job, but owns none of their data. That's a textbook application service / saga, not an aggregate of any of the three — giving it its own deployable keeps "what tools exist and how a turn is orchestrated" independently scalable and independently restartable from "where files live" or "what was said."

Why Workspace is separate from Agent, even though today only Agent calls it: the sandboxed filesystem is also directly reachable by a human through the gateway's upload/download/explorer panel. Coupling it to Agent would mean every file-browsing click goes through the LLM orchestration service for no reason, and would block scaling "concurrent agent runs" independently of "workspace storage."

Why rendering lives only in Gateway: pkg/renderkit (Markdown, syntax highlighting, code-snippet extraction) and every html/template file are a presentation concern, not a domain concept — no bounded context's business rule depends on HTML. Agent streams plain text and structured tool-call data; Gateway is the one place that turns that into HTML. This is also what makes Agent's gRPC contract (api/proto/agent/v1/agent.proto) reusable by a future non-HTML client (a CLI, a different front end) without change.

2. Database-per-service

Every context that owns data gets its own Postgres database (not just its own schema/tables in a shared instance):

  • iam → enkly_iam (users, sessions, email_verification_codes, password_reset_tokens)
  • billing → enkly_billing (subscriptions, stripe_events, usage_daily)
  • conversation → enkly_conversation (conversations, messages, tool_calls)
  • knowledge → enkly_knowledge (documents, pgvector)

workspace has no database — the sandboxed directory tree itself is its data, on its own Docker volume.

Consequence worth naming explicitly: the monolith's schema.sql had user_id BIGINT REFERENCES users(id) ON DELETE CASCADE on conversations, subscriptions, and usage_daily. Postgres cannot enforce a foreign key across two separate databases, so every one of those is now a bare user_id BIGINT with no cascade delete. The monolith never actually exposed an "delete my account" feature, so no behavior is lost today — but if one is added later, deleting a user must become a saga (iam deletes the user, then publishes a user.deleted event for billing/conversation to react to and delete their own rows), not a single SQL statement. This is the standard, unavoidable cost of database-per-service and is called out here so it isn't rediscovered the hard way.

3. Communication rules

"gRPC for synchronous communication, RabbitMQ for asynchronous communication" — applied throughout as: if the caller needs an answer (or needs to know the write succeeded) before it can proceed, it's a gRPC call; if the caller doesn't need to wait for the result, it's an event.

Synchronous (gRPC) — api/proto/*/v1/*.proto

Every cross-context call that's part of a request's critical path:

  • Gateway → IAM: session validation on (almost) every request, login, signup, ...
  • Gateway → every other service: composing a page from their data
  • Agent → Billing: check-and-increment the daily quota before a run starts
  • Agent → Conversation: persist each message/tool-call as a turn proceeds
  • Agent → Workspace: every filesystem/bash tool call
  • Agent → Knowledge: the search_codebase tool
  • dispatch_subagent makes no new outbound call of its own kind: it runs a second, nested agentrun.Agent in-process, sharing the Agent service's own vLLM-backed client and making the same Workspace/Knowledge gRPC calls an ordinary tool call would, just via a toolexec.restrictedExecutor that only forwards the read-only subset (read_file/list_directory/glob_search/grep/search_codebase). No new service, no new proto — see services/agent/internal/infrastructure/toolexec/subagent.go.
  • solve_ml_math (like write_todos) makes no outbound call at all: it's pure computation over pkg/mathkit, a dependency-free numerical engine (linear algebra, calculus/gradients, statistics, optimization, ML primitives) that runs entirely inside the agent process — see pkg/mathkit's package doc and services/agent/internal/infrastructure/toolexec/mlmath.go.
  • Agent StreamTurn: a server-streaming RPC — the gRPC replacement for the monolith's Server-Sent Events, carrying the same token/thinking/tool-call/done event sequence; the gateway re-emits each message as an actual SSE frame to the browser (see services/gateway/internal/handlers/chat.go).

Asynchronous (RabbitMQ) — pkg/eventbus

One topic exchange (enkly.events); routing keys of the form <context>.<aggregate>.<event>. Today's only real consumer is notification, bound to:

  • iam.user.verification_code_issued → send the signup/resend email
  • iam.user.password_reset_requested → send the reset-link email

Also published, with no consumer yet (kept so the pattern — every aggregate commit gets an event — is consistent across every context, and so a future service can bind without a producer-side change): iam.user.registered, iam.user.email_verified, billing.subscription.changed, conversation.message.created.

Trust boundary of the event bus: VerificationCodeIssued and PasswordResetRequested carry the raw code/token on the bus, not just an ID. This is a deliberate trade-off (documented in pkg/eventbus/events/iam_events.go) rather than an oversight: it lets notification send the exact email the monolith sent without needing read access to iam's token tables (which would violate database-per-service far more seriously than this does). It's safe only because RabbitMQ is never exposed outside the private service network — the same trust level the monolith implicitly gave its own in-process function call. A production hardening pass should put this exchange behind TLS + per-service credentials (RabbitMQ supports both) even on a private network, as defense in depth.

4. One Go module, many deployables

Every service shares a single module enkly (one go.mod at the repo root) rather than one go.mod per service. This is a deliberate, pragmatic choice: true microservice independence comes from separate processes, separate databases, separate containers, and a versioned API contract between them (all of which this repo has) — not from separate Go dependency graphs, which mainly adds replace-directive and version-skew overhead in a monorepo without buying any of the actual deployability benefits. Each service still has its own cmd/server/main.go, its own Dockerfile that builds only that one binary, and starts, scales, and fails independently of the others.

5. Layering within a service

internal/domain/            entities + repository interfaces (port). No sql, no net/http, no generated gRPC code.
internal/application/       use cases. Depends on domain only. Transport-agnostic — unit-testable without a server.
internal/infrastructure/    adapters: Postgres repos, the Stripe/vLLM HTTP clients, the sandboxed filesystem.
internal/interfaces/grpc/   the ONLY files that import this service's generated `gen/<ctx>/v1` package.

Dependency direction is inward: interfaces and infrastructure both depend on domain (through its interfaces); domain depends on nothing. This is what lets, e.g., services/iam/internal/application be tested with a fake UserRepository and no database at all.

6. What's verified, what isn't

Every .proto file in api/proto/ was compiled for real with protoc + the actual protoc-gen-go/protoc-gen-go-grpc plugins — gen/ is genuine generated code, not hand-written stand-ins. Every hand-written .go file in this repo passes gofmt -l (verified file by file as it was written).

What this port could not do, for lack of outbound network access to proxy.golang.org/google.golang.org in the authoring sandbox: run go build/go mod tidy end-to-end across the whole dependency graph (google.golang.org/grpc, github.com/lib/pq, github.com/rabbitmq/amqp091-go, github.com/pgvector/pgvector-go, github.com/gomarkdown/markdown), and so no go.sum is committed. Every service's Dockerfile therefore runs go mod tidy at build time (after copying in gen/, pkg/, and that service's own directory) to resolve and lock the graph on first build — this needs ordinary internet access, same as any other Go project, and is expected to succeed on any normal developer machine or CI runner. make build (plain go build, no Docker) needs a go.sum to exist first, so run go mod tidy at the repo root once before using it locally. Once a go.sum is committed, each Dockerfile's go mod tidy line should be swapped back to go mod download (noted inline in services/iam/Dockerfile) for faster, better-cached rebuilds.

The same constraint applied to the later Ollama→vLLM migration (pkg/vllmkit, replacing pkg/ollamakit): pkg/vllmkit itself has no dependency beyond the Go standard library, so it was verified for real — go build/go test both pass in an isolated module — but every file that imports it still depends on the same ungenerated go.sum, so those files could only be checked with gofmt -l (a full syntax parse; it does not resolve imports or type-check across packages), not a full go build ./.... Run go build ./... (or make build) as the first gate on that change in an environment with normal network access, before trusting it beyond what gofmt already confirms. See CHANGES.md's most recent entry for the full migration writeup.

7. Known gaps / simplifications

Called out here rather than silently shipped, each with why it's safe to defer:

  • Session validation latency. gateway's RequireAuth now makes one IAM.ValidateSession gRPC call per request, where the monolith did one local Postgres query in the same process. For most deployments this is low-single-digit-millisecond overhead on an internal network and is the correct place to start; if it becomes a bottleneck, the standard fix is a short-TTL cache in the gateway (or switching sessions to signed, self-contained tokens) — not re-merging the services.
  • Reattaching to a run after a page reload. The monolith could show a still-in-flight reply immediately on reload (StreamManager.activeFor). The gateway doesn't re-wire this today (it would need a new "is there an active run for this conversation" Agent RPC with no other caller) — a reload mid-reply shows the completed answer on next load instead of resuming the live view. The reply itself is unaffected; this is UI polish, not correctness.
  • Deleting a conversation mid-run. The monolith proactively cancelled any in-flight stream for a conversation being deleted. Agent's API has no "stop by conversation" RPC (only by stream_id), so a straggling run's writes will fail (logged) against the deleted conversation rather than being pre-empted. Low-impact and easy to close later by adding that RPC.
  • Verification/reset email confirmation. Because sending email is now asynchronous (RabbitMQ), iam's SignUp/ForgotPassword RPCs can no longer report back "the email failed to send" synchronously the way the monolith occasionally could — they report success once the code/token is persisted and the event is published. This is the expected, intentional cost of decoupling a side effect onto a message bus, not a bug.
  • Per-call bash timeout. WorkspaceService.RunBashCommand accepts a timeout_seconds field for forward-compatibility, but (matching the monolith, which only ever had one process-wide timeout) does not yet apply it per call.
  • Chat attachments aren't a schema field. The composer's "Add files or photos" (services/gateway/internal/handlers/attachments.go) uploads into the workspace under attachments/ and folds the resulting paths into the prompt string itself, as a trailing [Attached files] block — conversation.Message has no attachments field of its own, and adding one would be a schema change across the conversation/gateway boundary. The trade-off: attachments only ever round-trip as a specific text shape inside content, parsed back out by renderUserMessage, instead of being structured data the conversation service understands natively. Also: removing a staged chip before sending, or never sending at all, leaves the uploaded file behind in the workspace — nothing currently reaps attachments that were uploaded but never attached to a sent message.
  • Zip attachments extract one level deep. A .zip attached to the chat is unpacked into attachments/<name>/ (gateway-side, with its own zip-slip guard on top of workspace's existing path jail, plus caps on entry count and total uncompressed size) so the agent's existing read_file/list_directory/grep/glob_search tools can read what's inside it — no new agent tool needed. A .zip nested inside that .zip is written out as-is rather than recursively extracted; unzip isn't in DefaultAllowedCommands (services/workspace/internal/infrastructure/sandboxfs/sandbox.go), so the agent can't shell out to unpack it further unless an operator adds it to ENKLY_BASH_ALLOWLIST.
  • Language preference storage. The language switcher (pkg/i18n, services/gateway/internal/handlers/i18n.go) persists a visitor's chosen UI locale in a year-long enkly_lang cookie, not on the IAM User row. That was a deliberate choice, not an oversight: adding a preferred_language field means extending iam.proto's User message and an RPC, which means regenerating every language's generated client/server code under gen/ — this repo's gen/ directory is checked in and hand-maintained, and doing that regeneration safely needs protoc and the project's buf/codegen toolchain, which isn't always available in every environment this code is edited in. A cookie needs neither: it survives logins and browser restarts, just not a switch to a second device. The same reasoning is why the visitor's locale reaches the agent service as gRPC metadata (x-enkly-lang, set in chat.go's withLangMetadata, read in services/agent/internal/interfaces/grpc/server.go's langFromIncomingContext) rather than as a new StartTurnRequest field. Moving either onto the User row/proto later is a clean, backward-compatible addition — localeFrom/langFromIncomingContext are the only two places that would need a new "check the account first" branch — just not one to do without protoc on hand to verify the regenerated code.
  • UI translation coverage. pkg/i18n ships English and French complete; Spanish, German and Portuguese cover the nav/auth/composer chrome and fall back to English for the rest (quota/error copy, the subscription and files panels) — see pkg/i18n/locales/*.json and T's fallback behavior in pkg/i18n/i18n.go. subscription.html and files.html aren't wired to the switcher yet (no Lang field on their page-data structs). None of this affects how the agent itself talks to a user — see agentrun.SystemPrompt's "## Language" section and agentrun.WithPreferredLanguage, which work from any locale regardless of how much of the surrounding chrome has a catalog entry for it.

8. Operational hardening before a real rollout

Deliberately out of scope for this restructuring (it's infrastructure polish orthogonal to the DDD boundary work), but worth a checklist:

  • mTLS between services (today: plaintext gRPC on a private Docker network — fine inside a single trusted VPC, not fine across one)
  • RabbitMQ publisher confirms + a dead-letter exchange for poison messages (today: at-least-once with a bounded retry via Nack)
  • Replacing the in-memory authsupport.Limiter/domain.Manager (agent's stream registry) with something shared across replicas (Redis) once any service runs more than one instance — both are currently per-process, same as the monolith's own in-memory limiter was