A governed, multi-tenant, agentic LLM platform — as a reference architecture you can actually learn from.
Most reference implementations for agentic systems show you a stack wired together: a model call behind an API, a queue, maybe a vector store, and a README that says "add your own auth." They demonstrate that the pieces connect. They rarely demonstrate the part that determines whether a system like this survives contact with real users, real tenants, and real auditors — the governance. AEGIS is built the other way around. It starts from the hard parts and treats the plumbing as the easy part, because in practice it is.
The shape is a governed edge (NestJS/Fastify: authentication, authorization, audit, and a human-in-the-loop safety gate) fronting durable outer orchestration (Hatchet), which drives an inner agent graph (LangGraph, in Python) over a typed gRPC contract, with model calls shaped by a structured prompting layer. It is multi-tenant by construction, backed by Postgres (with pgvector, and row-level security as the enforcement target — see Status) and ArangoDB. That paragraph could describe a dozen repos. The difference is in how the boundaries are enforced, not merely drawn.
Authorization belongs at the data layer, not at the route. A guard on an HTTP handler is a suggestion —
one forgotten decorator, one new endpoint, one internal caller, and it's bypassed. AEGIS pushes the
authorization decision down to where the rows actually are (policy compiled to a query predicate, backed by
database-enforced row-level security), so "which records can this actor see" is answered by the store itself,
not by remembering to ask nicely on the way in. (Today the predicate is applied in the repository; WU-05
lowers it into engine-enforced RLS — see Status.)
The human-in-the-loop gate is structural for the same reason: a checkpoint the orchestration graph pauses on
and cannot route around, not an if (needsApproval) branch someone can quietly delete. And the separation
between sensitive data and the analytics/measurement path is a real firewall with a CI leak-gate — a test
that fails the build if the two domains touch — rather than a naming convention and good intentions.
It is meant for engineers and architects who ship agentic systems into environments where "it worked in the demo" is not an acceptable standard, and who would rather study a small number of hard problems solved carefully than a large number of easy ones solved fast.
Honesty note. This is a work in progress, and the sections below label exactly what is wired today versus designed-next. The governance machinery is the deliverable; one neutral workload (a governed Case-approval flow) demonstrates it. An evaluator can grep the repo — nothing here claims more than the tree contains.
The client's only entrypoint is the governed edge; enforcement converges on the data layer, so a bypassed
route guard still returns zero unauthorized rows. Green = wired today · amber = present but off the governed
read path · red-dashed = specified, not yet built (WU-05 keystone). Full walkthrough — six diagrams tying
each layer to its ADR and its source files — in docs/ARCHITECTURE.md.
Parity across a polyglot stack is usually somebody's job to remember. Here it's a property of the build — a contract change that outruns a consumer fails a build, not a production request.
- One
.proto→ TypeScript and Python, in lockstep.proto/aegis/**is the single source of truth for the edge ↔ agent-tier boundary.buf generateemits the ts-proto surface the NestJS edge imports (@aegis/contracts) and the gRPC/protobuf stubs the Python agent tier imports (apps/agents/src/aegis/…) — from that one schema. Both are committed and drift-gated: a regenerate-and-diff test fails the build if either language's stubs fall out of sync with the.proto. [wired · ADR-0014 / ADR-0018] - One CASL ability model → the edge guard and the web UI.
@aegis/authzis defined once; the NestJSPoliciesGuardenforces it at the data layer, and the React app renders from the same abilities — isomorphic authz, so the button a user can't see is exactly the action the server won't allow. Edge enforcement [wired], web render [designed-next · WU-11]. - The edge's Zod DTOs → OpenAPI → a typed browser client, for free. Request/response shapes are Zod schemas
(
nestjs-zod); the edge emits an OpenAPI spec from them; the browser's REST client is generated from that spec, not hand-synced. [designed-next · WU-11 · ADR-0022]
The payoff: end-to-end type safety across TypeScript, Python, and the browser — from single sources — and cross-layer drift becomes impossible to ship silently, because the wiring keeps the layers in agreement, not a person remembering to update three places.
Every non-trivial choice is recorded as an ADR that carries three things a "we chose X over Y" note drops: the deciding axis (the one dimension the call turned on), an honest steelman of the runner-up (the rejected option presented as genuinely good, because it usually was), and the flip-condition (the concrete change in constraints that reverses the call). The one-liners below are the deciding axis; the ADR carries the steelman and the flip-condition.
-
Governed edge + authorization at the data layer — a NestJS/Fastify boundary owning authn, CASL authorization, audit, and the safety gate; the CASL ability compiles into a store-level predicate (Postgres RLS / Arango
FILTER) so a bypassed route guard still returns zero unauthorized rows. Deciding axis: enforcement has to survive a bypassed route guard — the store is the only plane the edge and the Python agent tier both share. → ADR-0013, ADR-0017 -
protobuf + gRPC as the edge↔agent-tier contract — one
.protodefines the polyglot, streaming boundary (RunAgentserver-streamsAgentEvent; anActorContextcarries governed identity across the process hop); buf codegen emits the Python and TypeScript surfaces from that single schema, drift-gated in CI. Deciding axis: one authoritative schema kept enforced across a polyglot, streaming boundary, where drift fails a build rather than a request. → ADR-0014, ADR-0018 -
Hatchet — outer durable orchestration — durable-execution engine for the long-lived cross-tier process (fan-out, rate-limited LLM/embedding batches, durable waits on the human gate, crash-resume), riding the Postgres already in the stack with no new datastore. Deciding axis: the smallest operational/vendor surface that still gives true step-level durability, on infrastructure already present. → ADR-0009
-
LangGraph — inner agent graph in a separate Python tier — the tool-calling loop, checkpoints, HITL pauses, and token streaming live in
apps/agents(Python), reached over gRPC. One LangGraph run = one durable Hatchet task; neither layer checkpoints what the other already checkpoints. Deciding axis: two durability layers persisting the same state is a correctness liability, so split by responsibility (inner control-flow vs outer run) rather than stack them. → ADR-0010 -
DSPy-shaped LLM calls — narrow LLM surfaces are written as typed
SignatureI/O contracts (adopted now, runnable un-optimized); the GEPA optimizer is deferred until a metric + labeled eval set exist, at which point the required safety/eval gate is the optimization signal. Deciding axis: structure and evaluability are cheap to buy now; optimization complexity is expensive and not yet earned. → ADR-0011 -
Sensitive ↔ analytics firewall — an append-only, content-free
eventsschema under distinct credentials computes the aggregate measurement asset without a grant that can read raw records; a CI leak-gate keeps the two domains from touching. Deciding axis: "computed without touching raw records" must be a property of the wiring (separate credentials), not of good intentions in a query. → ADR-0015 -
Postgres + pgvector as default primary; ArangoDB scoped to earned traversal — relational tenant state, RLS isolation, and vector retrieval collapse onto Postgres; the graph store is retained only for the cross-entity traversal that measurably defeats recursive CTEs / Apache AGE. Deciding axis: can the engine backstop authorization at the data layer? Postgres RLS is engine-enforced and fail-closed; a graph store has no equivalent, so it must earn its place on evidence. → ADR-0016, ADR-0019
The full deciding-axis · steelman · flip-condition writeups live in
.agent-docs/decisions/.
- The governed edge boot chain —
main.ts → NestFactory(AppModule)mounts a globalJwtAuthGuard → PoliciesGuard → AuditInterceptorchain.PoliciesGuardfails open on an unannotated route by design (the fail-open baseline the architecture then closes), so every governed route carries an explicit@CheckPolicies. - The frozen CASL model (
packages/authz) — six domain subjects, the four lifecycle actions (propose/approve/reject/commit), and separation-of-duties expressed as distinct actions on distinct roles (not a conditionalupdate). Shared verbatim by the edge and the React frontend. - The proto/gRPC contract + buf codegen —
aegis.agent.v1andaegis.governance.v1inproto/;make proto(buf generate) emits the committed Python and TypeScript stubs, drift-gated by a regenerate-and-diff test. - The Postgres/Kysely governance data layer — a non-root
PostgresService, anode-pg-migrategovernance migration (five tables, indexed scoping columns, the effectively-once partial-unique index on committed decisions), KyselyDBtypes introspected from the migrated schema and drift-gated via testcontainers. - The tenant-scoped read plane —
GET /cases(and the sibling governance lists) run Controller → Service → Repository → Postgres, applyingwhere tenant_id = actor.tenantIdin the query, at the data layer — not at the route guard. This is the read half of the neutral governed-approval workload.
- The
ability → {RLS | AQL FILTER}compiler: one seam that lowers a CASL ability into a Postgres RLS policy predicate and an ArangoDB AQLFILTER, so the row-level rules (workspaceId $in,assignedReviewerIds $in) are pushed to the store rather than applied in application code. - Dual-store enforcement: Postgres
CREATE POLICY+ENABLE ROW LEVEL SECURITYunder the engine backstop; ArangoDB routed through one mandatory filter-injecting chokepoint (no engine backstop there — the compiled predicate carries the full burden, so the chokepoint is construction-enforced). - Fail-open closure: a mechanical policy-omission lint (every tenant/scoped table must enable RLS and carry a policy) plus a cross-tenant-returns-zero falsifier run against both stores, so isolation is proven, not asserted.
Further out on the roadmap (tracked in .agent-docs/traceability/ledger.md):
the immutable hash-chained audit (WU-10), the field-classification firewall manifest (WU-09), and the full
agent-draft → HITL-approve → commit path through the agent tier. The read plane and the enforcement seams are
in the tree today; the compiler that turns those seams into engine-enforced isolation is the next unit of work,
not something already shipped.
Prerequisites — you need BOTH of these on the host, and nothing else:
-
Jetify Devbox — the entire toolchain (node · pnpm · uv · python · buf · make) is pinned inside Devbox, so you don't install any of it by hand:
curl -fsSL https://get.jetify.com/devbox | bash -
Docker — runs the infra (
make up: Postgres · ArangoDB · Dragonfly · Hatchet) and the ephemeral testcontainers theapps/apisuite spins up.
Devbox provides the languages and tools; Docker provides the services. Install both on the host — that's it.
# 1. Enter the toolchain shell (node/pnpm/uv/buf/make on PATH)
devbox shell
# 2. Bootstrap: install deps, generate proto stubs, build shared libs, install git hooks
make setup
# 3. Bring up infra (Docker must be running) — postgres, arango, dragonfly, hatchet.
# On first run this also scaffolds .env from .env.example; edit it if you need to.
make up
# 4. Migrate and seed the governance schema (so GET /cases returns rows)
pnpm --dir apps/api pg:migrate:up
pnpm --dir apps/api pg:seed
# 5. Run the edge + web (api :3000, Swagger at /docs; web :5173)
make devThe agent tier runs in its own terminals. It needs a Hatchet token — with infra up (step 3), mint one and
paste it into .env as HATCHET_CLIENT_TOKEN, then start the two Python processes:
make hatchet-token # prints a token — copy it into .env as HATCHET_CLIENT_TOKEN
make dev-agents # Python gRPC agent server
make dev-worker # Python Hatchet workerEndpoints: API http://localhost:3000 (Swagger /docs) · Web http://localhost:5173 ·
Hatchet http://localhost:8888 · Arango http://localhost:8529.
What to look at first: authenticate at the edge, then call GET /cases. The response is tenant-scoped by a
predicate in the query, not by a route check — remove the @CheckPolicies from the controller and the read is
still bounded by the repository's where tenant_id, which is the whole point of authorization at the data
layer. (Today that predicate is applied in the repository; WU-05 lowers it into engine-enforced RLS.)
All three must pass; the git hooks enforce a subset at commit/push time.
make lint # eslint (strict) + ruff
make typecheck # tsc + mypy --strict
make test # vitest + pytestapps/
api/ NestJS/Fastify governed edge (TypeScript) — the boundary
web/ React frontend (Vite) — shares the CASL model with the edge
agents/ LangGraph inner graph + Hatchet worker (Python) — the agent tier
packages/
authz/ CASL abilities + domain subjects (framework-free, shared)
contracts/ generated gRPC/proto types (ts-proto) + shared zod schemas
proto/ the .proto contract (aegis.agent.v1, aegis.governance.v1)
.agent-docs/ the in-repo context system (see below)
The flagship vertical (a financial-crime-review workload) is meant to live on a branch as a governance
pack; main carries the neutral governed-approval flow so the machinery is legible without a domain.
AEGIS is also a demonstration of AI-native engineering done with discipline rather than vibes — and the working record is in the tree, not asserted after the fact.
- An in-repo context system —
.agent-docs/carries the fleeting state (now/), the decision record (decisions/), the production-reachability ledger (traceability/), review reports, and runbooks. It is the memory the build ran on. - ADRs with alternatives — every non-trivial decision records its deciding axis, an honest steelman of the runner-up, and a flip-condition. The rejected options are kept because the reasoning is portable even where the choice isn't — if you're building something adjacent, that's the part you actually need.
- An adversarial, gated build — the reviewer is never the builder; designs and diffs are checked by a clean-context verifier before they proceed, and a unit of work is "done" only when a path traces from a real entrypoint to the new code (the IMPL→WIRED bar), not when the tests merely go green.
The .agent-docs/ directory is not just documentation — it's a machine-readable context
system built so a person or an AI coding agent can pick up the work with zero archaeology. It carries the
fleeting state (now/ — a curated handoff, status, work-plan, open-questions), the decision record
(decisions/), the production-reachability ledger (traceability/), typed review reports (reviews/),
lessons, and runbooks — each routed through a per-directory index.md.
Clone the repo, open it in Claude Code, and run:
/orient
It reads the curated handoff, verifies it against the live git state, and surfaces a short brief — where the
work is, what's in flight, the open questions that gate progress, and the immediate next action — so you start
contributing in minutes instead of reverse-engineering the tree. /handoff regenerates that bridge at session
end (or before context runs out); /flush checkpoints mid-session. The full working discipline is in
CLAUDE.md and the cross-tool entrypoint AGENTS.md.
Apache License 2.0 — permissive, with an explicit patent grant, so you can fork this reference architecture into your own vertical. © 2026 Armen Rostamian.
