Skip to content

Format workers

Architecture

Normative spec

This page documents five worker specs, all Status: Draft except email and code (Status: Approved):

  • .ai/specs/core-engine/2026-07-02-docling-projection.md (rich documents: PDF/DOCX/PPTX)
  • .ai/specs/core-engine/2026-07-02-tabular-projection.md (CSV/XLSX)
  • .ai/specs/core-engine/2026-07-02-web-ingestion.md (web pages and bounded crawl)
  • .ai/specs/core-engine/2026-07-02-email-projection.md (.eml/.msg)
  • .ai/specs/core-engine/2026-07-02-code-projection.md (source code via tree-sitter)

Each spec's own §14 changelog is the as-built record; this page reconciles all five against the code in workers/.

Telha's core binary never parses a PDF, a spreadsheet, or a web page itself. It hands a job to a Python format worker over gRPC and receives back a graph projection: nodes, edges, spans, and canonical text. This page documents the worker protocol shared by every format, the IngestionResultBuilder that makes that protocol hard to misuse, and the five projections built on top of it.

Overview & purpose

Ingestion in Telha is deliberately split across a process boundary. The core engine (Rust, core-engine/) owns storage, temporal semantics, and the job queue; format-specific parsing (docling, polars, Playwright, tree-sitter, extract-msg) lives in Python workers under workers/, each a small package built on a shared harness, workers_common. A worker's only contract with core is the WorkerService gRPC surface: poll for work, submit a projection or a typed failure, heartbeat to keep a lease alive.

This split exists because the parsing ecosystems Telha depends on (docling's torch-backed stack, Playwright's Chromium, tree-sitter grammars) are heavyweight, version-churny, and not something a storage-and-temporal-semantics binary should link against. Keeping them out-of-process also means a worker crash or an OOM on a 500MB PDF never takes down the engine; the job simply re-enters the lease cycle.

Design

The WorkerService contract

service WorkerService {
  rpc PollJobs(PollJobsRequest) returns (stream JobPayload);
  rpc SubmitIngestionResult(IngestionResult) returns (SubmitResponse);
  rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse);
}

PollJobs(worker_id, formats[]) is a long-poll stream, exempt from the normal request deadline; a worker declares the formats it can handle ("pdf", "web", "csv", …) and core routes matching jobs to it. Each JobPayload carries job_id, tenant_id, organization_id, kind, a JSON payload_json, a lease_deadline (µs), and attempts (the 0-based prior-failure counter). SubmitIngestionResult posts an IngestionResult{job_id, success, result_json, failure_reason, permanent} and gets back a SubmitResponse{accepted, node_ids, lease_deadline}; the returned node_ids are the logical ids core assigned to the nodes in that submission, in submission order, letting a streaming worker wire cross-partial edges. A background Heartbeat(worker_id, job_ids[]) extends leases for in-flight jobs every HEARTBEAT_INTERVAL_SECS = 60.

sequenceDiagram
    participant W as Worker (workers_common.WorkerConsumer)
    participant C as Core (WorkerService)
    W->>C: PollJobs(worker_id, formats)
    C-->>W: stream JobPayload
    loop every 60s while jobs in flight
        W->>C: Heartbeat(worker_id, job_ids)
        C-->>W: extended job_ids, new lease_deadline
    end
    W->>C: SubmitIngestionResult(result_json)
    C-->>W: SubmitResponse(accepted, node_ids, lease_deadline)

workers_common: one builder, five projections

Every worker constructs its submission through IngestionResultBuilder (workers_common/result.py) rather than hand-assembling JSON. The builder's job is to make the wire protocol hard to get wrong:

  • add_span_text(text, kind=) / add_text(text) append canonical text and, for the former, register a byte-range span.
  • set_canonical_text(text) + add_span_range(start, end, kind=) is the alternative for workers that already know their full text up front (tabular, code) and just need to carve spans out of it.
  • add_node(labels, properties, spans=, valid_time=, id=) adds a node referencing previously added spans; an optional deterministic id lets core upsert instead of version-churn (email PERSON, code FILE).
  • attach_span(node_ref, span_ref) links a span after the node exists.
  • add_relationship(edge_type, from_ref=|from_id=, to_ref=|to_id=, properties=) adds an edge, addressing either a node in this submission (*_ref) or an existing node by id (*_id, needed for cross-job targets like email's IN_REPLY_TO).
  • set_content_hash(hex) and add_child_job(format, name, options=, content=) support the fetch/fan-out formats (web recrawl dedup, email attachment routing, code manifest fan-out).
  • add_ambiguity(node_ref, kind, candidates, detector_confidence=, applied_candidate_confidence=, severity_hint=) records a detected temporal ambiguity, gated on options.clarify.
  • build() / to_json() produce the submission dict / UTF-8 JSON bytes for IngestionResult.result_json.

All text passes through normalize_canonical: NFC + LF-only line endings, applied once in the builder so byte offsets are stable across every worker and every platform. This is what lets a citation computed by one worker resolve identically regardless of which OS produced the source bytes.

builder = IngestionResultBuilder()
span = builder.add_span_text("Alpha owes Beta.", kind="body")
node = builder.add_node(["CONTRACT"], {"name": "Alpha"}, spans=[span])
payload = builder.build()

For streaming workers (tabular, code, large web crawls), a builder is constructed per partial with base_offset set to the previous partial's byte_length: span offsets stay global into the accumulated canonical text even though each partial's canonicalText carries only the newly appended slice, so partials concatenate byte-exactly server-side.

The consumer loop

WorkerConsumer.run() (workers_common/consumer.py) is the shared poll/dispatch/heartbeat loop every worker package runs unmodified. A handler is either a plain function returning bytes (single-shot submission) or a generator yielding submission dicts (ids = yield builder.build()) for multi-submit streaming: the consumer assigns seq, defaults each yielded dict to partial: True unless it explicitly sets partial: False (which finalizes the job), and feeds each partial's server-assigned node_ids back into the generator via send(). If the generator exhausts without an explicit final dict, the consumer submits an empty finalizer.

Failure taxonomy

class WorkerFailure(Exception):
    kind = "transient"
    permanent = False

Handlers raise a typed subclass; anything else escaping a handler is wrapped as transient. The wire failure_reason is "{kind}: {detail}", capped at 512 bytes.

Class kind permanent Meaning
CorruptInput corrupt_input true Unparseable bytes; retrying cannot help.
UnsupportedFeature unsupported_feature true Input needs a feature outside the v1 support matrix.
FetchDenied fetch_denied true Robots disallow / auth wall / 4xx.
RenderFailed render_failed false Headless render blew up; worth a bounded retry.
ResourceLimit resource_limit false Hit a memory/time ceiling; may succeed on a larger worker.
Transient transient false Network blips, 5xx, and everything else worth retrying.

Permanent kinds dead-letter immediately in core; retryable kinds re-enter the backoff cycle, bounded by jobs.max_attempts.

Data model

Wire submission shape

{
  "v": 1,
  "canonicalText": "...",
  "spans": [{"start": 0, "end": 17, "kind": "body"}],
  "nodes": [
    {"labels": ["CONTRACT"], "properties": {"name": "Alpha"}, "spanRefs": [0]}
  ],
  "relationships": [
    {"type": "REFERS_TO", "fromRef": 0, "toId": "...", "properties": {}}
  ],
  "stats": {"rows": 1200, "dirty_counts": {"date": 4}},
  "contentHash": "blake3-hex",
  "childJobs": [{"format": "docling", "name": "attachment:abc123", "contentB64": "..."}],
  "ambiguities": [
    {
      "nodeRef": 0,
      "kind": "conflicting_dates",
      "candidates": [{"label": "2026-05-01 (effective)", "validFrom": 1746057600000000}],
      "detectorConfidence": 0.7,
      "appliedCandidateConfidence": 0.55
    }
  ]
}

One subsection per projection

One DOCUMENT node; SECTION per heading level; PARAGRAPH per block; TABLE with TABLE_ROW children (cells as row properties keyed by header). CONTAINS edges, parent to child, in reading order (ord property). Every node carries page and bbox [x0,y0,x1,y1] where docling provides them. Lists project as paragraphs carrying list_level. Span kinds: heading, paragraph, code, cell. Headerless table columns get positional col_<i> names, aligned with the tabular worker.

Parsing runs in a subprocess with a memory ceiling; exit codes map onto the failure taxonomy (0/10/20/30/otherok/corrupt/ unsupported/resource/transient). RLIMIT_AS applies on POSIX only; on Windows, docling.timeout_s is the sole backstop. The worker package depends on docling-core (document model) only, with the torch-backed parse stack as an optional extra, so the golden corpus is five committed DoclingDocument fixtures rather than raw PDFs.

One node per row, labeled by the table name (explicit config, or filename/sheet name sanitized to UPPER_SNAKE). Header rules: auto (row 0), none (col_<i>), or an explicit row index; duplicate headers get _2, _3 suffixes. REFERS_TO edges (or a configured type) from explicit refs: [{from_col, to_label, to_col}] mappings only: a heuristic ref suggester (column name ends _id/Id AND ≥ 0.9 value overlap with a candidate key column) only suggests refs in job stats, never creates edges uninvited.

Loading is a unified all-string pipeline: every cell loads as a string, then per-column target type is inferred from the first 1000 non-empty cells in the first chunk (≥ 80% must cast). Boolean lexicon is {true, false} case-insensitive only ("1"/"0" stay ints). Datetimes accept four ISO-8601 shapes and become microseconds-since-epoch ints (JSON cannot express Value::Datetime directly). Cells that fail their column's cast keep the raw string and increment a per-column dirty_count, a row is never dropped for a dirty cell. Streaming is chunked at tabular.chunk_rows (50k) with one partial submission per chunk; row spans are global offsets via base_offset chaining, and the final submission resolves explicit refs across chunk and sheet boundaries by fromId/toId.

Node labels WEB_PAGE (title, canonical_url, fetched_at), SECTION per heading, PARAGRAPH per block, URL_REF for external link targets. CONTAINS (ord) plus REFERENCES (internal targets resolve to their WEB_PAGE node within the same submission; cross-page and external targets become deduped URL_REF nodes). Canonical text is the fetched markdown (NFC/LF).

A Fetcher protocol abstracts the rendering backend: PlaywrightFetcher (default, zero external dependency) tries plain HTTP + readability + markdownify first and escalates to headless Chromium only on an unrendered-SPA heuristic; FirecrawlFetcher is the opt-in hosted-API alternative. Robots.txt is fetched and cached per origin for web.robots_ttl (1h); a 401/403 on robots itself is treated as deny-all per RFC 9309, other 4xx as allow, unreachable as allow. Crawls are bounded to web.crawl.max_depth (2) and web.crawl.max_pages (50); discovered same-origin links enqueue as child jobs via childJobs, with the page budget split deterministically across links in document order rather than looping in-worker. Recrawl dedup is driven by a submitted contentHash (hex BLAKE3 of the fetched markdown) against the latest source version.

Nodes: MESSAGE (subject, message_id, sent_at as valid-time start), PERSON ({address} only, keyed per tenant by normalized lowercase address, display names live on the edges, not the node, so the core upsert always matches), ATTACHMENT_REF (filename, mime, content_hash, child_source_name). Edges: SENT_BY, TO/CC (with ord and displayName), IN_REPLY_TO, HAS_ATTACHMENT. Format is sniffed by magic (OLE compound header D0 CF 11 E0 A1 B1 1A E1.msg, else .eml); .msg parses via extract-msg.

Ids are all uuid5(NAMESPACE_URL, ...): PERSON from "person-email:" + lowercase(address); MESSAGE from "email-msg:" + message_key (normalized Message-ID, or "blake3:" + blake3(raw_bytes) when absent); ATTACHMENT_REF from "email-att:{message_key}:{index}". IN_REPLY_TO always targets the parent's deterministic id, even before the parent arrives; reads validate endpoints, so the edge self-heals the moment the parent lands: no pending-ref bookkeeping needed. Quoted-reply blocks are excluded from embedding structurally: the body property carries fresh text only (capped at 8192 bytes), while quoted text exists solely as quote-kinded canonical spans. Attachment routing is by magic sniff, then MIME, then extension: pdf/docx/pptx → docling; csv/xlsx → tabular; nested eml/msg → email; child job names are content-hash-derived (attachment:{blake3_hex}) so identical bytes attached to any number of messages collapse into one shared source document.

Nodes: FILE (path, language, content_hash), MODULE (rust inline mod blocks only), FUNCTION, CLASS (also covers rust enum/union/trait, ts interface/enum; rust impl blocks group onto the type's CLASS node), METHOD, CODE_REF (an unresolved import, carrying the raw import string). Edges: CONTAINS (structural parent → child, ord), IMPORTS (file → file when lexically resolvable within the ingested file set, else file → CODE_REF), CALLS (resolution: "syntactic", same-file only: the callee must be a simple identifier resolving to exactly one same-file FUNCTION; ambiguous or cross-file calls land on the caller's calls_unresolved property instead of a guessed edge).

A format=code job is a manifest: JSON {repoNs, files: [{path, hash, contentB64}], languages?} that fans out one format=code-file child job per supported file. Incremental re-ingest falls out of this for free: an unchanged file re-enqueues with the same (source name, content hash) and core no-ops it; the worker performs no hash check itself. Every id is deterministic (CODE_NS = uuid5(NAMESPACE_URL, "telha:code-worker"), then FILE, CODE_REF, and declaration ids all derive from it), so unchanged declarations match on re-ingest instead of orphaning. Grammars are pinned exact package versions (code_worker.languages.GRAMMAR_PINS), asserted before the first job; a grammar upgrade is a deliberate, spec-amended re-ingest, not silent drift.

Algorithms & invariants

Determinism

flowchart LR
    BYTES["Identical input bytes"] --> PARSE["Deterministic parse<br/>(no wall clock, no randomness)"]
    PARSE --> PROJECT["Deterministic projection"]
    PROJECT --> HASH["Identical submission hash"]

Determinism is the cross-cutting invariant

Every spec pins the same rule: identical input bytes must produce an identical projection (node count, ordering, spans). Projection logic consumes only document content, never wall-clock time or randomness. This is what makes golden-corpus tests meaningful and what makes re-ingest of unchanged content a safe no-op rather than a coin flip.

Temporal ambiguity detection

Docling, tabular, and email each carry a §15 addendum defining document-specific rules for detecting temporally ambiguous facts (conflicting effective dates, relative-date phrases, missing dates, day-first/month-first cell readings). All three share the same contract:

Ambiguity emission is capability-gated

Detection only runs, and add_ambiguity only emits, when the job payload advertises options.clarify == true. Without that flag a worker emits nothing (an old core would otherwise reject the field loudly). No worker assigns a severityHint in v1; kind → severity elevation is tenant policy, not worker heuristics.

Each rule produces one or more labeled candidates (best guess first), a detectorConfidence, and an appliedCandidateConfidence; core applies the first candidate's interval to the fact version. Every detector is deterministic: docling and email anchor relative-date phrases to document dates, never to "today," and tabular's day-first/month-first tie-break votes off unambiguous cells seen earlier in the same deterministic chunk order.

Streaming submission chaining

Global offsets across partials

A streaming worker builds one IngestionResultBuilder per chunk with base_offset set to the previous chunk's byte_length. Each partial's canonicalText carries only its own appended slice, but every span's start/end is a byte offset into the accumulated text across all partials. The server acks each partial with node_ids in submission order; the final partial resolves any cross-chunk relationships by fromId/toId using those accumulated ids.

Configuration

Key Default Worker
docling.max_pages 2000 docling
docling.mem_ceiling_mb 4096 docling
docling.timeout_s 600 docling
tabular.chunk_rows 50,000 tabular
tabular.max_rows 10,000,000 tabular
tabular.max_cols 2000 tabular
tabular.heuristic_overlap 0.90 tabular
web.fetcher playwright web
web.crawl.max_depth 2 web
web.crawl.max_pages 50 web
web.robots_ttl 3600s web
web.timeout_s 60 web
email.max_message_mb 50 email
email.max_attachments 50 email
email.strip_plus_tags false email
email.heuristic_thread_window_h 168 email (reserved, not yet active)
code.max_file_kb 1024 code
code.max_files_per_job 5000 code

HEARTBEAT_INTERVAL_SECS = 60 is a workers_common constant, not a per-worker config value.

As-built notes

Reconciled against each spec's §14 changelog:

  • Tabular multi-submit streaming shipped (v0.3): the original v0.2 deviation (single submission per job, because core had no partial-submission protocol) was retired once the seq/partial protocol landed. Row spans are global offsets via base_offset chaining exactly as described above.
  • Docling's failure-exit-code mapping and rlimit backstop are as specced in v0.2; RLIMIT_AS is POSIX-only, and the golden corpus is five docling-core fixtures rather than raw PDFs since the torch-backed parse stack is an optional extra.
  • Web's URL_REF dedup is per-submission, not per-tenant, in v1 (v0.2 deviation): workers cannot query the graph, and true per-tenant dedup needs deterministic node identity/upsert, deferred.
  • Email's heuristic threading tier is deferred (v0.2 pin 4): the worker sees one message per job and cannot query the graph for subject/time-window matches. subject_normalized is still emitted as a property so a future core-side pass can key on it. IN_REPLY_TO resolution instead relies on self-healing dangling edges to deterministic parent ids (pin 3), which the spec notes is simpler than pending-ref properties and satisfies retroactive resolution by construction.
  • Email attachment child-job naming changed to content-hash-derived in v0.4 (PR-067), replacing the v0.2 message-scoped naming, so identical attachment bytes across any number of messages collapse into one shared source document.
  • Code's cross-file CALLS resolution was not implemented (v0.2 pin 3): the spec's §3 aspiration was "same-ingestion resolvable," but because each file is its own child job without sibling file contents, only same-file resolution shipped. This is recorded as a v1 limitation, not silently dropped.
  • Code's MODULE nodes are rust-only (v0.2 pin 4): a Python file's module identity is its FILE node; mod foo; projects as an IMPORTS reference, not a declaration.