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:
// 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:
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 | CreateRecordsRequest → CreateRecordsResponse | Create records (+ inline relationships) |
AppLayerService | UpdateRecord | UpdateRecordRequest → UpdateRecordResponse | Full-snapshot update, appends a new version |
AppLayerService | Query | QueryRequest → QueryResponse | Execute the JSON query DSL |
AppLayerService | Snapshot | SnapshotRequest → SnapshotResponse | Query pinned at a temporal coordinate |
AppLayerService | Compare | CompareRequest → CompareResponse | Temporal delta between two coordinates (PR-053) |
AppLayerService | BatchWrite | BatchWriteRequest → BatchWriteResponse | Records + standalone relationships in one call |
WorkerService | PollJobs | PollJobsRequest → stream JobPayload | Long-poll job leasing |
WorkerService | SubmitIngestionResult | IngestionResult → SubmitResponse | Apply (or stream) a job result |
WorkerService | Heartbeat | HeartbeatRequest → HeartbeatResponse | 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¶
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.
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
optionalfield on an existing message (e.g.RecordInput.idlanded as field 6 in PR-043;JobPayload.attemptslanded 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
UNIMPLEMENTEDrather than fail to compile against). - A new field inside a JSON-bytes payload (
result_json,query_json, etc.) gated bydeny_unknown_fieldson 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:
#[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:
#[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) |
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: worker → ingest:, clarify → clarify: and notify:, connector → sync: (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:
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/partiallanded in the Submission JSON (not proto);SubmitResponsegained additive proto fieldsnode_ids(2) andlease_deadline(3); worker-service decode limit raised to 64 MiB. - v0.3 (2026-07-03):
PollJobsscope rule (PR-038) - an emptyformatslist now means "everyingest:*kind," not literally every kind; internal kinds likeembedare never leased externally. - v0.4 (2026-07-04): PR-043 additive fields -
RecordInput.id(field 6) and the newUpdateRecordRPC; 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):
Compareactivated (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):
WorkerServiceaudience widening (PR-060) - the worker interceptor acceptsworkerorclarifyaudience tokens; the audience alone selects leasable prefixes.SubmitIngestionResultnow rejects non-ingest:kinds from worker audience withINVALID_ARGUMENT. No.protochange. - v0.7 (2026-07-05): connector audience and delivery attempts (PR-065/PR-063) - the third token shape (connector),
JobPayload.attemptsas additive field 7, and the sync-chain completion protocol ridingresult_jsonwith 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.
Related¶
- 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_jsonvia the{"$bytes": ...}convention - Data-split routing - how requests are routed once past the gRPC boundary
- Version record model - the storage layout
RecordVersionmirrors on the wire - Query language - the grammar carried verbatim in
query_json/compare_json - Error reference - the gRPC status mapping table