Skip to content

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 single telha binary 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/specs are 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.toml pins channel 1.90, and core-engine/Cargo.toml declares rust-version = "1.85" as the crate's minimum supported version. Both are newer than the spec's 1.83.
  • app-layer/package.json pins "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 root README.md.
  • Turborepo is pulled as "turbo": "^2.3.0" in app-layer/package.json, matching the spec.
  • app-layer/.yarnrc.yml sets nodeLinker: node-modules rather than Yarn's default PnP, with an inline comment explaining why: PnP's ESM loader is experimental and Next.js / grpc-js proto loading assume a real node_modules tree. 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-needed label (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 own Cargo.toml, Cargo.lock, and rust-toolchain.toml) for offline evaluation runs, separate from the core-engine Cargo workspace. See Eval harness.
  • /docs: this MkDocs Material documentation site.
  • tooling/packaging: a second tooling/ member beyond spec-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 has email_worker (format worker) and five connectors: entra_connector, sharepoint_connector, exchange_connector, slack_connector, salesforce_connector. Each is its own uv workspace member with its own pyproject.toml and tests/.
  • app-layer/packages/ additionally has clarify-bot and contracts.
  • Each addition has its own spec under .ai/specs/core-engine/, .ai/specs/app-layer/, or .ai/specs/integration/ (for example 2026-07-04-source-connector-framework.md and 2026-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.