Skip to content

Data-split routing

Architecture

Normative spec

This page documents .ai/specs/integration/2026-07-02-data-split-routing.md, Status: Draft (owners: app-layer lane; reviewers: storage lane). Code cited: app-layer/packages/contracts/src/entities.ts, app-layer/packages/contracts/src/service.ts, app-layer/packages/contracts/ROUTING.md, app-layer/packages/core/src/telha/module.ts, app-layer/packages/core/src/db/base.ts and orm.ts, app-layer/packages/core/src/auth/entities.ts, app-layer/packages/enterprise/src/field-crypto/entities.ts, and core-engine/src/grpc/token.rs.

Every persistent type in Telha lands in exactly one of three stores: PostgreSQL, Telha core, or blob storage. This page is the "what goes where" contract, the three-question test that decides it, and the soft cross-links that let a PostgreSQL row and a Telha record refer to each other without a cross-engine transaction.

Overview

The PRD's data-split table (PRD v8 §13.1) only becomes enforceable once it is a reviewable policy rather than a paragraph of prose. The spec's problem statement is specific about how routing mistakes happen: cognitive data (anything with temporal or provenance meaning) leaks into PostgreSQL and loses its history; commodity CRUD leaks into Telha and pays tritemporal write amplification for a row nobody will ever ask "what was this on date X" about. Both mistakes are expensive to reverse, because they change query patterns, not just a storage location.

The fix is a three-question test applied to every new entity or record type, at review time, not at runtime:

  1. Does history itself carry business meaning? Will anyone ask "what was true on date X" or "what changed between X and Y"? -> yes: Telha.
  2. Does it need graph traversal or temporal-semantic search? -> yes: Telha.
  3. Is it relational CRUD serving app mechanics (joins, uniqueness constraints, transactions with other commodity rows)? -> yes: PostgreSQL.

Anything binary or over 64 KB goes to blob storage, with a Telha or PostgreSQL record holding the reference. Ties, which the spec expects to be rare, default to PostgreSQL: promoting a row into Telha later is a defined ingest; demoting a Telha record back out is effectively forbidden, because it means discarding history.

Why this belongs in review, not in code

The spec rejects heuristic auto-routing (§9): routing errors are expensive and silent, while a human-reviewed three-question test is cheap and auditable. There is deliberately no per-entity "storage backend" config flag (§8); ambient switchability would defeat the policy's reviewability.

Design

The three stores and who writes to them

flowchart TB
    subgraph handler["Telha app module command handler"]
        direction TB
        C1["commodity write"]
        C2["cognitive write"]
        C3["binary/blob write"]
    end

    C1 -->|"em.persistAndFlush()"| PG[("PostgreSQL<br/>MikroORM, tenant-filtered")]
    C2 -->|"@telha/sdk<br/>GrpcTransport / UDS"| CORE[("Telha core<br/>32-byte tenant+org key prefix")]
    C3 -->|"pre-signed URL"| BLOB[("Blob store<br/>S3 / local")]

    PG -.->|"soft reference: contract_key column"| CORE
    CORE -.->|"soft reference: property holds PG row id"| PG
    CORE -->|"sources record: sha256 + uri"| BLOB

PostgreSQL is reached only through MikroORM's EntityManager, using the The Telha app tenant-entity convention: every entity extends TenantScoped (id, tenantId, organizationId, createdAt, updatedAt) and is declared through defineTenantEntity, which merges those base columns in and attaches a default-on tenant filter (app-layer/packages/core/src/db/base.ts). There is no code path to define a tenant entity without the filter.

Telha core is reached only through the telha DI module (app-layer/packages/core/src/telha/module.ts). Modules resolve a TelhaConnector and call forSession(session); the tenant and organization id ride from the authenticated session into a signed gRPC token (core-engine/src/grpc/token.rs), never from a field the request body supplies. This is the same sanctioned-path guarantee the field-encryption integration relies on: registerFieldCryptoModule re-registers the TELHA token with a connector whose transportWrap installs encrypt-on-write / decrypt-on-read, so a module resolving TELHA after that call is structurally incapable of writing plaintext classified fields.

Blob storage holds bytes over 64 KB or binary content; the owning record (a PostgreSQL row or a Telha sources-family record) holds the hash and URI, never the bytes.

No cross-engine foreign keys

PostgreSQL rows may hold Telha logical ids, and Telha properties may hold PostgreSQL row ids, but both are soft references, resolved at the application layer. The spec explicitly rejects distributed transactions (2PC) across PostgreSQL and RocksDB for this (§9): there is no driving invariant that justifies the complexity.

The worked example: contracts module

The spec's normative worked example (PR-028 commit 4) is implemented in app-layer/packages/contracts/ and its ROUTING.md is the review artifact every module is expected to copy:

  • Order -> PostgreSQL. Uniqueness on (tenantId, orderNumber), joins against future billing rows, transactional with other commodity tables. Its contractKey column is a soft reference to the Telha CONTRACT_VERSION timeline; there is no FK.
  • CONTRACT_VERSION (+ AMENDS relationships) -> Telha. "What did clause 4 say when we signed?" is the product; the entity needs AMENDS chain traversal and as-of queries.
  • Contract PDF -> blob store. A Telha record labeled CONTRACT_DOCUMENT holds {sha256, uri, bytes} and a DOCUMENTS relationship to the version it evidences; the bytes never enter PostgreSQL or Telha.
// app-layer/packages/contracts/src/service.ts (abridged)
async createOrderWithContract(em, telha, session, input) {
  const contractVersion = await this.recordContractVersion(telha, input.contract);

  const order = em.create(OrderEntity, {
    ...tenantColumns(session),
    orderNumber: input.orderNumber,
    contractKey: input.contract.contractKey, // soft reference, no FK
  });
  await em.persistAndFlush(order); // commodity -> PostgreSQL

  return { order, contractVersion };
}

Every module with persistent types is expected to carry its own ROUTING.md, answering the three questions per entity, next to its models. Module-generator templates scaffold the file with the three-question test pre-filled (spec §7). The success metric in the spec (§12) is file presence, spot-checked in CI by a lint; the content itself stays a human review item (§13, OQ-1: "existence-only in v1; content review stays human").

Data model

Normative routing table (spec §5), reproduced verbatim:

Data Target Why
Users, accounts, billing, orders, inventory counts, sessions, settings PostgreSQL Relational CRUD, constraints, joins; history is audit-log-grade at most
Contract/version timelines, risk evidence, audit trails, org-knowledge entities, anything answered "as of T" Telha Tritemporal queries, traversal, provenance
Embeddings + semantic search Telha (vectors CF + HNSW) Temporal-aware similarity
PDFs, images, attachments S3/local blob Blob semantics; Telha sources records hold hash + reference
Encrypted classified fields Wherever their record routes Routing is orthogonal to classification; encrypted fields are opaque either way

As-built entities confirm this split. PostgreSQL, via defineTenantEntity, currently hosts:

Entity Table Module Kind
Order orders contracts Commodity CRUD
AuthSession auth_sessions core/auth Session state
AuthLoginTxn auth_login_txns core/auth OIDC login-flow state
ClarifyDelivery clarify_deliveries clarify-bot Delivery/notification state
ToolInvocation ai_tool_invocations ai-assistant Audit log
FieldCryptoDek field_crypto_deks enterprise/field-crypto Wrapped key inventory

Telha core, via @telha/sdk records against the tritemporal engine, hosts (in the worked example) CONTRACT_VERSION nodes with AMENDS relationships and CONTRACT_DOCUMENT nodes with DOCUMENTS relationships. CONTRACT_DOCUMENT properties (sha256, uri, bytes) are the Telha-side half of the blob pattern: the bytes live in S3/local storage, never in RocksDB or PostgreSQL.

None of this is configurable at runtime

Spec §8 is explicit: routing is a design-time decision, checked in review. There is no "storage backend" flag per entity.

Algorithms & invariants

  • Tenant id must match on both sides of a soft reference. A PostgreSQL row's contractKey and a Telha record's contractKey property are matched at the application layer, inside the caller's own tenant scope; there is no mechanism that could resolve a soft reference across tenants, because TelhaConnector.forSession binds every call to the session's tenant and the PostgreSQL tenant filter binds every query the same way. The contracts integration test (app-layer/packages/contracts/tests/integration.test.ts) asserts this directly: an edge or soft reference tenant B constructs toward tenant A's ids resolves to nothing on both the Telha side (dangling edge, never traversed into tenant A's graph) and the PostgreSQL side (a query scoped to B's filter cannot return A's row).
  • Deletion does not cascade across the two stores automatically. Nothing in the spec or code ties a PostgreSQL row delete to a Telha tombstone write or vice versa; the cross-link is a soft reference maintained by application code, not an enforced invariant. (Telha's own in-engine tombstone cascade is a separate mechanism, documented on Tombstone cascade, and is orthogonal to this split.)
  • Promotion (PostgreSQL to Telha) is a defined ingest; demotion is forbidden by policy. Spec §11: rows becoming Telha records get sourceRecordTime set to the original row timestamps and Origin::Ingestion provenance (the same Origin enum used by ingestion workers generally, core-engine/src/model/mod.rs). Demoting a Telha record back to a commodity row loses history and requires a new spec to justify an exception.
  • Classification is orthogonal to routing. Whether a field is encrypted (see Field encryption) has no bearing on which store its owning record lives in; an encrypted field is opaque ciphertext in either engine.
  • Every module with persistent types carries a ROUTING.md. Enforced as a file-presence lint in CI (spec §12), not a content parser (§13, OQ-1).

Configuration

There is no runtime configuration surface for routing itself (spec §8): which store an entity lives in is fixed at review time in code and in ROUTING.md, not read from an environment variable or database flag.

The two stores each have their own connection configuration:

Surface Variable / default Notes
PostgreSQL DATABASE_URL, default postgres://telha:telha@127.0.0.1:5442/telha_app app-layer/packages/core/src/db/orm.ts; the compose default maps host port 5442 to the container's 5432.
Telha core (gRPC) TELHA_GRPC_ADDR Host:port, or unix:/path/to.sock on Unix hosts.
Telha core (token signing) TELHA_GRPC_TOKEN_KEY Shared 64-hex signing key, matching core's grpc.token_key.

telhaConfigFromEnv (app-layer/packages/core/src/telha/module.ts) throws if either Telha variable is unset, so a missing key fails loudly at boot rather than at first request.

As-built notes

Per §14 Changelog, the spec is at its initial state:

  • 2026-07-02, v0.1: initial draft for peer review. No revisions recorded yet.

The spec's own status is Draft; unlike the ratified storage-key layout it depends on (F1 tenant isolation), this policy has not yet been marked approved. Open question OQ-1 (should the ROUTING.md lint parse the three answers rather than check mere file existence) is explicitly deferred: existence-only in v1, with content review staying human.

What this page could not verify

The spec's Migration Path (§11) describes the PostgreSQL-to-Telha promotion ingest and forbids demotion, but no code implementing that promotion ingest was found in this repository as of this writing; it is documented here as designed, not as shipped.

  • Field encryption - the encryption boundary is orthogonal to routing; classified fields stay opaque wherever their record lives.
  • gRPC contracts - the signed-token transport that carries tenant scope from the app layer into Telha core.
  • SSO & authentication - the session and RBAC data that lives in PostgreSQL (AuthSession, AuthLoginTxn) rather than Telha.
  • Storage engine & key layout - the 32-byte tenant prefix that makes a Telha-side soft reference tenant-safe by construction.
  • Tombstone cascade - deletion semantics inside Telha, distinct from cross-store deletion (which this policy does not automate).