Skip to content

ICS Partner Data Contract ​

Engineering reference for the Tellia Partner API (ICS = consumer #1). The partner-facing docs (bilingual, published to Scalar) live separately; this page is the internal view: what we built, where it lives, and how partner data maps onto workspaces.

The pivot: the contract IS the real API ​

The partner API is not a fixed-DTO faΓ§ade. It is the real Workspace + Records API (already @ApiKeyAllowed()), plus a thin /v1/ transport for high-volume ingest. Partners model their data as workspaces (dynamic tableHeader schemas), fetch canonical schemas, create/extend their own, and push schema-driven records. This is deliberate: ICS hands the doc to an external team that must build against the end-game architecture, and partners have their own shapes/extra tables β€” workspaces (dynamic) are the core model, not a frozen column set.

What exists today ​

  • Zod contract β€” packages/schemas/src/partner-contract/ holds the net-new, partner-agnostic pieces: the identity spine (identity.ts), the CloudEvents envelope (envelope.ts), the transport shapes (transport.ts: events / ingest / validate / records-page / record-upsert), and well-known workspace-key guidance (registry.ts). No fixed resource DTOs β€” records validate against each workspace's live tableHeader.
  • Static OpenAPI β€” pnpm --filter @tellia-solutions/schemas generate:partner-openapi emits partner-openapi.json at the repo root. It imports the real schemas (WorkspaceResponseSchema, CreateWorkspaceSchema, TableHeaderFieldSchema, bulk-create) so the doc cannot drift from the backend, plus a hand-authored PropertyDefinition (the 13 property types) and the /v1 transport shapes.
  • /v1 endpoints (ENG-710) β€” apps/agri-backend/src/partner-ingest/ ships the sandbox transport: POST /v1/events (CloudEvents β†’ dedupe β†’ Pub/Sub), POST /v1/ingest/{key} (NDJSON β†’ stage), POST /v1/validate/{key} (dry-run, structural). Keyed by workspace key (any workspace). Structural validation only (identity spine); deep tableHeader validation + the workspace-upsert consumer are WL-gated (ENG-615). Accepted payloads land in the partnerInboundHolding collection until the consumer exists.
  • Published to Scalar β€” a "Partner Data Contract" nav group in the tell-ia / tell-ia-staging configs (generic, no partner named).

The API surface ​

GroupEndpointsAuth
WorkspacesGET/POST /workspaces, GET /workspaces/by-key/{key}, PUT /workspaces/{id}x-api-key
RecordsPOST /parse-call/create, POST /parse-call/bulk-create, GET /workspaces/{id}/federated-records (offset)x-api-key
IngestionPOST /v1/events, POST /v1/ingest/{key}, POST /v1/validate/{key}x-api-key

The Workspace + Records endpoints already exist on the WL stack (WorkspaceController, ParseController, both @ApiKeyAllowed()); the federated read is WL-gated (documented inline in the OpenAPI). /v1 is ours (ENG-710). Everything under admin/* is not partner-safe.

Layers ​

The contract's reusable pieces:

LayerWhatReuse
TransportCloudEvents envelope, NDJSON ingest, dry-run validate, offset records read, x-api-keyVerbatim across partners
Identity spinereferenceCode (<scheme>:<code>, the rename-safe upsert + federation join key), updatedAt (delta key)Verbatim
Workspace schemaEach workspace's tableHeader β€” dynamic, fetched via the APIPer-workspace, live

referenceCode reuses the records-layer NamespacedReferenceCodeSchema β€” the same <namespace>:<code> join key the federation matcher uses β€” so the contract and the matcher cannot drift. Owned records use a partner scheme (ics:field-99); catalog records use a master scheme (amm:, gov_fr_crop:, eppo:) so they federate precisely (no fuzzy name matching). There is no separate sourceId β€” the partner's stable id lives inside referenceCode.

Workspace mapping (well-known keys) ​

Records target a workspace key. Some map onto Tellia's canonical (system-layer) workspaces; the rest a partner models as their own. This list is guidance in registry.ts (WELL_KNOWN_WORKSPACE_KINDS), not a closed set or a validation source.

KindCanonical workspaceStatus
growertellia:growerExists (party β€” singular)
croptellia:cropsExists (catalog)
fieldtellia:fieldsExists
observationtellia:scouting_reportExists
farm, sub_parcel, criteria, campaign, treatment, phyto_productpartner-modeled or ENG-609Not necessarily Tellia system workspaces β€” the partner creates their own at the integration layer when the shape differs; which become true system catalogs is scoped in ENG-609.

Canonical = a join key, not a schema ​

"Maps onto a canonical workspace" means the record is written to that key carrying a referenceCode, so it federates with the system master catalog and every other partner's rows. It does not mean a fixed column set: federation merges records field-by-field (priority company > integration > system, joined on referenceCode) and never merges columns β€” each layer's workspace has its own free-form tableHeader. A partner concept with no canonical twin gets its own <partner>:* workspace and simply doesn't federate β€” that's fine.

Why point at the real API (not a frozen faΓ§ade) ​

We considered a fixed /v1 DTO faΓ§ade wrapping the internals (an ACL). We rejected it and point partners at the real Workspace + Records API, because:

  • The end-game must be handable. ICS's external team builds against this. A frozen faΓ§ade that diverges from the product's own workspace model would teach them the wrong architecture and force a rewrite later.
  • Workspaces are already the stable product surface. The Workspace + Records API is the app's own contract; it doesn't churn on internal refactors the way arbitrary internals would. Partners get full CRUD to model, extend, and add workspaces to complete their integration.
  • The only hard-shared primitive is referenceCode (NamespacedReferenceCodeSchema) β€” the federation join key, so wire and matcher must agree bit-for-bit.
  • /v2 only on a breaking change to these endpoints. Additive workspace/record fields stay /v1; URI versioning (ENG-587) runs /v1 and /v2 in parallel if a breaking change is ever needed.

Partner-specific shapes β†’ the same canonical workspace ​

Different partners model the same concept differently (ICS crop β‰  FieldX crop). They still target one canonical key; the layer system (system β†’ template β†’ integration β†’ company, federated by exact key) carries the differences:

LayerHoldsFor tellia:crops
systemcanonical, partner-agnostic columnsthe cross-partner pivot (ENG-609)
integrationpartner-specific surplus columns, same keyICS's extra crop fields live here
companythe tenant's own customizationsper-company
  • Partner surplus columns go into an integration-layer overlay on the same tellia:X key β€” federation unions them at read time. Not a sibling ics:crop workspace (that fragments the pivot).
  • The partner↔canonical link is referenceCode, not a Mongo parent id.
  • A partner-namespaced workspace (ics:<thing>) is correct only for a genuinely partner-only concept with no canonical equivalent β€” it won't participate in cross-partner analysis.

Self-serve partner modeling (design) ​

Today this is manual: Tellia does the modeling β€” done with the partner during onboarding via the same Workspace API (full CRUD) partners themselves can use. The flow below is the self-serve roadmap (ENG-618/619) that removes the per-partner work; the insight: reuse the existing WorkspaceAssistant β€” a partner logs in and models workspaces like any user β€” because layer is derived from companyId.

Company topology per partner (all distinguished by companyId β†’ layer):

CompanyLayerFederates toRole
Sandboxcompanynobodysafe modeling playground
Platform (e.g. FIELDX_PLATFORM_COMPANY_ID)integrationall the partner's tenantslive master catalogs
Tenantcompanyβ€”end-user data (provisioned on activation)

Flow: connect (ENG-614) provisions the platform + sandbox companies and a real partner-admin login β†’ model in the sandbox with the assistant (taught canonical mapping) β†’ promote a validated workspace into the platform company (layer: integration, adapterKey) β†’ tenants federate with it via key + referenceCode.

Correctness pillars:

  • Layer = f(companyId) β€” integration-layer writes must target the platform company's id; sandbox is a different id (β†’ company layer). Generalize FieldX's hardcoded sentinel into a companyId β†’ {adapterKey, layer, kind} registry (ENG-608).
  • Namespace guard on create/promote β€” tellia:<x> only if <x> is in the canonical catalog (so it federates); else <partner>:<x>; reject other partners' keys.
  • Capture the referenceCode strategy, not just columns β€” the assistant emits both the tableHeader and the fieldβ†’canonical + referenceCode mapping the ingest consumer (ENG-615) uses.
  • Promote is the only path to the live integration layer β€” gated + shows blast radius ("affects N tenants"). The partner's day-to-day session is the sandbox.

Open decision: sandbox = separate company + promote-copies-defs (recommended, works today) vs draft/published versioning on the integration workspace. Full design + slices: ENG-619 (assistant + promote), ENG-608 (registry), ENG-614 (login), ENG-618 (restricted partner view).

Dependency on the WL stack ​

The record-upsert consumer + deep tableHeader validation depend on the layered-workspace (WL) stack β€” federation, upsert-by-external-id, system catalogs + the EPPO importer, reference-matching. Those PRs are in flight; the consumer (ENG-615) builds off whatever has merged to main at that point. The /v1 transport (ENG-710) and this contract/docs deliverable have no such dependency, which is why they ship first (staging as a sandbox for ICS's team).

Membership & provisioning (ENG-614) ​

How a partner's users land in Tellia companies. The contract above moves data; this moves identity. Both planes are decoupled: OAuth2 authenticates the user, the per-company x-api-key carries the data.

The ICS connect flow (PartnerConnector, reused from FieldX) is: OAuth2 callback β†’ GET /me + GET /my-orgs β†’ for each org, resolve-or-create the Tellia company and write externalIds.ics=<orgId> β†’ mint per-company x-api-key. Three per-partner mappings drive it, all the same federation pattern as referenceCode:

MappingSourceTargetNotes
externalOrgId β†’ companyId/my-orgsCompany.externalIds.ics1:1 org↔company; a user can hold N orgs
externalUserId β†’ userIdOIDC subUser.externalIds.icsstable identity across connects
externalRole β†’ roles[]per-org role on /my-orgsTellia role enummapped via the per-partner adapter (see below)

Provisioning writes N entries into the user's memberships[] (one per org). Today self-serve registration writes only one membership (isPrimary:true) β€” the partner path is the first consumer of the multi-membership array.

Role mapping ​

Tellia roles are owner | manager | foreman | specialist | technician | operator | worker | other. The partner sends its own role string; we never let it target Tellia's enum directly. The map lives in the field-mapping adapter (one map per partner), e.g. { admin: ['manager'], member: ['worker'] }. Two hard rules:

  • owner is never partner-grantable. A partner admin/owner maps to manager at most. owner (billing, tenant control, destructive ops) stays Tellia-curated or the connecting user. A partner-side reorg must not be able to hand out owner.
  • Unknown role β†’ safe floor (worker/other), never escalate up. Log unmapped strings to extend the map.

Security gate ​

Company.externalIds.<partner> is settable only via the partner-authed connect path (or Tellia-side), never a self-serve create-company form. Otherwise an attacker creates a company, stamps a known orgId, and inherits that org's users + data.

Eager (ICS) vs lazy (reusable platform) ​

  • ICS = eager. /my-orgs enumerates the user's orgs, so companies are resolved-or-created at connect time.
  • Partners without org enumeration = lazy/claim-based. Store the unresolved (partner, externalOrgId, externalUserId, externalRole) as a pending claim in a separate store β€” not in memberships[], which stays resolved-only to protect tenant-scoped queries. When the company is later created (or gets its externalIds stamped), a BullMQ reconcile (idempotent, keyed on the external id) drains matching claims into real memberships. Pending claims carry a TTL. This generalization belongs to the self-serve platform (ENG-618); ICS itself does not need it.

Open questions ​

Bilateral, ICS-specific mapping questions (which of farm/sub_parcel/criteria/campaign are canonical vs ICS-modeled, area units, the criteria value-type enum, the treatment↔field link) resolve against ICS's foreign-key map in a private appendix (not published) and feed ENG-609. The public partner docs (docs/partner-api/) are generic.