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:
- Does history itself carry business meaning? Will anyone ask "what was true on date X" or "what changed between X and Y"? -> yes: Telha.
- Does it need graph traversal or temporal-semantic search? -> yes: Telha.
- 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. ItscontractKeycolumn is a soft reference to the TelhaCONTRACT_VERSIONtimeline; there is no FK.CONTRACT_VERSION(+AMENDSrelationships) -> Telha. "What did clause 4 say when we signed?" is the product; the entity needsAMENDSchain traversal and as-of queries.- Contract PDF -> blob store. A Telha record labeled
CONTRACT_DOCUMENTholds{sha256, uri, bytes}and aDOCUMENTSrelationship 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
contractKeyand a Telha record'scontractKeyproperty 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, becauseTelhaConnector.forSessionbinds 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
sourceRecordTimeset to the original row timestamps andOrigin::Ingestionprovenance (the sameOriginenum 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.
Related¶
- 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).