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 livetableHeader. - Static OpenAPI β
pnpm --filter @tellia-solutions/schemas generate:partner-openapiemitspartner-openapi.jsonat the repo root. It imports the real schemas (WorkspaceResponseSchema,CreateWorkspaceSchema,TableHeaderFieldSchema, bulk-create) so the doc cannot drift from the backend, plus a hand-authoredPropertyDefinition(the 13 property types) and the/v1transport shapes. /v1endpoints (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); deeptableHeadervalidation + the workspace-upsert consumer are WL-gated (ENG-615). Accepted payloads land in thepartnerInboundHoldingcollection until the consumer exists.- Published to Scalar β a "Partner Data Contract" nav group in the
tell-ia/tell-ia-stagingconfigs (generic, no partner named).
The API surface β
| Group | Endpoints | Auth |
|---|---|---|
| Workspaces | GET/POST /workspaces, GET /workspaces/by-key/{key}, PUT /workspaces/{id} | x-api-key |
| Records | POST /parse-call/create, POST /parse-call/bulk-create, GET /workspaces/{id}/federated-records (offset) | x-api-key |
| Ingestion | POST /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:
| Layer | What | Reuse |
|---|---|---|
| Transport | CloudEvents envelope, NDJSON ingest, dry-run validate, offset records read, x-api-key | Verbatim across partners |
| Identity spine | referenceCode (<scheme>:<code>, the rename-safe upsert + federation join key), updatedAt (delta key) | Verbatim |
| Workspace schema | Each workspace's tableHeader β dynamic, fetched via the API | Per-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.
| Kind | Canonical workspace | Status |
|---|---|---|
grower | tellia:grower | Exists (party β singular) |
crop | tellia:crops | Exists (catalog) |
field | tellia:fields | Exists |
observation | tellia:scouting_report | Exists |
farm, sub_parcel, criteria, campaign, treatment, phyto_product | partner-modeled or ENG-609 | Not 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. /v2only on a breaking change to these endpoints. Additive workspace/record fields stay/v1; URI versioning (ENG-587) runs/v1and/v2in 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:
| Layer | Holds | For tellia:crops |
|---|---|---|
system | canonical, partner-agnostic columns | the cross-partner pivot (ENG-609) |
integration | partner-specific surplus columns, same key | ICS's extra crop fields live here |
company | the tenant's own customizations | per-company |
- Partner surplus columns go into an
integration-layer overlay on the sametellia:Xkey β federation unions them at read time. Not a siblingics:cropworkspace (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):
| Company | Layer | Federates to | Role |
|---|---|---|---|
| Sandbox | company | nobody | safe modeling playground |
Platform (e.g. FIELDX_PLATFORM_COMPANY_ID) | integration | all the partner's tenants | live master catalogs |
| Tenant | company | β | 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
referenceCodestrategy, not just columns β the assistant emits both thetableHeaderand the fieldβcanonical +referenceCodemapping 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:
| Mapping | Source | Target | Notes |
|---|---|---|---|
externalOrgId β companyId | /my-orgs | Company.externalIds.ics | 1:1 orgβcompany; a user can hold N orgs |
externalUserId β userId | OIDC sub | User.externalIds.ics | stable identity across connects |
externalRole β roles[] | per-org role on /my-orgs | Tellia role enum | mapped 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:
owneris never partner-grantable. A partner admin/owner maps tomanagerat most.owner(billing, tenant control, destructive ops) stays Tellia-curated or the connecting user. A partner-side reorg must not be able to hand outowner.- 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-orgsenumerates 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 inmemberships[], which stays resolved-only to protect tenant-scoped queries. When the company is later created (or gets itsexternalIdsstamped), 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.