Spec: gRPC Contracts — Services, Tenancy Metadata & Evolution Rules¶
File: .ai/specs/integration/2026-07-02-grpc-contracts.md Status: Draft Owners: API lane; reviewers: app-layer lane (consumer), workers lane (consumer) Related: PR-025/026/031; composite-key-layout spec (TenantScope); query-language spec (Query message mirrors the DSL)
1. Overview¶
Defines the two internal gRPC services (AppLayerService, WorkerService), the tenancy-propagation metadata protocol, transport selection (UDS vs TCP), deadline/cancellation conventions, error mapping, and the buf-enforced evolution rules for everything under /proto.
2. Problem Statement¶
The gRPC boundary is where tenancy crosses processes. A raw tenant-id header is a confused-deputy invitation: any process that can open the socket could claim any tenant. Separately, proto contracts consumed by three codebases (Rust core, TS app layer, Python workers) drift catastrophically without mechanical breaking-change enforcement, and undefined deadline semantics turn slow queries into resource leaks.
3. Proposed Solution¶
Tenant identity travels as gRPC metadata carrying a compact signed internal token (HMAC-SHA256 over tenant_id ‖ org_id ‖ issued_at ‖ audience, key from shared config/KMS, TTL 5 min, clock-skew ±30s) — validated by a server interceptor that alone constructs the TenantScope. Worker credentials are separate per-worker tokens with audience=worker, valid only for WorkerService. Transport: UDS preferred (config grpc.uds_path), localhost TCP fallback; identical service surface on both. All proto lives in /proto, buf-linted with breaking checks against the base branch in CI.
4. Architecture¶
app layer (@telha/sdk GrpcTransport) ──UDS──▶ interceptor(validate token, build TenantScope) ─▶ AppLayerService ─▶ engine
python workers (workers_common) ──UDS──▶ interceptor(audience=worker) ─▶ WorkerService ─▶ jobs/engine
5. Data Models¶
Metadata keys: x-telha-token (required, signed), x-telha-request-id (optional, propagated into tracing spans). Token payload (msgpack, base64): {v:1, tid, oid, iat, aud: "app"|"worker", wid?}. Services:
service AppLayerService {
rpc CreateRecords(CreateRecordsRequest) returns (CreateRecordsResponse);
rpc Query(QueryRequest) returns (QueryResponse); // carries the JSON DSL verbatim (bytes) + parsed coordinate
rpc Snapshot(SnapshotRequest) returns (SnapshotResponse);
rpc Compare(CompareRequest) returns (CompareResponse); // temporal delta engine (PR-053)
rpc BatchWrite(BatchWriteRequest) returns (BatchWriteResponse);
}
service WorkerService {
rpc PollJobs(PollJobsRequest) returns (stream JobPayload); // long-poll stream; capability filter in request
rpc SubmitIngestionResult(IngestionResult) returns (SubmitResponse);
rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse); // extends leases for listed job ids
}
Field semantics pinned in proto comments (normative); temporal fields are uint64 µs matching storage encoding; QueryRequest embeds the DSL as bytes to avoid maintaining a parallel proto grammar (single source of truth = query-language spec).
Multi-submit streaming (v0.2). A worker may send several SubmitIngestionResult messages for one job. The chain protocol lives in the submission JSON (result_json), not proto fields — a core predating this protocol then rejects streamed submissions loudly (deny_unknown_fields) instead of silently completing the job on the first chunk:
seq(u32, default 0) andpartial(bool, default false) on the Submission JSON.seq=0, partial=false= single-shot (pre-streaming semantics, submit-time dedup included).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; spans may reference any already-streamed byte. Edges reference nodes from earlier partials by id (fromId/toId).- The job completes on the first
partial=falsesubmission (an empty finalizer — no text/nodes — is legal; the consumer sends one when a handler generator exhausts without an explicit final).statsare read from the finalizing submission. SubmitResponsegainednode_ids(ids created by THIS submission, in submission order — the cross-partial edge currency) andlease_deadline(µs; every accepted partial extends the job lease alongside its continuation checkpoint, so long streams don't depend on the heartbeat thread; 0 once the job completed).- Sequencing:
seqmust equal the server continuation's expected value. Re-submitting the previously acknowledged seq is an idempotent replay (lost-ack recovery — the storednode_idsare returned, nothing is written). Anything else → ABORTED; the worker fails/abandons the job and the retry starts a clean stream (a re-lease bumps the lease generation, which invalidates any stored continuation). - Message sizing: the worker service accepts up to 64 MiB per message (over tonic's 4 MiB default); workers should size partials well below that (tabular: one chunk per partial).
6. API Contracts¶
Deadlines: every RPC requires a client deadline; server enforces ceiling (grpc.max_deadline, default 30s; PollJobs stream exempt, heartbeat-governed). Cancellation propagates into engine scan budgets (cooperative checks per N rows). Error mapping (normative): QUERY_INVALID→INVALID_ARGUMENT, tenant/token failures→UNAUTHENTICATED, scope violations→PERMISSION_DENIED, budget-partial→OK with partial flag (never an error), lease conflicts→ABORTED, engine internal→INTERNAL with request_id. Retry guidance in proto comments: idempotent RPCs (Query/Snapshot/Compare) safe to retry; CreateRecords/BatchWrite carry client-generated idempotency keys.
7. UI/UX¶
Not applicable (process-internal). Debug: grpcurl recipes documented in /proto/README for local poking over TCP fallback.
8. Configuration¶
grpc.uds_path, grpc.tcp_addr, grpc.max_deadline, grpc.token_key (or KMS ref), grpc.token_ttl (300s). Audience strings are fixed constants.
9. Alternatives Considered¶
Raw tenant header trusted on UDS ("it's local") — rejected: any same-host process compromise becomes full cross-tenant access; signing is cheap. mTLS between local processes — rejected for v1: cert lifecycle overhead exceeds threat delta over signed tokens on a root-owned socket; revisit when processes leave the host (Phase 4). JSON-over-HTTP internally — rejected: loses streaming (PollJobs), deadlines, and typed contracts. Separate proto grammar for queries — rejected: two grammars drift; bytes-embedding keeps one truth.
10. Implementation Approach¶
PR-025: this spec → proto files → tonic-build + buf CI (lint + breaking). PR-026: interceptor + AppLayerService + UDS/TCP + deadline tests. PR-031: WorkerService + worker-audience auth. Token signing helper shipped in both @telha/sdk and workers_common.
11. Migration Path¶
Greenfield. Evolution rules (buf-enforced): fields are only added, never renumbered/retyped; reserved ranges on removal; new methods land as UNIMPLEMENTED-capable; breaking changes require a new package version (telha.v2) with dual-serve window.
12. Success Metrics¶
buf lint + breaking green in CI from PR-025 onward. Auth matrix: missing/expired/tampered/wrong-audience tokens all rejected with UNAUTHENTICATED (integration-tested). UDS and TCP round-trip parity test. Deadline test: expired deadline cancels an in-flight scan within one budget-check interval. Cross-language contract test: TS and Python clients exercise every RPC against the live binary in CI.
13. Open Questions¶
- OQ-1: Resolved at the field-encryption security review (2026-07-04, its OQ-1) — token signing keys join the field-crypto key inventory and rotation story as a distinct non-tenant key class, but are never KMS-held (hot-path HMAC; KMS must not become an availability dependency of auth). The interceptor keeps the existing in-process keyset (PR-026) with a dual-key acceptance window during rotation.
14. Changelog¶
- 2026-07-02 — v0.1: initial draft for peer review.
- 2026-07-05 — v0.6: WorkerService audience widening (PR-060; temporal-clarification spec §6). The worker interceptor accepts worker-shaped service tokens with audience
"worker"OR"clarify"; the token audience is the ONLY selector of leasable kind prefixes (worker→ingest:,clarify→clarify:), and the PollJobsformatsfilter can only narrow within the audience's prefix — the PR-038 embed-fence posture generalised. SubmitIngestionResult now rejects non-ingest:job kinds with INVALID_ARGUMENT (a clarify lease can never funnel a Submission into the apply path).ambiguitiesrides in the Submission JSON likeseq/partial(deny_unknown_fields keeps old cores loud), gated on theclarifycapability flag in job payload options. No .proto change. - 2026-07-04 — v0.5: Compare ACTIVATED (PR-053) — no wire change; the placeholder RPC now executes.
compare_jsoncarries the snapshot-compare spec §6 request verbatim (single grammar source, same posture as Query);delta_jsonmirrors its §5 delta page. Malformed/invalid requests → INVALID_ARGUMENT with the JSON pointer; bad cursors → INVALID_ARGUMENT. Idempotent-retry guidance for Compare is now live rather than aspirational. - 2026-07-04 — v0.4: PR-043 additions on AppLayerService (both additive), plus OQ-1 resolved (token keys: field-crypto inventory yes, KMS no). (1)
RecordInput.id(optional string, field 6): client-supplied logical id for creates — field-encryption spec §5 needs the id at encrypt time (the envelope AAD binds it), so classified-label creates send app-generated UUIDv7 ids. Nil/invalid → INVALID_ARGUMENT; an id with ANY existing version → ALREADY_EXISTS; tenant-scoped as always. (2)UpdateRecordRPC (full-snapshot update appending a new version of an existing record): the rotation job re-encrypts forward by writing new versions, which previously had no wire surface. NOT_FOUND on unknown ids, FAILED_PRECONDITION on tombstones. REST mirrors:idon POST /v1/records inputs; PUT /v1/records/:id; SDKrecords.update. (3) Write-DTO property JSON gained the top-level{"$bytes": "<base64>"}→Value::Bytesconvention (records/relationships writes ONLY — query operands and ingested documents keep plain JSON semantics) so ciphertext envelopes store as raw magic-prefixed bytes;Value::Bytesstill renders as a plain base64 string on reads. -
2026-07-03 — v0.3: PollJobs scope rule (PR-038): an empty
formatslist now means "everyingest:*kind" rather than "every kind" — internal job kinds (embed, processed by the in-core runner which holds the embedding credentials) are never leased to external workers. -
2026-07-03 — v0.2: multi-submit streaming on WorkerService (§5). Submission JSON gained
seq/partial; SubmitResponse gainednode_ids+lease_deadline(additive proto fields 2/3); out-of-sync seq → ABORTED; partial submits extend the lease server-side; worker-service decode limit raised to 64 MiB. Storage-side semantics in the ingestion-provenance spec v0.3. - 2026-07-05 — v0.7: connector audience + delivery attempts (PR-065/PR-063). (1) THIRD token shape: connector tokens carry
{v, tid, oid, wid, iat, aud}(audience"connector"), tenant-scoped AND named — the worker interceptor accepts them (shape-distinct decode; worker-shaped tokens fail it fail-safe), PollJobs maps the audience to thesync:prefix with TENANT-SCOPED leasing, and SubmitIngestionResult acceptssync:kinds from connector audience only, completing the window and enqueueing the successor from a discriminated sync-result JSON{v, sync: true, nextPayload?, delaySecs?, stats?}(rides result_json; no proto change). Ingest submissions now REQUIRE worker audience (fence tightened both directions). (2)JobPayloadgained additive field 7attempts(0-based prior failures at lease time) — the clarify-bot's crash-safe candidate selector (attempt N asks ranking[N]); python stubs regenerated, other workers ignore it.