Connector framework¶
Architecture
Normative spec
This page documents .ai/specs/integration/2026-07-04-source-connector-framework.md (Status: Approved, review 2026-07-04, namespace-scoped delete condition incorporated in v0.3) and .ai/specs/integration/2026-07-04-entra-identity-connector.md (Status: Approved, review 2026-07-04, Slack-alias ownership condition incorporated in v0.4). The implementation lives in workers/ (one thin Python package per source over the shared workers_common harness) and in core-engine/src/ingest/source_meta.rs (the envelope core applies).
Every connector, Entra, SharePoint, Exchange, Slack, Salesforce, pulls from a source system on a delta cursor and lands what it finds through the same ingest path everything else uses, carrying enough metadata that authorship, ownership, ACLs, and deletions all keep working without a bespoke integration per source.
Overview¶
A connector's job is narrow by design: discover, fetch, and describe. Parsing, projection, dedup, and graph writes all stay in the code paths that already exist for manually ingested content. The source-connector-framework spec states the normative contract every content connector implements (spec §3):
- Connectors are read-only against the source. They never write, move, tag, or annotate source content.
- Each runs as a
sync:{source}job chain: a run leases one job whose payload carries the delta cursor, processes a window, and enqueues the successor with the advanced cursor. The job chain is the connector's state; there is no separate registry. - Every discovered item goes through the existing ingest path (
POST /v1/ingest, format worker parses) carrying asourceMetaenvelope. - Principals (author, owner, last-modified-by, ACL entries) resolve through the identity model, never invented locally.
- Mirrored ACLs are advisory and floor-only.
- Deletions observed in a source delta feed propagate as tombstones.
- A failing item is skipped and audited; it never aborts the run.
The Entra identity connector is architecturally distinct from the content connectors even though it shares the same runtime harness and job-chain pattern: it writes PERSON/GROUP identity directly through the SDK records API rather than through the ingest/parsing path, because identity has no document to span (framework spec §9). Its own spec, 2026-07-04-entra-identity-connector.md, is what makes Entra the canonical identity source the other connectors depend on (see Entra as the identity substrate below).
For deploying and configuring a connector, day-2 operator commands, and the per-connector app-registration steps, see the operator how-to. This page covers the shared abstraction, the wire shapes, and the invariants underneath it.
Design¶
The connector abstraction¶
There is no Connector abstract base class in workers_common. The shared abstraction is the harness contract that workers_common.run and WorkerConsumer (workers/workers_common/workers_common/consumer.py, run.py) impose on any worker package, connectors included:
- A module
<package>.workerexposingFORMATS: list[str]and a callablehandle(job: Job) -> bytes | None(or a generator of submission dicts, for streaming workers; connectors are single-shot and return bytes). Job(workers_common.consumer.Job) is the leased-job dataclass every handler receives:job_id,tenant_id,organization_id,kind,payload(the dict cursor),lease_deadline.python -m workers_common.run <package>resolves that module, starts a/healthzendpoint, and runsWorkerConsumer(poll → dispatch → heartbeat → submit) until SIGINT/SIGTERM.
Every connector package (entra_connector, sharepoint_connector, exchange_connector, slack_connector, salesforce_connector) implements this contract identically:
# entra_connector/worker.py
FORMATS = ["entra"]
def handle(job: Job) -> bytes:
"""Run one sync window and return the sync-result JSON bytes."""
config = ConnectorConfig.from_env()
graph, writer, close = _runtime(config)
try:
result = run_sync(job.payload, graph, writer, config)
finally:
close()
return json.dumps(result, ensure_ascii=False).encode("utf-8")
Inside that shared shape, each package supplies its own four building blocks: a config.py (ConnectorConfig.from_env() / .require_runtime(), fails loudly at startup if a required env var is missing), a source-API client (graph.py for the three Graph-backed connectors, a REST/OAuth client for Slack and Salesforce), a sync.py (run_sync(payload, client, writer, config), the per-window delta walk and item processing), and, for content connectors, a meta.py that builds the sourceMeta envelope from the source's native item shape. sync.py is where the framework's real contract lives: the delta-walk loop, throttle/backoff, item-level fault isolation, and the pinned sync-result JSON are structurally the same function across every connector, reimplemented per source rather than inherited from a base class.
Why no base class
The framework spec (§9) rejects a from-day-one per-connector spec set and a dedicated connector-state store precisely because the job-chain checkpoint pattern and the ingest path already exist; the same reasoning shows up in the code as convention over inheritance. Each sync.py is independently readable and independently testable, at the cost of some duplication between _walk_delta/_sync_site-shaped loops.
The sync-result contract¶
A connector's handle does not call any "complete the job" RPC directly. It returns a discriminated JSON result over the existing SubmitIngestionResult RPC (no proto change, framework spec v0.4 item 3):
{
"v": 1,
"sync": true,
"nextPayload": { "...": "connector-owned cursor shape" },
"delaySecs": 1800,
"stats": { "ingested": 42, "deleted": 1, "failed": 0 }
}
Core recognizes sync: true, completes the current job, and enqueues the successor of the same kind with not_before = now + delaySecs and payload nextPayload. This is the entirety of "connector state": there is no row anywhere that says "SharePoint site X is at delta token Y" outside of that next job's payload.
Entra as the identity substrate¶
The Entra connector is not just one connector among five, it is a dependency the other four content connectors have on person identity, and the docs and specs both call this out structurally rather than as an afterthought.
SharePoint's ACL principals, Exchange's mailbox owner, and Slack's message authors all arrive as references (an aad_oid, an email address, a Slack user id), not as resolved people. Something has to be the canonical place those references become an actual PERSON with a name, a manager, and group memberships that RBAC and clarification routing can reason about. The Entra identity connector spec (§3.1) states this plainly: "Entra is canonical. A PERSON's binding identity comes from the corporate IdP; source systems contribute authorship/ownership facts; chat surfaces contribute delivery aliases only." Concretely:
- A SharePoint file's
permissionsfacet carries group grants as the group's AAD object id (spec OQ-1, ratified): the group itself, and who is a member of it, is Entra'sGROUPnode andMEMBER_OFedges, not something SharePoint's connector can resolve on its own. - Content connectors that only see an author's email create a placeholder PERSON (
uuidv5(NAMESPACE_URL, "person-email:" + lowercase(email))). When Entra later syncs the real person, the placeholder is merged via anALIAS_OFedge, not overwritten, append-only, and existingAUTHORED_BY/OWNED_BYreferences keep resolving through it (Entra spec §3.6, §5). - Slack identities are weaker still: a Slack user id is never sent as a principal at all (only an email, when the Slack profile exposes one), and even that email never creates binding authority on its own. Turning a Slack identity into something that can be trusted for clarification routing requires a separate, audited verified-match step (
POST /v1/person-aliases/verified-match, clarify-audience only, exact-one-match against Entra-verified aliases) performed lazily by the clarify bot, never by the Entra connector reaching out to Slack.
This is why the operator runbook says to run the Entra connector before the other connectors and before the clarify bot: it is the authority substrate clarification routing and the RBAC-adjacent ACL check both depend on. A content connector can run first and will not fail, but its authorship edges resolve to placeholders until Entra sync catches up.
flowchart TB
ENTRA["Entra connector<br/>PERSON / GROUP · MEMBER_OF · REPORTS_TO"]
SP["SharePoint connector"]
EX["Exchange connector"]
SL["Slack connector"]
SF["Salesforce connector"]
CLAR["Clarification routing<br/>+ RBAC-adjacent ACL gate"]
SP -->|"aad_oid principals resolve against"| ENTRA
EX -->|"aad_oid / email principals resolve against"| ENTRA
SL -->|"email principals; Slack user id needs<br/>verified-match to attach"| ENTRA
SF -->|"email principals (OwnerId → email)"| ENTRA
ENTRA -->|"canonical PERSON + MEMBER_OF"| CLAR
SP -.->|"placeholder person until merged"| CLAR Data model¶
Connectors covered¶
| Connector | Source API | What it ingests | Identity role |
|---|---|---|---|
| Entra ID | Graph /users/delta, /groups/delta, /users/{id}/manager | PERSON, GROUP, MEMBER_OF, REPORTS_TO (direct SDK writes, no ingest path) | Canonical identity substrate for every other connector |
| SharePoint / OneDrive | Graph sites/{id}/drive/root/delta | Files (pdf/docx/pptx to docling, csv/xlsx to tabular, json to the bypass) per allowlisted site | Authorship/ACL principals as aad_oid; owner from shared.owner |
| Exchange / Outlook | Graph /users/{upn}/messages/delta | Mailbox messages as raw MIME, format="email"; attachments fan out and dedupe by content hash | Author as email principal; mailbox owner is the ACL |
| Slack | conversations.history / conversations.replies | One thread (parent + replies) per format="json" document, SLACK_THREAD label, HAS_MESSAGES children; files by extension | Author as email principal only when users.info exposes one; never a binding identity |
| Salesforce | REST updated/deleted feeds per object, paged SOQL for full resync | Records as format=json, describe-shaped scalar content, labeled by UPPER_SNAKE object API name | OwnerId/CreatedById/LastModifiedById resolved to email via SELECT Email FROM User |
The sourceMeta envelope¶
Every synced item carries options.sourceMeta on the ingest call. The spec's prose is snake_case (§5); the as-built DTO rides camelCase, pinned by the framework spec's v0.4 changelog entry, and is implemented in core-engine/src/ingest/source_meta.rs:
{
"connector": "sharepoint",
"externalId": "01ABCDEF...",
"containerPath": "Shared Documents/Contracts/msa.pdf",
"sourceUrl": "https://contoso.sharepoint.com/...",
"createdAt": 1751328000000000,
"modifiedAt": 1751414400000000,
"author": { "kind": "aad_oid", "value": "11111111-2222-3333-4444-555555555555" },
"lastModifiedBy": { "kind": "aad_oid", "value": "22222222-3333-4444-5555-666666666666" },
"owner": { "kind": "email", "value": "finance-team@contoso.com" },
"acl": [
{ "kind": "aad_oid", "value": "33333333-4444-5555-6666-777777777777" }
],
"extra": {}
}
connector and externalId are required; every other field is emitted only when the source exposes it (omitting an available field is treated as a connector bug, spec §3.4). principal = {kind: aad_oid|email|slack_user|sf_user, value}; timestamps are µs since epoch. At apply time core:
- Derives the deterministic source id
uuidv5(NAMESPACE_URL, "{connector}:{tenant}:{external_id}")(connector_source_idinsource_meta.rs), so re-syncing an unchanged item is a no-op via content-hash dedup and a changed item creates a new source version. - Attaches
external_id,source_connector,source_url,container_path,source_created_at,source_modified_atas properties on the projection root (SourceMeta::root_properties). - Resolves
author/lastModifiedBy/ownerinto PERSON logical ids (aad_oid→ canonical,email→ placeholder;slack_user/sf_userare skipped at apply time with a log line, since those connectors resolve them to emails before the envelope is ever sent) and writesAUTHORED_BY/LAST_MODIFIED_BY/OWNED_BYedges from the projection root. - Serializes
acltoacl_jsonand stores it on the source version row, not on the nodes: the version pins what the ACL was at sync time, the same way every other temporally-scoped fact is pinned.
Algorithms & invariants¶
Sync loop¶
Every connector's sync.py follows the same shape, illustrated here against the SharePoint connector's per-site drive delta (sharepoint_connector/sync.py):
flowchart TB
START["lease sync:{source} job<br/>payload = saved cursor(s)"] --> FETCH["fetch one delta page<br/>(honor Retry-After on 429)"]
FETCH -->|"410 Gone"| RESET{"already reset<br/>this run?"}
RESET -->|no| FULL["drop cursor, restart<br/>from a full listing"]
RESET -->|yes| FAIL["raise Transient<br/>(retryable)"]
FULL --> FETCH
FETCH -->|page ok| ITEM["for each item in page"]
ITEM --> KIND{"item kind"}
KIND -->|"deleted facet"| DEL["lookup_record(externalId, connector)<br/>found → delete_record(rootId)<br/>not found → skip+stat"]
KIND -->|"unsupported/oversize/folder"| SKIP["skip + stat<br/>(never an error)"]
KIND -->|content item| ROUTE["fetch bytes + ACL,<br/>build sourceMeta,<br/>POST /v1/ingest"]
ROUTE -->|raises| ITEMFAIL["catch, stats.failed += 1,<br/>audit log, continue"]
DEL --> NEXT{"more pages?"}
SKIP --> NEXT
ROUTE --> NEXT
ITEMFAIL --> NEXT
NEXT -->|"@odata.nextLink"| FETCH
NEXT -->|done| CURSOR["new delta link"]
CURSOR --> RESULT["return {sync:true, nextPayload,<br/>delaySecs, stats}"]
RESULT --> SUBMIT["SubmitIngestionResult"]
SUBMIT --> ENQUEUE["core enqueues successor<br/>not_before = now + delaySecs"] Cursor shapes are connector-owned and per-source, but all follow the same "the job chain is the checkpoint" rule (framework spec §3.2):
| Connector | Cursor shape (as-built) |
|---|---|
| Entra | {usersDelta, groupsDelta, fullSyncCompletedAt, connectionId} (single Graph delta link per resource) |
| SharePoint | {deltaLink: {siteId: link, ...}, connectionId} (one Graph drive delta link per allowlisted site) |
| Exchange | {deltaLink: {mailbox: link, ...}, connectionId} (one Graph delta link per allowlisted mailbox) |
| Slack | {cursors: {channelId: {latest}}, connectionId} (newest fully processed history timestamp per channel) |
| Salesforce | {windows: {sobject: {since}}, connectionId} (a per-object window END; each run replays [since, now] and core dedups the overlap by content hash) |
A 410/expired cursor triggers an in-place full resync of that resource, never treated as an error (Entra spec §5; framework spec §5). Salesforce's variant is a stale-cursor case rather than an expiry: a window older than the feeds' 30-day reach invalidates and falls back to paged SOQL id enumeration, which doubles as the initial backfill.
ACL and deletion propagation¶
ACLs are floor-only, never enforcement. Mirrored source ACLs (sourceMeta.acl, stored as acl_json on the source version) may only narrow who is shown content in the clarification gate's source-side check; they can never widen access beyond Telha workspace RBAC. Both directions hold: a person present in Telha RBAC but absent from the item ACL is skipped for that item, and a person present in the ACL but absent from RBAC is also skipped. Telha RBAC remains the single enforcement point; ACLs go stale between syncs by design, which is the tradeoff the framework spec (§9) accepts explicitly rather than trying to keep source ACLs live. See Tenancy & security for the RBAC layer this sits on top of.
Deletions propagate through the standard tombstone cascade, not a connector-specific mechanism. When a source delta feed reports an item gone (SharePoint's deleted facet, Exchange's @removed, Salesforce's deleted feed), the connector resolves the item's projection root via GET /v1/records?externalId=&connector= (lookup_record in the Python SDK) and calls DELETE /v1/records/:id, which tombstones the root node (and cascades to its live edges) through the same tombstone cascade every other delete in Telha uses: a new version whose valid time opens at the delete instant, history reconstructable at prior transaction times, nothing physically erased. A 404 on the lookup (never synced, or already gone) is a skip+stat, not an error.
Not every connector propagates deletes, and the framework spec (§14, PR-068 changelog) records this as a deliberate per-source stance rather than a bug:
| Connector | Delete propagation |
|---|---|
| SharePoint | Yes, via delta deleted facet |
| Exchange | Yes, via @removed delta entries |
| Salesforce | Yes, via the deleted REST feed (a full resync after a stale cursor carries no delete feed, recorded limitation) |
| Slack | No, v1: polling conversations.history surfaces no delete delta |
| Entra (group membership) | @removed membership entries are audit-logged, not written: edge tombstoning needs an edge id the connector does not hold across runs (recorded v1 limitation) |
Delete authority is namespace-scoped. A connector-audience token may tombstone only records whose source_connector property matches its own connector name. Core checks this on every connector-token DELETE /v1/records/:id (core-engine/src/api/v1.rs) and rejects a mismatch with 403 CONNECTOR_NAMESPACE_MISMATCH plus a security_audit log event. A compromised SharePoint connector can therefore never tombstone Slack-synced, Exchange-synced, uploaded, or manually created records. API keys are unrestricted tenant-admin credentials and are not narrowed by this check. See Tenancy & security for how this fits the broader audience-fencing model.
Invariants¶
Idempotency of re-sync
Re-syncing an unchanged item is a no-op: source identity is deterministic (uuidv5(NAMESPACE_URL, "{connector}:{tenant}:{external_id}")), and content-hash dedup means an unchanged item creates zero new source versions on a repeat pass. A changed item creates exactly one new version. RecordInput.upsert: true on the records API gives the same guarantee for structured writes (Entra): create, identical → no-write, different → new version, tombstoned → 409 (never resurrected).
Item-level fault isolation
A failing item (fetch error, mapping crash) is caught, counted in the run's stats, and audit-logged; it never aborts the run and the cursor still advances past it. Only source-auth failures (invalid_grant/consent revoked) are permanent: they dead-letter the job and emit a CONNECTOR_AUTH_FAILED audit event.
Tenant scoping of connector credentials
A connector token is tenant-scoped and named ({v, tid, oid, wid, iat, aud}, audience "connector"): job leasing (lease_next) only ever surfaces that tenant's sync: jobs, and the token's name is the delete namespace checked above. This is the same tenant-prefixed address-space guarantee described in Tenancy & security, applied to a service credential instead of a user session.
Read-only against the source
Connectors never issue a non-GET request against the source API (barring the OAuth token exchange itself). Every per-connector test suite asserts this over recorded traffic (test_sharepoint_readonly.py and equivalents).
Configuration¶
Per-connection configuration is connector-process environment, not core TOML: core's surface stays generic (the seed CLI plus chain semantics), and [connectors.*] scope, credentials, and intervals live with whichever process hosts the connector (framework spec §8, item 7). Deliberately not configurable, per connector: read-only posture, the ACL floor-only rule, deterministic source identity, item-level fault isolation, deletion propagation.
Common harness environment (workers_common):
| Env var | Meaning | Default |
|---|---|---|
TELHA_URL | Core REST base URL | (required) |
TELHA_API_KEY | Tenant API key for REST writes | (required) |
TELHA_GRPC_TARGET | Core gRPC host:port | 127.0.0.1:7626 |
TELHA_WORKER_TOKEN / TELHA_WORKER_TOKEN_CMD | Job-leasing token (static, or a command that re-mints one; tokens expire in 300s so _CMD is preferred) | - |
TELHA_WORKER_ID | Worker identity in leases | package name |
TELHA_HEALTH_PORT | /healthz port | 8090 |
Per-connector credentials and scope (exact env var names, from each package's config.py):
| Connector | Credential env vars | Scope allowlist | Interval |
|---|---|---|---|
| Entra | ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET | n/a (tenant-wide) | ENTRA_INTERVAL_MINUTES (60) |
| SharePoint | SP_TENANT_ID, SP_CLIENT_ID, SP_CLIENT_SECRET | SP_SITE_IDS (empty = sync nothing) | SHAREPOINT_INTERVAL_MINUTES (30) |
| Exchange | EX_TENANT_ID, EX_CLIENT_ID, EX_CLIENT_SECRET | EXCHANGE_MAILBOXES (empty = sync nothing) | EXCHANGE_INTERVAL_MINUTES (30) |
| Slack | SLACK_BOT_TOKEN | SLACK_CHANNELS (empty = sync nothing) | SLACK_INTERVAL_MINUTES (30) |
| Salesforce | SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET | SF_OBJECTS (empty = sync nothing) | SALESFORCE_INTERVAL_MINUTES (30) |
Every oversized-item cap (SHAREPOINT_MAX_ITEM_MB, EXCHANGE_MAX_ITEM_MB, SLACK_MAX_ITEM_MB, SALESFORCE_MAX_ITEM_MB) defaults to 50 and is enforced as a skip+stat, not a failure. Full field-by-field detail, plus the Azure app-registration and Salesforce connected-app steps each connector needs, is in the operator how-to.
As-built notes¶
Both specs stay Approved; their §14 changelogs record deviations found while building, not design reversals.
Source-connector-framework (.ai/specs/integration/2026-07-04-source-connector-framework.md, v0.4-v0.8):
- The envelope rides
options.sourceMetawith camelCase inner keys; the spec's snake_case (§5) is conceptual only. - Connector tokens are a third token shape (audience
"connector"), tenant-scoped and named; job leasing useslease_next, not the cross-tenant worker scan. - The sync-result contract reuses the existing
SubmitIngestionResultRPC rather than a new proto message. slack_user/sf_userprincipals never ride the wire at all: those connectors resolve them to emails before submission, so core's principal resolution only ever needs to handleaad_oidandemail.- Slack (PR-068) landed with no ACL mirroring (channel membership churns) and no delete propagation (polling surfaces no delete delta) as recorded v1 stances, plus a documented coordination note: a concurrent session produced a competing PR-068 variant with delete propagation via a lookback window; the landed, spec-pinned implementation is the one described here.
- Salesforce (PR-069) excludes base64/compound fields from ingested content and holds audit-stamp/principal-id fields out of content so that audit-only touches resubmit identical bytes (dedup no-ops); ACL mirroring is out of scope for v1 (record-level Salesforce sharing is not cheaply exportable).
- Exchange (PR-067) uses
Prefer: IdType="ImmutableId"soexternalIdsurvives folder moves, and shares one DOCUMENT per distinct attachment content hash across messages and mailboxes (OQ-3, landed with this PR). - SharePoint (PR-066) cannot carry Graph's composite site-id form (
hostname,siteCollectionGuid,siteGuid) through the comma-separatedSP_SITE_IDSallowlist, since the id itself contains commas; recorded as a v1 limitation requiring one Graph-addressable site id per entry.
Entra identity connector (.ai/specs/integration/2026-07-04-entra-identity-connector.md, v0.5):
- Placeholder person shape is pinned to
{address}only, matching the PR-055 email worker's convention, so upserts against email-worker persons never version-churn. proxyAddressesentries under non-SMTP schemes (sip:,x500:) are dropped outright rather than mangled into a garbage placeholder probe.- Group
@removedmembership entries are audit-logged, not written: edge tombstoning needs an edge id the connector does not hold across runs (recorded v1 limitation). - The Slack verified-match endpoint landed clarify-audience only, with exact-one-match tenant scanning and re-verification of evidence against the person's own Entra-verified aliases.
Related¶
- The operator how-to for connectors: deploying and configuring connectors, app registrations, and day-2 CLI operations.
- Tombstone cascade: the mechanism source deletions propagate through.
- Tenancy & security: the RBAC floor ACLs sit on top of, and the audience-bound token model connector credentials use.
- SSO & authentication: identity provider integration and the Entra connector's role as the canonical identity source.
- GDPR erasure: physical erasure versus the soft tombstones described here.
- Field encryption: how classified fields in synced content are protected before core ever sees them.
- Clarifications: the authority routing that consumes
AUTHORED_BY/LAST_MODIFIED_BY/OWNED_BYedges.