§7 — does this connector's discovery (DiscoverObjects/DiscoverFields → IntrospectSchema) return the
AUTHORITATIVE, COMPLETE gamut of objects/fields the credentials expose? Default false (safe):
a connector must explicitly affirm this. Override to true ONLY when DiscoverObjects hits a real
list/describe endpoint that returns EVERYTHING accessible — so an object/field absent from a refresh
genuinely means the source dropped it (and may be deactivated). Leave false for stubbed discovery
(returns nothing → static metadata is all we have), a cache-driven IntrospectSchema, or any
partial/scoped enumeration — there, absence proves nothing and MUST NOT deactivate.
Verbatim ClassName / IntegrationName getter / MJ: Integrations.Name.
Highest SAFE per-layer concurrency the source tolerates (plan.md §7 peak parallelization) — the
ceiling the engine's adaptive controller ramps toward. null → use configured syncConcurrency.
Whether the watermark this connector returns (FetchBatchResult.NewWatermarkValue) is a
RELIABLE, monotonically-increasing global maximum — i.e. the connector fetches in watermark
order so the last batch's value IS the true high-water mark, and an updated record always
re-surfaces at a NEW (higher) watermark. When true, the engine uses that watermark to NARROW
the next incremental (instead of advancing a full sync to wall-clock "now", and instead of the
keyset clear-and-re-scan), so incrementals fetch only what's new.
Default false — the safe, backwards-compatible choice: a connector whose source returns
records out of watermark order (e.g. HubSpot's creation-ordered list API, where the last batch
can carry old modstamps) MUST stay false so the engine keeps advancing to "now" and never saves
a stale watermark. Override to true ONLY when the source guarantees monotonic ordering.
Conservative rate-limit policy. Path LMS publishes no explicit per-app limit; a single GraphQL endpoint behind Apollo tolerates modest sustained throughput. A low default keeps the connector polite without a documented number to push to (provable-only — see PROVENANCE BatchRequestWaitTimeGap).
Whether this connector supports batched target writes (plan.md §7 aggressive batching).
Whether this connector supports creating new records in the external system.
Whether this connector supports deleting records from the external system.
Whether this connector supports reading/fetching records. Always true.
Whether this connector supports paginated listing of records.
Whether this connector supports searching/querying records with filters.
Whether this connector supports updating existing records in the external system.
Whether this connector supports idempotent upserts (create-or-update keyed
by a unique business property). Connectors override this AND Upsert to enable it.
ProtectedAppendAppends default query parameters from the IntegrationObject metadata. DefaultQueryParams is stored as a JSON object like {"key": "value"}. Automatically skips params whose key (case-insensitive) already appears in the URL to avoid duplicates with pagination params.
ProtectedapplyRuns TransformRecord, then RE-ADDS any source key the transform dropped (present in
raw, absent from the output) unless declared in ExcludedSourceKeys. This makes the
base-fetch path structurally full-record (§0 forward-compat contract): a TransformRecord
override may reshape/coerce but cannot SILENTLY drop a source key from ExternalRecord.Fields,
so the framework's custom-column capture sees everything the source returned. The default
identity transform returns raw unchanged → fast-path no-op (zero overhead, zero behavior
change for connectors that don't override TransformRecord).
ProtectedAuthenticateTwo-step token exchange. Resolves applicationId/applicationSecret from the credential, returns a cached non-expired token when available, else POSTs form-urlencoded credentials to /api/v1/getToken and caches the resulting bearer (stripping any leading "Bearer ") with a 12h expiry (minus a skew buffer). No refresh token exists — re-mint is just another exchange.
Batch-create. Default loops single-record CreateRecord, so the engine may always call the batch form.
Batch-delete. Default loops single-record DeleteRecord.
Batch-update. Default loops single-record UpdateRecord.
ProtectedBuildBuilds a CRUDResult for a record CREATE, failing LOUDLY when the external system returned no usable record ID. A 2xx response with an empty/undefined ID means the create did not durably produce a record we can track — returning Success:true there silently loses the record and causes duplicate creates on the next sync (the HubSpot-association class of bug, fixed in next commit 9f718a7e). This makes that failure explicit at the connector boundary.
ProtectedBuildBearer header on every GraphQL request, plus JSON content negotiation.
ProtectedBuildBuild the operation request body per BodyShape:
ProtectedBuildAppends pagination parameters to a URL based on the object's PaginationType. Override in subclasses to use vendor-specific parameter names.
Optionalcursor: stringOptionaleffectivePageSize: numberWhen provided by FetchPaginatedLoop, caps the requested page size to the remaining batch capacity. Subclasses should honor this to prevent overshoot.
Generic create: reads CreateAPIPath/Method/BodyShape/BodyKey/IDLocation from the IO row.
Generic delete: reads DeleteAPIPath/DeleteMethod/DeleteIDLocation from the IO row.
Returns the fields for an object as the FULL STANDARD set parsed CREDENTIAL-FREE from the public
SpectaQL schema (the baseline), enriched by the seeded Declared metadata (PK/FK/type curation) where
it exists, then — only when a live credential is present — ADDITIVELY augmented with any tenant-
specific fields the live introspection exposes that the public SDL lacked (the Discovered overlay).
The public-schema field set is the baseline that always resolves token-free. The Declared overlay never replaces it; the live overlay only appends. Introspection failures degrade gracefully to the public + Declared fields. NEVER samples live data; NEVER hardcodes the field catalog.
Discovery via the connector's READ PATH (FetchChanges), TIME-BOUNDED — the way to gather a statistically-significant sample when a single DiscoverFields sample is too small to PROVE a key. "Discovery is the sync read path with the save removed."
Loops FetchChanges as a read-only FULL fetch (WatermarkValue=null, nothing persisted), threading pagination/keyset cursors across batches, and streams every record through the data-informed field + provable-PK inference. It stops at the discovery TIME BUDGET (default 5 min), or a record cap, or source exhaustion — whichever comes first — so the provable-PK decision is made on as much real data as the budget allows. It NEVER fabricates a key: if even this larger sample yields no provable single/composite PK, the field set comes back PK-less and the object is honestly not added.
Falls back to the single-sample DiscoverFields if the read path can't run for this object (e.g. a connector whose FetchChanges needs an already-persisted IO row that doesn't exist yet).
Optionalopts: { BatchSize?: number; MaxRecords?: number; TimeBudgetMs?: number }ProtectedDiscoverStage-2 field discovery for sources WITHOUT a describe/introspection endpoint (file feeds,
undocumented JSON list endpoints): stream the source's actual records — READ-ONLY, no save,
no ack — and derive the full field set + data-informed PK/uniqueness/nullability from the
gathered statistics. The connector supplies whatever read-only fetch yields the records; this
helper turns that stream into ExternalFieldSchema[].
Why data-informed: streaming the real values lets pickPrimaryKeyFromStats pick the PK from
evidence (uniqueness/non-null statistics) COMBINED with the naming convention, rather than a
name guess alone. The PK is a SOFT key, so the pick is best-available, not strict-significance:
a confident unique+non-null column wins outright; otherwise a near-unique / convention-named
column is taken as a soft key (a PK-less object would stall CodeGen). The scan is time-bounded —
it stops on exhaustion OR opts.Discovery.TimeBudgetMs; more rows simply mean stronger claims.
Provable-only encoding into the standard flags:
IsPrimaryKey — set ONLY on the single statistics-first pick. Multiple equally-ranked unique
columns leave PK unset here (ambiguous → the pipeline's SoftPKClassifier LLM tiebreaker
decides, fed these same stats). Zero unique columns → no PK is fabricated.IsUniqueKey — set when the column was all-distinct over the scan AND uniqueness was provable
(the distinct-cap wasn't hit).AllowsNull — asserted true ONLY when a null/absent value was actually observed; otherwise
left undefined (permissive default). Never fabricates NOT NULL — critical under a time-capped
partial scan where unseen rows could still be null.The PK emitted here is SOFT (it rides additionalSchemaInfo via the persist + DDL path; it is
NEVER a hard DB key), so a wrong inference can never reject a valid row — the engine dedupes via
the record-map. IsReadOnly defaults to true (stream discovery targets read feeds); a writable
source overrides via opts.ReadOnly.
A read-only sync/async iterable of source records (the caller's fetch yields them).
Optionalopts: { Discovery?: StreamDiscoveryOptions; Pk?: PkPickOptions; ReadOnly?: boolean }OptionalDiscovery?: StreamDiscoveryOptionsTime budget / sample caps for the scan (see StreamDiscoveryOptions).
OptionalPk?: PkPickOptionsSignificance threshold + naming-rank tiebreaker (see PkPickOptions).
OptionalReadOnly?: booleanWhether discovered fields are read-only. Default true.
Enumerates the FULL STANDARD UNIVERSE of record types CREDENTIAL-FREE by fetching + parsing the
public SpectaQL schema page at https://data-api.pathlms.com/ — NOT a baked array and NOT dependent
on any token or on the seeded metadata cache. This is the T3-deadlock fix: because the standard
universe is sourced from the public schema (which always resolves without a credential), the runtime
credential-free DocStructureSelfCheck re-yields the same universe and persisted objects never read
as structure drift.
The seeded Declared metadata cache is consulted only to ATTACH the persisted IntegrationObject ID
to each discovered object (a join, not the source of the object set). A live credential is NOT used
here at all — standard objects are token-free by construction.
ProtectedDiscoveryDISCOVERY-ONLY: last-resort SAFETY bound on the recursion depth for multi-level template chains (a
parent that is itself a template-var child), so a malformed metadata cycle cannot loop unboundedly.
This is only the fallback when the consumer sets no discoverySampleMaxDepth in Configuration and
no env override is present — the consumer decides the real value. A recursion cap MUST have some
bound, so a conservative default remains here; override the getter to change the fallback.
ProtectedDiscoveryREST override (§sample-discover): a SINGLE-template-var CHILD is sampled with the recursive, record-constrained StreamRecordsForDiscovery. Flat objects fall back to the generic FetchChanges loop; MULTI-var (composition) children are deferred — StreamRecordsForDiscovery adjourns them (declared-only until first sync) rather than fire malformed URLs.
ProtectedExcludedSource keys a connector DELIBERATELY removes in its TransformRecord override (vendor noise, a flattened parent blob, a nested child collection emitted as its own object). These are the only sanctioned drops — excluded from the applyTransformPreservingKeys re-add. Default: none. Override per-object to declare intentional removals.
ProtectedExtractBest-effort error message extraction from a vendor response. Override for vendor-specific shapes.
ProtectedExtractExtract the new record's external ID from a create response per IDLocation.
ProtectedExtractOffset/limit pagination. Path LMS report queries return a flat list with no total-count or
has-next signal, so "more pages remain" is inferred from a full page: when the page returned
exactly pageSize records, there may be more (advance the offset); a short/empty page is the end.
Parse a Retry-After / rate-limit signal out of a failed response or thrown error into
milliseconds so the engine can back off precisely. Return undefined when the error is not a
throttle (or carries no hint).
Fetches records by POSTing a GraphQL document built from the IO's metadata:
json fields given a one-level sub-selection resolved from the live SDL type map),Full-record pass-through: every record's COMPLETE GraphQL node lands in ExternalRecord.Fields so
the framework's custom-column capture sees every key. Identity is the IOF-declared PK; the base
content-hash fallback handles PK-less / object-container objects.
ProtectedFetchFetches the raw public SpectaQL schema HTML from https://data-api.pathlms.com/ with NO auth header.
Overridable seam (tests inject a fixture). Throws on a non-2xx response so discovery surfaces a real
fetch failure rather than silently returning zero objects.
Returns the ActionGeneratorConfig for this connector, combining the integration name, category, icon, and objects into a ready-to-use configuration for ActionMetadataGenerator.Generate().
Override in subclasses to customize the config (e.g., icon, category). Returns null by default if GetIntegrationObjects() returns empty.
ProtectedGetThe single GraphQL endpoint. APIPath on every IO is /graphql, so the base URL is the host root.
ProtectedGetGets IntegrationObjectField records from the engine's cache for a given object ID. Returns only active fields sorted by Sequence.
ProtectedGetGets an IntegrationObject from the engine's cache by integration ID and object name. Throws if not found.
Returns a proposed default configuration for quick setup. Override in subclasses to provide connector-specific defaults including schema name, objects to sync, and field mappings. Returns null by default (no quick setup available).
Returns suggested default field mappings for an external object to MJ entity. Override in subclasses to provide intelligent defaults.
Name of the external object
Name of the target MJ entity
Array of default field mappings (empty by default)
Returns the integration objects and their fields that this connector supports, for use by the ActionMetadataGenerator. This is static metadata that does NOT require a live connection — it describes the connector's known object model.
Override in subclasses to provide connector-specific objects/fields. Returns an empty array by default (no action generation available).
Generic get-one: hits APIPath/{ID} via GET. Override if API uses non-standard get shape.
Builds SourceSchemaInfo from IntegrationEngineBase cached metadata. Provides richer metadata than the base class implementation, including FK relationships derived from RelatedIntegrationObjectID.
Lists records with cursor-based pagination.
Override in subclasses that support paginated listing.
Check SupportsListing before calling.
ProtectedMakeExecutes an HTTP request via fetch. For GraphQL all requests are POSTs carrying a JSON
{ query, variables } body. Parses the JSON body; returns the raw text on a non-JSON response.
Optionalbody: unknownProtectedNormalizeStrips the GraphQL envelope: { data: { <responseDataKey>: <records> } } → records, normalizing a
single object to a one-element array. GraphQL errors are surfaced as a thrown error so the engine
records the failure (rather than silently treating data: null as zero records). When data is
present alongside errors (partial success), the data is returned and the error is left to the
caller's HTTP-status handling.
Type-driven post-processing hook (plan.md §10): a connector may normalize/enforce a record's
values to the resolved column formats AFTER transform/normalize and BEFORE write. Default
returns the record unchanged. (Named for this system — NOT MCP, not take.) The engine ALSO
applies target-type constraint enforcement; this is the connector-side complement.
Searches for records matching the given filters.
Override in subclasses that support search/query operations.
Check SupportsSearch before calling.
KEYSET / no-watermark resume hint. None of the Path LMS report queries expose a record-modification
watermark, so every object resumes by its stable ordering key — the object's declared primary key
(the report's id). Resolved from the cached IOF PK. Returns null when no PK is declared (keyset
resume unavailable for the PK-less roll-up/aggregate report types).
ProtectedSubstituteSubstitute the ExternalID into a URL path template at runtime. Path templates may use {ID}, {id}, or {ExternalID} as the placeholder; for symmetry with FetchChanges template-var handling, any unsubstituted {var} in the template is left in place (caller's responsibility to ensure consistency).
For IDLocation='body' or 'header' the ID is not substituted into the path (caller must handle separately); we just return the raw path.
ProtectedTemplatePer-call cap on how many PARENTS a parent-iterated object processes before yielding a resumable batch (the engine loops on HasMore). Kept conservative so a single FetchChanges call fits the engine's op-timeout even at a slow initial adaptive rate; the AIMD bucket ramps up over batches. Override in a concrete connector if a vendor tolerates larger per-call parent runs.
Verifies credentials by minting a token then issuing the cheapest GraphQL query the SDL documents
(teamsList { id }). A 401/GraphQL auth error means bad credentials; a 2xx with a data envelope
confirms both the token exchange and live GraphQL access.
ProtectedTransformOptional per-record CUSTOMIZATION hook called between NormalizeResponse (vendor envelope-stripping) and ToExternalRecord (composite-PK assembly).
Distinct from NormalizeResponse — that strips the vendor's response envelope to expose individual records. This hook is for vendor-specific record-level customization the standard pipeline shouldn't carry: nested-field flattening, empty-string→null coercion for date columns, computed fields, removing vendor metadata blobs (e.g. Salesforce 'attributes'), etc.
Default is identity (no transform). Override only when a concrete connector needs vendor-specific shape changes.
Generic update: reads UpdateAPIPath/Method/BodyShape/BodyKey/IDLocation from the IO row.
Upserts a record — a single idempotent create-or-update keyed by a unique
business property (e.g. email), eliminating the search-then-create race window.
Override in subclasses whose external system exposes a keyed upsert primitive.
Check SupportsUpsert before calling.
Path LMS Reporting API connector (Blue Sky eLearn).
TRANSPORT: GraphQL over HTTP. There is exactly ONE protocol base — BaseRESTIntegrationConnector; GraphQL rides on top of it. Every report query POSTs a GraphQL document to the single endpoint
https://data-api.pathlms.com/graphql; NormalizeResponse strips thedata.<queryName>envelope and surfaceserrors[].AUTH (two-step, no refresh token): Authenticate exchanges
{ applicationId, applicationSecret }(form-urlencoded) athttps://data-api.pathlms.com/api/v1/getTokenfor a ~12h bearer JWT. The token + expiry are cached per credential; on expiry OR a 401, the connector re-mints. applicationId/Secret come from the linked Credential entity (or the CompanyIntegration.Configuration JSON) — NEVER baked in code.PAGINATION: offset/limit (
PaginationType=Offset) on the report queries.PULL-ONLY: the SDL documents 0 mutations / 0 subscriptions, so SupportsCreate/Update/Delete stay false (the base defaults) and no CRUD path is wired. No 501 stubs.
INCREMENTAL: every IO here is
SupportsIncrementalSync=false— the SDL's startDate/endDate args filter by event date, NOT record-modification time, so there is no watermark to assert (provable-only). The engine relies on content-hash idempotency + StableOrderingKey (the IO's stableid) for keyset-style resume.DISCOVERY — CREDENTIAL-FREE, FROM THE PUBLIC SCHEMA (the T3-deadlock fix): Path LMS publishes its complete GraphQL SDL credential-free as a SpectaQL HTML reference page at
https://data-api.pathlms.com/. That page is the schema-of-record. DiscoverObjects and DiscoverFields FETCH + PARSE that public page WITH NO CREDENTIAL and enumerate the full standard universe of GraphQL record types (84 = 93 SDL object types − 9 non-record types). This is what makes the runtime credential-freeDocStructureSelfCheckre-yield the same standard universe every time (so persisted objects never read as "structure drift").A live credential is strictly ADDITIVE: when present, DiscoverFields also runs a standard GraphQL introspection against
/graphqland appends any tenant-specific fields the public SDL lacked (theDiscoveredextension). It NEVER samples live data at build time and NEVER becomes the baseline — the standard universe always comes from the public, token-free schema. The auth-gated live introspection being available does NOT make the connector "case 2": the same schema is published credential-free, so discovery is case 1 (public schema) + an additive case-2 tenant overlay. The catalog is NOT a module-level constant — it is fetched + parsed from the public page at discovery time (only the small set of non-record SDL types to exclude is a documented constant, NOT the catalog itself).