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 forappaudience onAppLayerService, andmcpaudience on MCP sessions. - Worker-shaped service token (
{v, wid, iat, aud}): used forworkerandclarifyaudiences onWorkerService. No tenant scope, these identities poll across every tenant namespace. - Connector token (
{v, tid, oid, wid, iat, aud}): used forconnectoraudience. 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); absentendis 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, andCompareRequest.compare_jsonall carry their JSON verbatim asbytesrather 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¶
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¶
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) andpartial(bool, default false).seq=0, partial=falseis single-shot, pre-streaming semantics.partial=trueappends 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 byfromId/toId.- The job completes on the first
partial=falsesubmission (an empty finalizer is legal).statsare read from the finalizing submission. seqmust equal the server continuation's expected value. Re-submitting the previously acknowledgedseqis an idempotent replay (lost-ack recovery: the storednode_idsreturn, 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_deadlinein the response), so long streams do not depend solely on the heartbeat thread. - Message size: the
WorkerServiceaccepts 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.
Related¶
- 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 -
GrpcTransportand the token-signing helpers - Error reference - error codes and the gRPC status mapping