Skip to content

gRPC contracts

Architecture

Normative spec

grpc-contracts (Status: Draft) is the source of truth for wire design and evolution rules, alongside proto/telha/v1/app_layer.proto, proto/telha/v1/worker.proto, and the server implementation in core-engine/src/grpc/.

Two internal gRPC services, AppLayerService and WorkerService, carry the traffic between the Rust core and the two other codebases in the monorepo: the TypeScript app layer and the Python workers. This page covers why the wire is shaped the way it is; for request/response walkthroughs, see gRPC API.

Overview

AppLayerService is the app layer's read/write path onto the engine: creates, updates, queries, snapshots, temporal diffs, and batch writes. WorkerService is the ingestion/clarify/connector workers' path: long-poll job leasing, result submission, and heartbeat lease extension. Both are proto3, defined under /proto, generated into core-engine/src/grpc::proto via tonic-build, and consumed by three codebases that must never silently drift apart: Rust core, the TypeScript SDK's GrpcTransport, and Python's workers_common.

flowchart LR
    subgraph app["App layer"]
      SDK["@telha/sdk GrpcTransport"]
    end
    subgraph py["Python workers"]
      WK["workers_common"]
    end
    SDK -->|"x-telha-token, aud=app"| I1[tenant_interceptor]
    I1 --> ALS[AppLayerService]
    WK -->|"x-telha-token, aud=worker/clarify/connector"| I2[worker_interceptor]
    I2 --> WS[WorkerService]
    ALS --> ENGINE[(engine: nodes, edges, executor, jobs)]
    WS --> ENGINE

Both services share one AppState (the same engine handles REST uses), so a gRPC write and a REST write hit the same code paths, not a parallel stack.

Design

The proto files carry three deliberate wire-format decisions, all pinned in the file-header comments of app_layer.proto as normative.

Temporal fields are uint64 microseconds, not a Timestamp message

Every valid-time and transaction-time field on the wire is a plain uint64 count of microseconds since the Unix epoch, matching the storage encoding exactly:

proto/telha/v1/app_layer.proto
// Half-open interval [start, end); absent end = open-ended.
message Interval {
  uint64 start = 1;
  optional uint64 end = 2;
}

There is no google.protobuf.Timestamp or nanosecond-precision message type anywhere on the contract. Using the same integer microsecond encoding that core-engine/src/model/ and core-engine/src/temporal/ already use for on-disk keys means a value read from RocksDB, marshalled into RecordVersion.valid_time, and handed to the app layer never needs a unit conversion or a lossy timestamp-message round-trip in between. An open interval end is represented by the field's absence (optional uint64), matching the tritemporal model's half-open [start, end) convention (see The tritemporal model).

properties_json and query_json travel as JSON bytes, not typed messages

RecordInput.properties_json, RecordVersion.properties_json, QueryRequest.query_json, SnapshotRequest.query_json, and CompareRequest.compare_json are all bytes, carrying canonical JSON verbatim rather than a parallel proto message tree:

message QueryRequest {
  // The JSON query DSL, verbatim (query-language spec v1).
  bytes query_json = 1;
}

Single grammar source

The query DSL grammar is defined once, in the query language spec, and used identically by REST's POST /v1/query and gRPC's Query RPC. If the DSL were instead re-expressed as a proto message tree, the grammar would live in two places that could drift: a proto schema change and a DSL grammar change would need to land in lockstep forever. Carrying it as bytes means gRPC and REST validate against the exact same parser (core-engine/src/query/ast.rs), and a new DSL operator ships without a proto or buf change at all. The spec's alternatives-considered section records this explicitly: "Separate proto grammar for queries: rejected, two grammars drift; bytes-embedding keeps one truth." The same reasoning applies to compare_json/delta_json against the snapshot & compare grammar, and to properties_json against the REST write DTO shape, including the {"$bytes": "<base64>"} convention used by field encryption envelopes (Value::Bytes on write; still a plain base64 string on read).

Tenancy travels as a signed token, never a raw header

A raw tenant-id header would be a confused-deputy invitation: any process able to open the socket could claim any tenant. Instead, every RPC carries metadata key x-telha-token, and a server interceptor (tenant_interceptor / worker_interceptor in core-engine/src/grpc/mod.rs) is the only code path that constructs a TenantScope from it:

core-engine/src/grpc/mod.rs
fn scope_of<T>(request: &Request<T>) -> Result<TenantScope, Status> {
    request
        .extensions()
        .get::<TenantScope>()
        .copied()
        .ok_or_else(|| Status::internal("interceptor did not attach scope"))
}

Handlers never read a tenant id out of the request body; they read the scope the interceptor already attached to the request's extensions. See Tenancy & security for why this matters beyond gRPC.

Data model

Services

Service RPC Request → Response Purpose
AppLayerService CreateRecords CreateRecordsRequestCreateRecordsResponse Create records (+ inline relationships)
AppLayerService UpdateRecord UpdateRecordRequestUpdateRecordResponse Full-snapshot update, appends a new version
AppLayerService Query QueryRequestQueryResponse Execute the JSON query DSL
AppLayerService Snapshot SnapshotRequestSnapshotResponse Query pinned at a temporal coordinate
AppLayerService Compare CompareRequestCompareResponse Temporal delta between two coordinates (PR-053)
AppLayerService BatchWrite BatchWriteRequestBatchWriteResponse Records + standalone relationships in one call
WorkerService PollJobs PollJobsRequeststream JobPayload Long-poll job leasing
WorkerService SubmitIngestionResult IngestionResultSubmitResponse Apply (or stream) a job result
WorkerService Heartbeat HeartbeatRequestHeartbeatResponse Extend leases for listed job ids

AppLayerSvc and WorkerSvc in core-engine/src/grpc/mod.rs are the two implementing types; both hold an AppState and delegate to the same nodes/edges/executor/compare/jobs/ingestor handles REST uses.

Key message types

proto/telha/v1/app_layer.proto
message RecordInput {
  repeated string labels = 1;
  bytes properties_json = 2;
  optional Interval valid_time = 3;
  optional uint64 source_record_time = 4;
  repeated RelationshipInput relationships = 5;
  optional string id = 6;  // client-supplied logical id (PR-043)
}

message RecordVersion {
  string id = 1;
  repeated string labels = 2;
  bytes properties_json = 3;
  Interval valid_time = 4;
  Interval tx_time = 5;
  bool tombstone = 6;
}

RecordVersion is the wire shape every read RPC (Query, Snapshot) and every write RPC (CreateRecords, UpdateRecord, BatchWrite) returns; it carries both temporal intervals plus the tombstone flag, mirroring the version record model rather than exposing a REST-flavored partial view.

proto/telha/v1/worker.proto
message JobPayload {
  string job_id = 1;
  string tenant_id = 2;
  string organization_id = 3;
  string kind = 4;
  bytes payload_json = 5;
  uint64 lease_deadline = 6;
  uint32 attempts = 7;  // 0-based prior failures at lease time (PR-063)
}

message IngestionResult {
  string job_id = 1;
  bool success = 2;
  bytes result_json = 3;
  string failure_reason = 4;
  bool permanent = 5;
}

JobPayload is streamed back from PollJobs; payload_json and result_json are, like properties_json/query_json, JSON bytes rather than typed sub-messages, for the same single-grammar reason: the job payload shape is defined by the ingestion & provenance spec, not by /proto.

Algorithms & invariants

Additive evolution rules

The spec's migration path (§11) and every proto file's header comment state the same buf-enforced rule: fields are only added, never renumbered or retyped, and a field's number is reserved (not reused) if removed.

What's allowed vs. what breaks compatibility

Allowed (additive, safe across the fleet):

  • A new optional field on an existing message (e.g. RecordInput.id landed as field 6 in PR-043; JobPayload.attempts landed as field 7 in PR-063).
  • A new enum value.
  • A new RPC method (must be added in a way that lets an old server answer UNIMPLEMENTED rather than fail to compile against).
  • A new field inside a JSON-bytes payload (result_json, query_json, etc.) gated by deny_unknown_fields on the consuming struct, so an old server rejects it loudly rather than silently ignoring it (see below).

Breaking (requires a new package version, telha.v2, with a dual-serve window):

  • Renumbering or retyping an existing field.
  • Removing a field without reserving its number.
  • Changing an RPC's request/response message type.

buf lints every change under /proto against the base branch in CI with breaking checks, so a violation fails the build rather than shipping and drifting the three consuming codebases apart.

deny_unknown_fields on streamed submissions

SubmitIngestionResult supports multi-submit streaming: a worker may send several calls for one job, each carrying seq/partial fields inside result_json, not as proto fields on IngestionResult. This is intentional: putting the streaming protocol in JSON rather than in .proto means a core binary that predates streaming support rejects a streamed submission loudly (a hard decode error) instead of silently treating the first partial chunk as a complete, final result and corrupting the record.

The JSON-side guard is #[serde(deny_unknown_fields)] on the structs that decode result_json, for example Submission in core-engine/src/ingest/mod.rs:

core-engine/src/ingest/mod.rs
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Submission {
    pub v: u32,
    // ...
    pub seq: u32,
    pub partial: bool,
    // ...
}

The same guard is applied to the connector sync-chain payload that also rides result_json for sync:-kind jobs:

core-engine/src/grpc/mod.rs
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct SyncResult {
    v: u32,
    #[serde(default)]
    sync: bool,
    #[serde(default)]
    next_payload: Option<serde_json::Value>,
    #[serde(default)]
    delay_secs: Option<u64>,
    #[serde(default)]
    stats: Option<serde_json::Value>,
}

Why this guards correctness, not just schema hygiene

Without deny_unknown_fields, an older serde struct would silently drop fields it doesn't recognize, and an IngestionResult meant to be the second of three partials could be misread as a single complete submission. deny_unknown_fields turns that into a hard decode error instead, which the handler maps to a job failure rather than a silently wrong write. The same posture is applied to ambiguities on Submission (gated behind the clarify capability flag): an unexpected field is rejected rather than ignored.

Streaming semantics, pinned by the spec (§5) and mirrored in the proto comments:

sequenceDiagram
    autonumber
    participant Wk as Worker
    participant Core as WorkerService

    Wk->>Core: SubmitIngestionResult(seq=0, partial=true)
    Core-->>Wk: SubmitResponse(node_ids, lease_deadline>0)
    Wk->>Core: SubmitIngestionResult(seq=1, partial=true)
    Note over Core: appends to the ONE source version<br/>pinned at stream start,<br/>lease extended
    Core-->>Wk: SubmitResponse(node_ids, lease_deadline>0)
    Wk->>Core: SubmitIngestionResult(seq=2, partial=false)
    Note over Core: job completes,<br/>stats read from this submission
    Core-->>Wk: SubmitResponse(node_ids, lease_deadline=0)

Sequencing rule: seq must equal the server continuation's expected value. Re-submitting the previously acknowledged seq is treated as an idempotent replay (lost-ack recovery: the stored node_ids are returned, nothing is written); anything else returns ABORTED, and the worker abandons the job so a re-lease starts a clean stream. Span offsets inside a streamed submission are global byte offsets into the accumulated canonical text, and edges reference nodes from earlier partials by fromId/toId using the node_ids returned in each SubmitResponse.

Token shapes

Three structurally distinct token payloads exist, all msgpack-encoded and HMAC-SHA256-signed (base64url(msgpack{...} ‖ tag)), verified in core-engine/src/grpc/token.rs:

Shape Fields Used for
Tenant token {v, tid, oid, iat, aud} app audience on AppLayerService
Worker-shaped service token {v, wid, iat, aud} worker / clarify audiences on WorkerService (no tenant scope)
Connector token {v, tid, oid, wid, iat, aud} connector audience on WorkerService (tenant scope and connector name)
core-engine/src/grpc/token.rs
struct Payload {
    v: u32,
    tid: Uuid,
    oid: Uuid,
    iat: u64, // seconds since epoch
    aud: String,
}

TTL is 300 seconds (TOKEN_TTL_SECS) with ±30 seconds clock skew (CLOCK_SKEW_SECS); a valid iat must satisfy iat <= now + skew && now <= iat + TTL + skew. The three shapes are structurally distinct on purpose: a worker-shaped payload has no tid/oid and fails to decode as a connector token (and vice versa), so cross-shape decoding fails safe rather than silently accepting a token minted for a different purpose. WorkerService's interceptor tries the connector shape first, then falls back to the worker-shaped verifier.

Audience selects leasable job-kind prefixes, never a client filter

The token's aud claim is the only thing that decides which job-kind prefixes a WorkerService caller may lease: workeringest:, clarifyclarify: and notify:, connectorsync: (tenant-scoped to the token's own tenant). PollJobsRequest.formats only narrows within the audience's allowed prefixes; it can never widen past them.

Message size limits

WorkerService is built with an explicit decoding limit, well above tonic's 4 MiB default, because a streamed ingestion submission carries a full parse result (or a chunk of one) as JSON:

core-engine/src/grpc/mod.rs
let worker_service = tonic::service::interceptor::InterceptedService::new(
    proto::worker_service_server::WorkerServiceServer::new(WorkerSvc::new(state))
        .max_decoding_message_size(64 * 1024 * 1024),
    worker_interceptor(keys),
);
Limit Value Enforced by
WorkerService incoming message size 64 MiB max_decoding_message_size on WorkerServiceServer
Default (unconfigured) tonic message size 4 MiB tonic's built-in default, still in effect for AppLayerService

What happens when exceeded

A message over the configured limit is rejected by tonic's own decoding path before it reaches the handler; the spec directs workers to keep each partial well below 64 MiB (one chunk per partial, tabular-sized) so a legitimately large document is submitted in a stream of small partials rather than one oversized message.

Configuration

GrpcConfig (core-engine/src/config.rs) holds the operator-facing fields actually implemented as of this writing:

Field Default Purpose
grpc.addr 127.0.0.1:7626 TCP socket address for the gRPC listener
grpc.uds_path unset Unix domain socket path; preferred for same-host app-layer traffic on Unix, ignored with a warning on Windows
grpc.token_key unset Hex-encoded 32-byte HMAC key validating internal tenant tokens; gRPC stays disabled until this is set

Config validation rejects rest.addr == grpc.addr (both listeners must bind distinct addresses), and warns if grpc.uds_path is set on Windows (no UDS support there, so the TCP listener is used instead). clarify.enabled without grpc.token_key set is also rejected at config-validation time, since the clarify loop depends on the same signed-token machinery.

Spec vs. as-built naming

The spec (§8) additionally names grpc.tcp_addr and grpc.max_deadline as configuration surface, with the deadline ceiling defaulting to 30 seconds and PollJobs exempted (heartbeat-governed instead). The as-built GrpcConfig struct in core-engine/src/config.rs exposes addr (not tcp_addr) and does not yet expose a max_deadline field; treat the deadline-ceiling behavior as spec-level pending a §14 changelog entry that reconciles it with the code.

A rotation set of HMAC keys (Vec<[u8; 32]>) is accepted by both interceptors, so operators can roll grpc.token_key without downtime; the resolved OQ-1 (§13) fixed these keys as an in-process keyset, not KMS-held, so token verification stays off the KMS availability path.

As-built notes

Per the spec's changelog (§14), the wire has moved considerably past the v0.1 draft while remaining fully additive:

  • v0.2 (2026-07-03): multi-submit streaming added to SubmitIngestionResult. seq/partial landed in the Submission JSON (not proto); SubmitResponse gained additive proto fields node_ids (2) and lease_deadline (3); worker-service decode limit raised to 64 MiB.
  • v0.3 (2026-07-03): PollJobs scope rule (PR-038) - an empty formats list now means "every ingest:* kind," not literally every kind; internal kinds like embed are never leased externally.
  • v0.4 (2026-07-04): PR-043 additive fields - RecordInput.id (field 6) and the new UpdateRecord RPC; OQ-1 resolved (token keys join the field-crypto key inventory but are never KMS-held). Write-DTO property JSON gained the {"$bytes": "<base64>"} convention for classified byte values.
  • v0.5 (2026-07-04): Compare activated (PR-053) with no wire change - the RPC that previously existed as a placeholder now executes against the snapshot-compare engine.
  • v0.6 (2026-07-05): WorkerService audience widening (PR-060) - the worker interceptor accepts worker or clarify audience tokens; the audience alone selects leasable prefixes. SubmitIngestionResult now rejects non-ingest: kinds from worker audience with INVALID_ARGUMENT. No .proto change.
  • v0.7 (2026-07-05): connector audience and delivery attempts (PR-065/PR-063) - the third token shape (connector), JobPayload.attempts as additive field 7, and the sync-chain completion protocol riding result_json with no proto change.

Every one of these landed as an additive field, a new RPC, or a JSON-side convention change, none as a renumber/retype - consistent with the evolution rules above holding since PR-025.

  • gRPC API - day-to-day usage: the full RPC reference, auth failure table, and how to call each service
  • Field encryption - the envelope format carried inside properties_json via the {"$bytes": ...} convention
  • Data-split routing - how requests are routed once past the gRPC boundary
  • Version record model - the storage layout RecordVersion mirrors on the wire
  • Query language - the grammar carried verbatim in query_json/compare_json
  • Error reference - the gRPC status mapping table