Monorepo topology¶
Architecture
Normative spec
This page is drawn from .ai/specs/core-engine/2026-07-02-monorepo-topology.md (Status: Approved), which defines the canonical directory tree, the three coexisting toolchains, and the spec-first enforcement mechanism that governs every subsequent change to the repository.
Telha ships from a single repository with three cooperating language layers (Rust, TypeScript, Python) plus shared client SDKs and protobuf contracts. This page describes the topology as designed and verifies it against the tree as built, including where the two disagree.
Overview & purpose¶
The spec exists because, without a fixed topology, neither human contributors nor AI coding agents can reliably locate code, understand module boundaries, or determine which subsystem owns a given file. Ad-hoc structure leads to circular dependencies, unclear ownership, and CI path-filters that silently stop matching anything. Fixing the tree up front also gives the spec-first harness a known location for .ai/specs/ and a known set of "protected paths" to gate, before any enforcement logic can be written.
The spec is scoped to structure only: it fixes directory layout, toolchain versions, and build conventions. Runtime data models (key layout, version records, and so on) are defined in their own specs and linked from Architecture.
Design¶
The core idea¶
One monorepo, three toolchains, one topology that every PR must conform to. Concretely:
core-engine(Rust): the tritemporal graph and vector engine, compiled to a singletelhabinary that serves REST, gRPC, and MCP.app-layer(TypeScript): the Telha app, a Next.js application, plus the enterprise packages (field encryption, RBAC) that sit on top of core.workers(Python): stateless gRPC clients that parse documents and pull from source systems, with no direct storage access of their own.sdk,proto,tooling, and.ai/specsare shared infrastructure that all three layers depend on but none of them own.
Why three layers instead of separate repositories¶
The spec's alternatives-considered section explicitly rejects splitting by language into separate repositories: AI coding agents need full-stack context in one place, and cross-repo context switching loses coherence when a single change (say, a new fact type) touches storage, the app layer, and a worker projection at once. It also rejects Nx in favor of Turborepo for the TypeScript build (simpler config for a Yarn-workspace monorepo, better caching for this shape of project) and Poetry in favor of uv for Python (native pyproject.toml workspace support, faster resolution).
Why spec-first¶
The .ai/specs/ directory is the single source of truth for design decisions and is exposed as an MCP resource, so the running system can be cross-checked against its own design manuals. The spec explicitly rejects keeping specs in a separate repository, because the CI gate (below) needs the spec change to land in the same PR as the code it justifies. This is the mechanism the rest of Architecture leans on: every subsystem page here names the spec it was verified against.
flowchart TB
subgraph specs["Spec-first source of truth"]
TEMPLATE[".ai/specs/_TEMPLATE.md<br/>14-section manual"]
COREVSPEC["core-engine/ specs"]
APPSPEC["app-layer/ specs"]
INTSPEC["integration/ specs"]
end
subgraph build["Three toolchains, one repo"]
direction LR
subgraph coreeng["core-engine, Rust"]
CARGO["Cargo workspace<br/>rust-toolchain.toml"]
end
subgraph applayer["app-layer, TypeScript"]
YARN["Yarn workspaces<br/>+ Turborepo"]
end
subgraph wk["workers, Python"]
UV["uv workspace<br/>pyproject.toml"]
end
end
subgraph shared["Shared infrastructure"]
PROTO["proto/ (buf-linted)"]
SDK["sdk/typescript + sdk/python"]
TOOL["tooling/spec-lint, tooling/packaging"]
end
specs -.governs.-> build
PROTO --> coreeng
PROTO --> applayer
PROTO --> wk
coreeng -->|generates client types| SDK
TOOL -->|CI gate| specs Directory topology¶
The tree below is the spec's §4 Architecture section, verified directory by directory against the actual repository at the working tree root.
/telha-v2
├── /core-engine Rust binary (telha-core / telha)
│ ├── /src/{api,backup,clarify,erasure,graph,grpc,ingest,
│ │ jobs,llm,mcp,model,planner,query,retention,
│ │ schema,storage,temporal,vector,verify,watch}
│ ├── Cargo.toml / Cargo.lock
│ ├── build.rs protobuf codegen
│ ├── rust-toolchain.toml
│ ├── benches/, fuzz/, tests/, openapi/
│ └── run-*.cmd local gate/lint/test scripts
├── /app-layer TypeScript (Yarn workspaces + Turborepo)
│ ├── /apps/mercato Next.js web application
│ ├── /packages/{core,enterprise,ai-assistant,clarify-bot,
│ │ contracts,ui,queue,events,search}
│ ├── turbo.json / package.json / .yarnrc.yml
├── /workers Python (uv workspace)
│ ├── /workers_common
│ ├── /docling_worker, /firecrawl_worker, /code_worker,
│ │ /tabular_worker, /email_worker
│ ├── /entra_connector, /sharepoint_connector,
│ │ /exchange_connector, /slack_connector,
│ │ /salesforce_connector
│ └── pyproject.toml
├── /sdk
│ ├── /typescript @telha/sdk (dual transport: REST + gRPC)
│ └── /python telha-sdk (REST, pydantic)
├── /proto
│ ├── buf.yaml buf v2, STANDARD lint, FILE breaking-check
│ ├── /telha/v1 app_layer.proto, worker.proto
│ └── /mcp tools.json
├── /tooling
│ ├── /spec-lint spec-lint.mjs (Node, zero-dep)
│ └── /packaging Windows zip packaging, sample config, smoke probe
├── /.ai/specs
│ ├── _TEMPLATE.md
│ ├── README.md
│ ├── /core-engine, /app-layer, /integration
├── /.github/workflows CI pipelines (spec-gate, rust, typescript,
│ python, rust-bench, rust-fuzz, eval-nightly,
│ release-build)
├── /deploy systemd unit + env template for the binary
├── /eval standalone Rust workspace for offline eval runs
└── /docs this documentation site (MkDocs + Material)
This is the verified tree, not just the spec text
Everything above was confirmed against the working tree. A few directories exist in the repo but not in the original spec text (deploy/, eval/, and several workers/ and app-layer/packages/ members); see As-built notes below.
Core engine module map¶
core-engine/src/ is organized by subsystem, one directory per concern. Each maps to one or more architecture pages:
| Directory | Owns | Related page |
|---|---|---|
storage/ | RocksDB column families, key codec, scan ranges, bitmap index | Storage engine, Bitmap index |
model/, temporal/ | Version records, the winner rule, tombstone cascade | Version record model, Tombstone cascade |
graph/ | Node/edge operations, traversal | Traversal semantics |
query/ | Query AST, executor, global scan, bitmap planner, compare | Global temporal scan, Snapshot & compare |
schema/ | Schema inference over facts | Schema inference |
vector/ | Embedding provider, vector storage, HNSW partitions | Vector storage, HNSW partitioning |
planner/ | Memory planning under token budget | Memory planner |
verify/ | Claim decomposition, lexing, span matching | Claim verification |
jobs/ | Job queue and leases | Job queue & leases |
ingest/ | Ingestion entry points, provenance, JSON bypass | Ingestion & provenance |
erasure/ | GDPR erasure: scan, freeze, mask, blocklist, archives | GDPR erasure |
retention/ | Retention ledger, archival executor | (see spec 2026-07-05-retention-archival.md) |
backup/ | Backup/restore | (see spec 2026-07-05-backup-restore.md) |
api/, grpc/, mcp/ | REST /v1, gRPC, MCP server surfaces | gRPC contracts |
clarify/ | Clarification engine hooks | Clarification engine |
llm/ | Generation orchestration, cost accounting, secrets | Generation orchestration |
watch/ | Change watching (memory watch integration) | Memory features |
App layer package map¶
| Package | Role |
|---|---|
apps/mercato | The Next.js application itself |
packages/core | Shared app-layer primitives |
packages/enterprise | Field-level encryption (src/field-crypto/), RBAC |
packages/ai-assistant | AI assistant surface |
packages/clarify-bot | Clarification bot integration |
packages/contracts | Cross-package contracts, including routing (ROUTING.md) |
packages/ui | Shared UI components |
packages/queue, packages/events, packages/search | Supporting app-layer services |
Workers map¶
workers/ holds both format workers (parse documents into facts) and source connectors (pull from external systems), all as independent uv workspace members sharing workers_common:
| Kind | Members |
|---|---|
| Format workers | docling_worker, firecrawl_worker, code_worker, tabular_worker, email_worker |
| Source connectors | entra_connector, sharepoint_connector, exchange_connector, slack_connector, salesforce_connector |
| Shared | workers_common |
Format workers correspond to the projection specs (docling-projection, web-ingestion, tabular-projection, code-projection, email-projection) and connectors correspond to the connector-framework and identity-connector specs; see Format workers and Connector framework.
Toolchains per layer¶
| Layer | Language/runtime | Package manager | Build orchestrator | Pinned by |
|---|---|---|---|---|
core-engine | Rust, stable channel | Cargo | Cargo workspace | core-engine/rust-toolchain.toml |
app-layer | Node.js 22+ | Yarn 4 (yarn@4.6.0, node-modules linker) | Turborepo 2 | app-layer/package.json (packageManager), turbo.json |
workers | Python 3.12+ | uv | uv workspace ([tool.uv.workspace]) | workers/pyproject.toml |
proto | Protobuf | n/a | buf (v2, STANDARD lint, FILE breaking-change detection) | proto/buf.yaml |
sdk/typescript | TypeScript | npm/Yarn | tsc | sdk/typescript/package.json |
sdk/python | Python 3.12+ | pip/uv | n/a | sdk/python/pyproject.toml |
Build interface, unchanged from the spec and confirmed runnable from a clean clone:
# Rust core engine
cd core-engine && cargo build
# TypeScript app layer
cd app-layer && yarn install && yarn turbo build
# Python workers
cd workers && uv sync
# Spec lint
node tooling/spec-lint/spec-lint.mjs .ai/specs
All three toolchains succeeding from a clean clone is the spec's Phase 0 acceptance gate (§12 Success Metrics).
Toolchain versions, spec vs. as-built
The spec's Configuration table (§8) pins Rust 1.83, Turborepo 2.3, and Yarn 4.6 as the target versions. As built:
core-engine/rust-toolchain.tomlpins channel 1.90, andcore-engine/Cargo.tomldeclaresrust-version = "1.85"as the crate's minimum supported version. Both are newer than the spec's 1.83.app-layer/package.jsonpins"packageManager": "yarn@4.6.0", matching the spec, but declares"engines": { "node": ">=20.18" }, which is looser than the "Node 22+" stated in the spec and in the rootREADME.md.- Turborepo is pulled as
"turbo": "^2.3.0"inapp-layer/package.json, matching the spec. app-layer/.yarnrc.ymlsetsnodeLinker: node-modulesrather than Yarn's default PnP, with an inline comment explaining why: PnP's ESM loader is experimental and Next.js /grpc-jsproto loading assume a realnode_modulestree. This decision is not called out in the spec.
The spec-first CI gate¶
The gate is .github/workflows/spec-gate.yml, and it runs three separate jobs on every pull request (opened, synchronize, reopened, labeled, unlabeled):
flowchart LR
PR["Pull request"] --> LINT["Job: lint\nspec-lint.mjs over .ai/specs/"]
PR --> GATE["Job: gate\ndesign-change requires spec"]
PR --> ITER["Job: iterator-gate\nF1 tenant isolation"]
LINT -->|exit 0| PASS1["pass"]
LINT -->|violation| FAIL1["fail: RULE-01..11 violation"]
GATE --> DIFF["diff base...HEAD\nchanged.txt"]
DIFF --> PROTECTED{"touches a\nprotected path?"}
PROTECTED -->|no| PASS2["pass"]
PROTECTED -->|yes| SPECCHANGE{"spec file\nchanged too?"}
SPECCHANGE -->|yes| PASS3["pass"]
SPECCHANGE -->|no| LABEL{"'no-spec-needed'\nlabel present?"}
LABEL -->|yes| WARN["warn + pass\n(needs PR justification)"]
LABEL -->|no| FAIL2["fail: add spec as\nfirst commit"]
ITER --> GREP["grep .raw_iterator( / .iterator( /\n.prefix_iterator( in core-engine/src/"]
GREP --> OUTSIDE{"match outside\nsrc/storage/?"}
OUTSIDE -->|yes| FAIL3["fail: F1 violation"]
OUTSIDE -->|no| PASS4["pass"] Job 1: lint¶
Runs node tooling/spec-lint/spec-lint.mjs .ai/specs on Node 22, checking every spec file in the tree against eleven rules (RULE-01 through RULE-11), enforced by tooling/spec-lint/spec-lint.mjs:
| Rule | Checks |
|---|---|
| RULE-01 / 02 | Filename matches {YYYY-MM-DD}-{kebab-title}.md; date is real and not in the future |
| RULE-03 | Spec lives directly under core-engine/, app-layer/, or integration/, no nesting |
| RULE-04 | First content line is # Spec: <title> |
| RULE-05 | Has a **Status:** Draft \| In Review \| Approved \| Superseded line |
| RULE-06 / 07 | All 14 canonical sections present, in order, correctly numbered; no unknown top-level ## sections |
| RULE-08 / 11 | Every section has substantive content (40+ non-whitespace characters); bare "N/A" is rejected |
| RULE-09 | Changelog has at least one entry matching - YYYY-MM-DD, |
| RULE-10 | Open Questions entries match - OQ-N: ... (or a resolved strikethrough form), or the section is exactly None. |
The 14 canonical sections, in required order, are: Overview, Problem Statement, Proposed Solution, Architecture, Data Models, API Contracts, UI/UX, Configuration, Alternatives Considered, Implementation Approach, Migration Path, Success Metrics, Open Questions, Changelog. This spec you are reading right now is itself linted against that same template.
Job 2: gate (design-change requires spec)¶
This job computes the changed-files diff against the PR's base branch, then checks whether any changed path matches the protected-path regex baked into the workflow:
^(core-engine/src/(storage|graph|temporal|vector|query|schema|planner|jobs)/|
proto/|
workers/(docling_worker|firecrawl_worker|code_worker|tabular_worker|email_worker)/|
app-layer/packages/enterprise/field-crypto/)
If a protected path is touched:
- The PR passes if a file under
.ai/specs/{core-engine,app-layer,integration}/was also changed in the same diff. - Otherwise, the PR passes with a warning if it carries the
no-spec-neededlabel (intended for typo fixes inside protected paths; the label requires a justification comment, and changing the protected-path list itself requires CODEOWNERS review of the workflow file). - Otherwise, the job fails and instructs the author to add the spec as the first commit of the PR.
Job 3: iterator-gate (F1 tenant isolation)¶
A separate, narrower structural gate: it greps core-engine/src/ for .raw_iterator(, .iterator(, and .prefix_iterator( calls and fails if any match falls outside core-engine/src/storage/. The intent is that no call site anywhere in the engine can construct an unbounded RocksDB iterator that bypasses the KeyCodec/ScanRange layer; tenant isolation is enforced structurally rather than by convention. This is not one of the three jobs described in the spec's prose, but it is deployed in the same workflow file.
Protected-path drift, spec vs. code
The gate's regex protects app-layer/packages/enterprise/field-crypto/, but the field-encryption code as built lives one level deeper, at app-layer/packages/enterprise/src/field-crypto/. As written, the gate's regex is a path-prefix match, so a change under src/field-crypto/ still matches the broader app-layer/packages/enterprise/field-crypto/ prefix test used by grep -E, but this is a coincidence of how the pattern is anchored, not a confirmed intentional design. Contributors relying on exact-path protection for this directory should verify gate behavior directly rather than assume it from the spec text.
Lint can also be run locally, either over the whole tree or a single file:
node tooling/spec-lint/spec-lint.mjs # whole tree
node tooling/spec-lint/spec-lint.mjs --file <path> # one file
As-built notes¶
The spec's own Changelog (§14) has a single entry, "2026-07-02, v0.1: initial topology spec documenting PR-001 decisions," and records no deviations. The items below are differences found by comparing the spec's prose directly against the repository as it exists today; they are not recorded in the spec's changelog itself.
Directories added beyond the original scaffold
The spec's §4 tree only names core-engine, app-layer, workers, sdk, proto, tooling/spec-lint, .ai/specs/, and .github/workflows/. The repository as built also has:
/deploy: a systemd unit (telha.service) and an env template (telha.env.example) for running the binary as a service./eval: a standalone Rust workspace (its ownCargo.toml,Cargo.lock, andrust-toolchain.toml) for offline evaluation runs, separate from thecore-engineCargo workspace. See Eval harness./docs: this MkDocs Material documentation site.tooling/packaging: a secondtooling/member beyondspec-lint, holding Windows zip packaging (make-windows-zip.ps1), a sample config (telha.sample.toml), and a smoke-test probe (smoke-probe.sh).
Workers and packages grew beyond the spec's example list
The spec's §4 tree lists workers as /{docling,firecrawl,code,tabular}_worker and app-layer packages as /packages/{core,enterprise,ai-assistant,ui,queue,events,search} (§8 and README examples). As built:
workers/additionally hasemail_worker(format worker) and five connectors:entra_connector,sharepoint_connector,exchange_connector,slack_connector,salesforce_connector. Each is its ownuvworkspace member with its ownpyproject.tomlandtests/.app-layer/packages/additionally hasclarify-botandcontracts.- Each addition has its own spec under
.ai/specs/core-engine/,.ai/specs/app-layer/, or.ai/specs/integration/(for example2026-07-04-source-connector-framework.mdand2026-07-04-entra-identity-connector.md), so the spec-first rule was followed even though the §4 tree in this topology spec was never updated to list the new members.
The .qoder/ symlink claim does not hold
Spec §11 (Migration Path) states: "The .qoder/ directory contains pre-built planning documents (PRD, build doc) that are referenced by symlink from the root README." As built, .qoder/.ai/specs is a real, separate directory tree (not a symlink), and the root README.md links to .qoder/docs/telha-prd-v8.md and .qoder/docs/telha-build-doc.md as plain relative paths, not symlinks. Whether those specific docs/ files exist under .qoder/ was not independently confirmed; only .qoder/specs, .qoder/.ai, and .qoder/.github were found.
Proto scaffolding is further along than the placeholder text suggests
proto/telha/v1/README.md still reads as a placeholder ("Proto files are added in PR-025 (AppLayerService + WorkerService)"), but the directory already contains real app_layer.proto and worker.proto files, and core-engine/build.rs performs protobuf codegen from them. The placeholder comment was not updated after the files landed.
SDK package names
The spec's §4 tree and §8 describe the Python SDK simply as telha (REST, pydantic). As built, sdk/python/pyproject.toml names the package telha-sdk. The TypeScript SDK matches the spec: sdk/typescript/package.json names it @telha/sdk.
None of these are functional defects: every added directory has its own normative spec, and the core three-layer, three-toolchain shape the spec mandates is intact. They are documented here because this page's job is to describe the system as built, not just as designed, per the spec-first convention that governs the rest of this section.
Related¶
- Architecture overview, how the spec-first convention applies across all subsystems.
- Storage engine & key layout, what lives under
core-engine/src/storage/. - The version record model, what lives under
core-engine/src/model/andcore-engine/src/temporal/. - gRPC contracts, the
proto/telha/v1/contracts and how app-layer and workers call core. - Connector framework, the
workers/*_connectormembers. - Format workers, the
workers/*_workermembers. - Field encryption,
app-layer/packages/enterprise/src/field-crypto/. - Eval harness, the standalone
/evalRust workspace. - Core concepts, the product ideas this engine implements.
- Developers › Query language and REST API, the surfaces
core-engine/src/api/andcore-engine/src/query/expose.