Skip to content

gRPC API

The internal AppLayer and Worker services

Two Tonic-based gRPC services carry process-internal traffic: AppLayerService for the app layer's read/write path, and WorkerService for the Python ingestion, clarify, and connector workers. Tenancy crosses the boundary as a signed token, never a raw header.

Normative sources

.ai/specs/integration/2026-07-02-grpc-contracts.md, proto/telha/v1/app_layer.proto, proto/telha/v1/worker.proto, and the server implementation in core-engine/src/grpc/mod.rs + core-engine/src/grpc/token.rs.

Conventions

Convention Value
Transport TCP everywhere; Unix domain socket also available (grpc.uds_path)
Tenant metadata key x-telha-token (required on every RPC)
Request-id metadata key x-telha-request-id (optional, propagated into tracing spans)
Temporal fields uint64 microseconds since epoch, matching storage encoding; absent interval end = open-ended
Properties / query bodies Canonical JSON, carried as bytes (same shape as REST)
Deadline ceiling grpc.max_deadline, default 30s (every RPC requires a client deadline; PollJobs is exempt, heartbeat-governed instead)
Idempotent RPCs Query, Snapshot, Compare are safe to retry
Non-idempotent RPCs CreateRecords/BatchWrite carry client idempotency keys

Services at a glance

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)]
    WS --> ENGINE

RPC reference

AppLayerService

RPC Request Response Idempotent Notes
CreateRecords CreateRecordsRequest CreateRecordsResponse no (idempotency key) REST: POST /v1/records
UpdateRecord UpdateRecordRequest UpdateRecordResponse no Full-snapshot update; REST: PUT /v1/records/{id}
Query QueryRequest QueryResponse yes REST: POST /v1/query
Snapshot SnapshotRequest SnapshotResponse yes REST: POST /v1/snapshot
Compare CompareRequest CompareResponse yes REST: POST /v1/compare
BatchWrite BatchWriteRequest BatchWriteResponse no (idempotency key) REST: POST /v1/relationships (+ inline records)

WorkerService

RPC Request Response Notes
PollJobs PollJobsRequest stream JobPayload Long-poll stream, exempt from the deadline ceiling; liveness is heartbeat-governed
SubmitIngestionResult IngestionResult SubmitResponse Supports multi-submit streaming (see below)
Heartbeat HeartbeatRequest HeartbeatResponse Extends leases for the listed job ids

Auth: signed tokens

Every RPC on both services requires the x-telha-token metadata key: base64url(msgpack{v, tid, oid, iat, aud} ‖ HMAC-SHA256 tag), keyed by grpc.token_key (a rotation set of 32-byte keys is accepted so operators can roll keys without downtime). TTL is 300 seconds with ±30 seconds clock skew. The server interceptor is the only code path that constructs a TenantScope, a raw tenant id anywhere else in the request is never trusted.

Audience constant Value Valid on Carries
AUDIENCE_APP "app" AppLayerService tenant + org scope
AUDIENCE_WORKER "worker" WorkerService worker id (no tenant scope; polls every namespace)
AUDIENCE_MCP "mcp" MCP sessions (not gRPC) tenant + org scope
AUDIENCE_CLARIFY "clarify" WorkerService (+ REST clarify routes) worker id (clarify-bot's polling identity)
AUDIENCE_CONNECTOR "connector" WorkerService (+ REST records/ingest routes) tenant + org scope and connector name
AUDIENCE_ADMIN "admin" REST /admin/* only tenant + org scope

Three distinct token shapes exist:

  • Tenant token ({v, tid, oid, iat, aud}): used for app audience on AppLayerService, and mcp audience on MCP sessions.
  • Worker-shaped service token ({v, wid, iat, aud}): used for worker and clarify audiences on WorkerService. No tenant scope, these identities poll across every tenant namespace.
  • Connector token ({v, tid, oid, wid, iat, aud}): used for connector audience. Carries both a tenant scope (a connector serves one tenant connection) and a name (the delete-authority namespace). Structurally distinct from the other two shapes, so cross-shape decoding fails safe (a worker-shaped token can never verify as a connector token or vice versa).

AppLayerService's interceptor accepts only app-audience tenant tokens. WorkerService's interceptor accepts worker/clarify-audience service tokens or connector-audience tokens; it tries the connector shape first (shape-distinct decode) and falls back to the worker-shaped verifier.

Token validation failures

All map to UNAUTHENTICATED:

Failure Cause
Malformed / tampered Not decodable, or the HMAC tag does not match any key in the rotation set
Expired iat outside [now - TTL - skew, now + skew]
Wrong audience e.g. a worker token presented to AppLayerService, or a connector token presented where app is expected

PollJobs leasing fence

The token audience alone selects which job-kind prefixes a WorkerService caller may lease, never a client-supplied filter:

Audience Leasable prefixes
worker ingest: only
clarify clarify: and notify:
connector sync: (tenant-scoped to the token's own tenant)

The formats field on PollJobsRequest narrows within the audience's allowed prefixes; an empty list means "every prefix the audience allows." Internal job kinds like embed (processed in-core, holding embedding credentials) are never leased to any external worker.

Field conventions

  • Intervals: message Interval { uint64 start = 1; optional uint64 end = 2; }, half-open [start, end); absent end is open-ended.
  • Property maps: bytes properties_json, a canonical JSON object, identical shape to the REST write DTOs, including the {"$bytes": "<base64>"} convention for classified byte values.
  • Query/compare bodies: QueryRequest.query_json, SnapshotRequest.query_json, and CompareRequest.compare_json all carry their JSON verbatim as bytes rather than a parallel proto grammar, the query-language spec and the compare request shape in REST API are the single grammar source for both REST and gRPC.
  • IDs: UUIDs travel as string.

CreateRecords / UpdateRecord

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
}

message CreateRecordsRequest {
  repeated RecordInput records = 1;
  string idempotency_key = 2;
}

RecordInput.id: nil/invalid → INVALID_ARGUMENT; an id with any existing version → ALREADY_EXISTS.

message UpdateRecordRequest {
  string id = 1;
  repeated string labels = 2;
  bytes properties_json = 3;             // full snapshot
  optional Interval valid_time = 4;      // absent = carried over
  optional uint64 source_record_time = 5;
}

UpdateRecord appends a new version of an existing record (the field-encryption rotation job re-encrypts forward this way). NOT_FOUND on unknown ids; FAILED_PRECONDITION on tombstones.

Query / Snapshot

message QueryRequest { bytes query_json = 1; }

message QueryResponse {
  repeated RecordVersion records = 1;
  repeated RelatedGroup related = 2;
  optional string next_cursor = 3;
  optional string trace_id = 4;
  ExecStats stats = 5;
  repeated double primary_scores = 6;   // present only on vector queries
}

Snapshot wraps the identical QueryResponse inside SnapshotResponse.result; the request pins atValidTime/atTxTime inside the JSON body exactly as REST's /v1/snapshot does.

Compare

message CompareRequest { bytes compare_json = 1; }   // {baseline, comparison, find?, where?, limit?, cursor?}
message CompareResponse { bytes delta_json = 1; }    // {added, removed, modified, stats, nextCursor}

Same grammar and delta shape as POST /v1/compare (see REST API), compare_json/delta_json are the identical JSON, just carried as bytes.

BatchWrite

message BatchWriteRequest {
  repeated RecordInput records = 1;
  repeated RelationshipStandalone relationships = 2;
  string idempotency_key = 3;
}
message BatchWriteResponse {
  repeated string record_ids = 1;
  repeated string relationship_ids = 2;
}

WorkerService: PollJobs, Heartbeat, SubmitIngestionResult

proto/telha/v1/worker.proto
message PollJobsRequest {
  string worker_id = 1;
  repeated string formats = 2;   // e.g. "pdf", "web", "csv", "json"
}

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
}

PollJobs returns a stream JobPayload; the worker's declared worker_id must match its token identity (PERMISSION_DENIED otherwise). attempts lets the clarify-bot map attempt N to its Nth ranked candidate, crash-safely (the queue owns the counter).

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

message SubmitResponse {
  bool accepted = 1;
  repeated string node_ids = 2;      // ids created by THIS submission
  uint64 lease_deadline = 3;         // 0 once the job completed
}

Multi-submit streaming (v0.2)

A worker may send several SubmitIngestionResult calls for one job. The chaining protocol lives inside result_json, not proto fields (so a core predating streaming rejects it loudly via deny_unknown_fields rather than silently completing on the first chunk):

  • seq (u32, default 0) and partial (bool, default false). seq=0, partial=false is single-shot, pre-streaming semantics.
  • partial=true appends canonical text, spans, nodes, and edges to one source version pinned at stream start. Span offsets are global byte offsets into the accumulated canonical text; edges reference nodes from earlier partials by fromId/toId.
  • The job completes on the first partial=false submission (an empty finalizer is legal). stats are read from the finalizing submission.
  • seq must equal the server continuation's expected value. Re-submitting the previously acknowledged seq is an idempotent replay (lost-ack recovery: the stored node_ids return, nothing is written). Any other mismatch returns ABORTED; the worker restarts the job with a clean stream.
  • Every accepted partial extends the job lease alongside its continuation checkpoint (lease_deadline in the response), so long streams do not depend solely on the heartbeat thread.
  • Message size: the WorkerService accepts up to 64 MiB per message (tonic's default is 4 MiB).

Sync-chain completion (connector audience)

For sync:-kind jobs, result_json instead carries a discriminated sync-result payload: {v: 1, sync: true, nextPayload?, delaySecs?, stats?}. Absence of nextPayload ends the chain (an operator re-seeds via telha connector run); delaySecs paces the successor job to the sync interval. This rides the same RPC with no proto change; a Submission sent for a sync: job (or vice versa) is rejected with INVALID_ARGUMENT, the kind/audience fence is checked both directions.

message HeartbeatRequest { string worker_id = 1; repeated string job_ids = 2; }
message HeartbeatResponse { repeated string extended = 1; uint64 lease_deadline = 2; }

Deadlines and cancellation

Every RPC (except the PollJobs stream) requires a client deadline; the server enforces a ceiling (grpc.max_deadline, default 30s). Cancellation propagates into engine scan budgets via cooperative checks every N rows, so an expired deadline halts an in-flight scan promptly rather than leaking resources.

Error mapping

Condition gRPC status
QUERY_INVALID (query-language validation failure) INVALID_ARGUMENT
Tenant/token failures (missing, malformed, expired, wrong audience) UNAUTHENTICATED
Scope violations (e.g. cross-tenant job access) PERMISSION_DENIED
Budget-partial results OK, with a partial flag on the response, never an error
Lease conflicts (stale seq, superseded lease) ABORTED
Engine internal failure INTERNAL, with a request id in server logs
Record not found NOT_FOUND
Record already exists ALREADY_EXISTS
Tombstoned record blocking an update FAILED_PRECONDITION
Unimplemented (e.g. vector.text without an embedding endpoint) UNIMPLEMENTED

See Error reference for the full REST-code-to-gRPC-status table.

REST-only surfaces (not available on gRPC)

The gRPC AppLayerService maps only a subset of the REST surface. Everything else must go through RestTransport:

REST-only operation
GET /v1/records/{id} (read)
GET /v1/records/{id}/history
DELETE /v1/records/{id}
GET /v1/records?externalId= (connector lookup)
GET /v1/schema
DELETE /v1/relationships/{id}
POST /v1/ingest and GET /v1/ingest/{job_id}
POST /v1/generate and GET /v1/trace/{query_id}
Clarifications and person-aliases routes

The TypeScript SDK's GrpcTransport throws UnimplementedError (code GRPC_UNMAPPED, status 501) for these rather than silently falling back to REST, see TypeScript SDK.

Evolution rules

Proto files under /proto are buf-linted with breaking checks against the base branch in CI: fields are only added, never renumbered or retyped; removed fields reserve their numbers; new methods land as UNIMPLEMENTED-capable; a breaking change ships as a new package (telha.v2) with a dual-serve window.

  • Query language - the JSON DSL carried verbatim as query_json/compare_json
  • REST API - the equivalent HTTP surface, including the REST-only endpoints
  • MCP tools - the agent-facing surface, authenticated the same way under audience mcp
  • TypeScript SDK - GrpcTransport and the token-signing helpers
  • Error reference - error codes and the gRPC status mapping