# Claudforce — HLS — Provider + Payer An ordered route from an empty Salesforce org to a working Data Cloud, Marketing Cloud Next and Agentforce demo. Written from the org it was built in, verified against that org on 3 September 2026. Liam Sheridan · Salesforce Solutions Engineer · org trailsignup-a612cacbdcac92 Connect this to your Claude ## This page is an MCP server. Point your agent at it. Everything below is also available as tools your agent can call. Connect this server for the route, the traps and the data shapes, and connect your own Salesforce org so your agent can act on it. Neither half is enough on its own: this server knows what to build and cannot touch an org; the Salesforce MCP can touch an org and does not know this build. 1\. Connect this guide ``` claude mcp add --transport http claudforce-hls https://claudforce-hls.thelonelyagent.com/api/public/mcp ``` No auth. Twelve tools: `start_here` first, then get\_guide, get\_corrections, list\_sections, get\_section, search\_traps, verify\_step, list\_skills, get\_skill, get\_dataset\_schema, get\_manifest, list\_downloads. 2\. Connect your own Salesforce org Point your agent at your own org with the Salesforce MCP server. That is the half that does the building. This guide never touches your org and never sees your data. **How to connect your Salesforce org, step by step** You need an External Client App. Connected Apps are not supported for MCP client connections. Verified against a live org — about five minutes of setup, plus up to thirty minutes of waiting that catches most people out. 1. In Setup, go to **External Client App Manager** and choose **New External Client App**. Broken ### It is an External Client App, not a Connected App Connected Apps are not supported for MCP client connections. If you build one you will get an app that looks correct, authorises, and never connects. Salesforce's own setup guide directs you to register the client as an External Client App, and that is the only path that works. 2. Give it a name. The **API Name must contain no hyphens** — the label can. A hyphen in the API name is a silent failure later. 3. Set the **Callback URL** to `https://claude.ai/api/mcp/auth_callback` 4. Add the OAuth scopes `mcp_api` and `refresh_token`. Both are required. 5. Under security, enable **JWT-based access tokens for named users**, and leave **PKCE** on. Uncheck everything else that is changeable. 6. Set **Distribution State** to **Local**. 7. Save, then **wait**. See the trap below. 8. In Claude, add the Salesforce MCP server using the server URL from Salesforce's documentation for your setup. Standard hosted servers ship with tools already and are the fastest route; a custom server only pays off when you need to expose specific Flows, Apex or Data Cloud queries. Trap ### Propagation takes up to thirty minutes The app is saved long before it is usable. First-attempt connection failures are almost always this and not a configuration mistake. Wait, then retry the same settings before you change anything. Changing settings during propagation is how people end up rebuilding a working app. Salesforce MCP connection summary | Setting | Value | | --- | --- | | Where | Setup → External Client App Manager → New External Client App | | API Name | No hyphens. The label can keep them | | Callback URL | https://claude.ai/api/mcp/auth\_callback | | OAuth scopes | mcp\_api and refresh\_token | | Security | JWT-based access tokens for named users, PKCE enabled | | Distribution State | Local | | Propagation | Up to 30 minutes | Salesforce's setup guide at [developer.salesforce.com/docs/platform/hosted-mcp-servers/guide/setup-overview.html](https://developer.salesforce.com/docs/platform/hosted-mcp-servers/guide/setup-overview.html) is the source for the server URL and the current client list. Salesforce lists Claude as a supported client. Reading instead? The whole document is below, or as one Markdown file at [/rebuild-path.md](/rebuild-path.md), plain text at [/rebuild-path.txt](/rebuild-path.txt), indexed at [/llms.txt](/llms.txt). Get everything ## The downloads Everything on this page is downloadable. The asset pack is the whole build: 37 Apex classes, 18 Lightning web components, pages, tabs, permission sets and the schema. The mock data is the thirteen scrubbed datasets on their own. The skills are five Claude skills written while building this, for driving Salesforce over its APIs: headless discovery, campaign build, web affinity, Tableau Next dashboards, and SE triage. Claude skills ### Salesforce SE skills Five skills for driving Salesforce over its APIs, written while building this and used to build it. 5 skills · 36 KB [Download the skills](/salesforce-skills-pack.zip) Data only ### Makana mock data The thirteen datasets on their own, if you have an org and just need something in it. Scrubbed and synthetic. 13 datasets · 467 KB [Download the mock data](/makana-mock-data.zip) The build ### The asset pack Everything behind the screenshots and videos on this page. Apex, components, page placement, data and configuration. 553 files · 1.04 MB Full file list: [/manifests/asset-pack.txt](/manifests/asset-pack.txt) [Download the asset pack](/rebuild-path-asset-pack.zip) Machine-readable paths and the MCP server are covered at the top of this page. The result ## What the build produces Four short screen recordings from the finished org, so you can see where the week ends before it starts. The unified profileA custom Unified Profile connected live to campaigns, segments and web affinity data, with Maps showing how close each patient is to the nearest clinic and a full engagement timeline. The campaign pageAutomated campaign creation with mock engagement data, attribution insights, A/B path experiments, multi-content campaigns and a matching landing page. A/B testingA standalone app inside Marketing Cloud Next that shows every live content A/B test running across the platform. Audience overlapAdjust audience priority and see where customers overlap across segments. Identify the people tied to three, four or more segments and prioritise them directly. Rule ### A 200 is not a result. A `200` proves the parser accepted your request. `status: Ok` proves a source resolved. Neither proves the thing is correct. In this project, at least a dozen distinct APIs returned success while doing nothing, doing the wrong thing, or silently discarding half the payload. **Verify every write with a read-back query.** Every _How you know it worked_ line in this document exists because that rule was learned the hard way. Note This is a sequence of verified steps. How long it takes depends on how much of it you are deciding versus following. It does not compress if you are also making the design decisions. **The asset pack now ships the whole build:** 37 Apex classes, 18 Lightning web components, the pages, tabs and permission sets that place them, 13 scrubbed datasets, and the segment, dashboard and agent configuration as reference. What it cannot ship is the schema — custom objects have no REST create path — and the Tableau Next dashboards, which have no read endpoint to export from. Scope ## What you are rebuilding A single Salesforce org carrying a full healthcare marketing demo, and it is not one undifferentiated route. The difficulty sits in the data foundation: the mock data, the data streams and the DLO to DMO mappings, identity resolution, the data graphs, the engagement data connections, consent, and the segments built on top of all of it. Everything downstream of that foundation — the Tableau Next dashboards, the custom components and the Agentforce agents — is comparatively mechanical once the foundation is correct and resolving. The foundation is where the time actually goes. The figures below are the shipped state of that org. 640 unified profiles 5,828 consent rows 6 campaigns 192 dashboard widgets, all returning Ok 4 agents 26 message variants Every figure on this page carries a verification date. Status in a catalogue decays faster than content. Day 0 ## Before you build anything Two constraints set here shape every decision for the rest of the week. Build Day 0 prerequisites | Requirement | Why | | --- | --- | | Data Cloud provisioned, with a data space | Everything downstream of Day 1 | | Marketing Cloud Next enabled | Campaigns, Flows, Content tabs | | UnifiedMarketingStarterUserPermSet assigned to yourself | Blank Campaigns/Flows/Content tabs are a permissions problem, not a setup problem. Marketing Cloud Admin configures MC Next; it does not let you _use_ it | | Tableau Next workspace | Day 4 | | Agentforce enabled | Day 6 | ### What the source org actually holds Verified against the source org on 3 September 2026. It is an **Enterprise Edition trial**, non-sandbox, expiring **11 November 2026**. Every permission-set licence below expires with it. A trial gets provisioning and limits a customer org will not — treat the counts as evidence the feature was on, not as a shopping list. Source org licence and permission inventory | Licence or permission | Total | Used | What breaks without it | | --- | --- | --- | --- | | Salesforce (user licence) | 550 | 69 | Everything | | Data Cloud `GenieDataPlatformStarterPsl` | 200,000 | 2 | Day 1 onward. No streams, no DMOs, no identity resolution | | Customer Data Platform `CdpPsl` | 2,500 | 1 | Segment creation and the `MarketSegment` sObject | | Customer Data Cloud for Marketing | 200,000 | 1 | Segment activation into campaigns | | Marketing Cloud `MarketingCloudPsl` | 36,005 | 1 | Campaigns, Flows, Content — the whole of Day 3 | | Marketing Triggers | 5,000 | 1 | Event-driven journeys | | Marketing Intelligence Users | 10,000 | 1 | Campaign performance reporting | | Agentforce (Default) `EinsteinGPTCopilotPsl` | 3,105 | 10 | Day 6. No agents in the picker | | Einstein Prompt Templates | 3,110 | 15 | Prompt Builder | | Einstein Agent (user licence) | 802 | 1 | Agent runtime | | Tableau Next Creator | 10 | 1 | Day 4. Authoring dashboards | | Tableau Next Consumer | 10 | 1 | Viewing them | | CRM Analytics Plus | 131 | 3 | The analytics platform underneath | | Data Pipelines Base User | 3 | 1 | Batch data prep | | Guest User License | 25 | 9 | The anonymous web-visit capture only | Verified ### The one assignment everybody misses `UnifiedMarketingStarterUserPermSet` — the Marketing Manager permission set. Without it the Marketing app and the Marketing Cloud Next campaign builder simply do not render, and nothing tells you why. Marketing Cloud Admin configures MC Next; it does not let you _use_ it. This is the same trap as the blank-tabs row in the table above, and it is worth stating twice because it costs an afternoon. Note ### What the build owner holds, and what that means for you The owner is a **System Administrator** carrying **149 permission-set assignments** and **101 permission-set-licence assignments**. You do not need to reproduce that. What matters: - The standard sets that gate the platforms: `UnifiedMarketingStarterUserPermSet`, `StarterMarketingApp`, `MarketingCloudAdmin`, `MarketingTriggers`, `CDPAdmin`, `GenieAdmin`, `GenieMarketingAdmin`, plus the Tableau Next and CRM Analytics sets. - **Every custom permission set in the asset pack has a null `LicenseId`.** None of them consumes a licence. They are free metadata — recreating them is labour, not spend. - **`Cld_Web_Visit_Guest_Api` goes on the guest user, not on you.** It belongs to the Marketing Landing Pages Site Guest User. Assigning it to yourself does nothing and the endpoint keeps returning 403 for anonymous traffic. - A plain `SELECT ... FROM PermissionSetAssignment` **under-reports**. It returned 102 rows where `COUNT(Id)` returned 149 — standard-type permission sets are silently omitted from the row-returning query. Audit by count, not by eyeballing the rows. Broken ### Two things the source org does not have **No Health Cloud user licence exists in this org at all**, yet 146 `HealthcareProvider` records are queryable — the HLS objects ride on the industries data model available to the trial's Salesforce licence. Do not promise a customer this reproduces without a Health Cloud entitlement. **`CarePlan` is not supported.** The Care Plans permission-set licences are provisioned (10 available) and entirely unassigned. Any demo beat that depends on care plans needs rework, not configuration. Note ### Limits worth knowing before you generate data Most limits in a trial are enormous — 441,450 daily API requests against 52 used, 53 GB of data storage against 539 MB. Two will bite: - **Single emails: 100 per day.** Mass emails: 10. The seed generators write engagement _records_, they do not send, so this only matters if you wire up real sends. - **Analytics dataflow executions: 60 per day.** Enough to iterate, not enough to brute-force. Everything else has four or more orders of magnitude of headroom at this data volume. ### The two constraints that shape every later decision - Trap **Custom objects cannot be created over any REST surface.** Tooling `CustomObject` reports `createable: true` and rejects `FullName`. There is no `createCustomObject` action. `/headless/metadata` is not served. `deployRequest` needs multipart. Confirmed independently four times across this project. Metadata API via the `sf` CLI only. - Trap **Custom fields are fine over Tooling.** This is the standard workaround, and it shapes the architecture: where you would reach for a new object, put fields on an existing one. Five fields on `User` replaced a leaderboard object. `B2B_Mood_Board__c`, a stock SDO object, carries an entire 27-row message registry. A Tooling-created CustomField has **no FLS**, so SOQL and REST report _No such column_ while Apex reads and writes it perfectly. A seed can therefore succeed against a field you cannot query. ### Traps that apply all week - Trap **`+`, `++`, `&` and `#` in an `executeAnonymous` query string.** `+` decodes to a space; `&` truncates the body at that character and surfaces as `Missing closing quote character '` at an unrelated column. Use `String.join` and a `List` as a counter. Build the characters you need with `String.fromCharArray(new List{38})`. This was the single most frequently recurring trap in the project. - Trap **Throwing an exception to read a value out of `executeAnonymous` rolls back every DML in that transaction.** It once reverted 2,983 updates and silently no-opped a 1,165-row seed. Read-only probes only. To prove a write: run it plain, confirm `success: true`, verify with a separate query. - Trap **`where`, `group` and `on` are reserved words in Apex.** `on` is the nastiest: `for (String on : map.keySet())` fails with `Unexpected token 'on'` and then cascades a dozen unrelated errors below it — `Extra ')'`, `Method does not exist: void get() from the type Map` — none of which are real. - Trap **Apex identifiers are case-insensitive.** A parameter `src` silently shadowed the constant `SRC` and mis-grouped every seeded row. - Trap **Every out-of-the-box marketing tile defaults to Last 30 Days.** Anchor every generator to `Date.today()`, never a literal date. Hit three separate times; stale data is indistinguishable from a broken pipeline. The part that takes the time ## The data foundation Dashboards, components and apps are straightforward once this exists. The foundation is where the difficulty lives, and it is the part that has to be right before anything downstream means anything. Everything here was verified against the source org. The order 1. Field inventory and design, complete, named, typed 2. Create fields and field-level security 3. Populate and verify in CRM by SOQL 4. Create the data stream 5. Map DLO to DMO, and verify the mapping by reading the DLO field list 6. FULL refresh 7. Dry-run the match rule, and state the predicted unified count 8. Run identity resolution, reconcile against the prediction, explain any gap 9. Segments, Calculated Insights, data graph 10. Dashboards last Steps 1 to 3 before step 4 is the rule that saves the most rework. Rule ### CRM nulls become empty strings in Data Cloud A CRM field that is `null` arrives as `''`, not `NULL`. Verified on 2,989 rows: `IS NULL` matched **0**, `= ''` matched **2,507**. So `IS NOT NULL` is always true. A care-gap measure written as `SUM(CASE WHEN Care_Gap_Type IS NOT NULL THEN 1 ELSE 0 END)` returned 2,989 instead of 482. Always test emptiness with `<> ''`. The data stream create contract ``` POST /services/data/v64.0/ssot/data-streams { "name": "Patient_Appointments_CRM", "label": "Patient Appointments - CRM", "datastreamType": "SFDC", "connectorInfo": { "connectorType": "SalesforceDotCom", "connectorDetails": { "name": "SalesforceDotCom_Home", "sourceObject": "Patient_Appointment__c" } }, "refreshConfig": { "refreshMode": "UPSERT", "frequency": { "frequencyType": "BATCH" } }, "sourceFields": [ { "name": "Id", "dataType": "Text" } ], "dataLakeObjectInfo": { "name": "Patient_Appointments_CRM__dll", "label": "Patient Appointments - CRM", "category": "Engagement", "recordModifiedFieldName": "SystemModstamp", "eventDateTimeFieldName": "Appointment_DateTime_c", "dataspaceInfo": [ { "name": "default" } ] } } ``` Data stream create traps | Trap | Detail | | --- | --- | | No array wrapper | POST the object directly | | `dataLakeObjectInfo` takes no field list | Derived from `sourceFields` | | `dataspaceInfo`, lowercase s, **inside** `dataLakeObjectInfo` | Entries take `name` only | | `sourceFields` uses `dataType` on create | The read returns `datatype` | | Source field types must match CRM exactly | Email declared as Text returns `Expected EMAIL, was given Text` | | No `isPrimaryKey`, `shouldFetchImmediately` or `type` in `connectorDetails` | Rejected on create, present on read | | Engagement category needs `eventDateTimeFieldName` | Give it the mangled DLO name, not the CRM name | | DELETE needs `?shouldDeleteDataLakeObject=true` | Otherwise `MALFORMED_QUERY` | The run trigger is `POST /ssot/data-streams/{name}/actions/run` with `{"refreshType":"FULL"}`, and `POST /ssot/calculated-insights/{apiName}/actions/run` with an empty body. Both return `{"errors":[],"success":true}`. `/actions/refresh`, `/refresh` and `/data-refreshes` all 404 and are not endpoints. Broken ### Category decides everything downstream and cannot be changed Profile DLOs map to Profile or Other DMOs. Engagement DLOs map to Engagement DMOs only. `Profile` is the only choice for person-shaped rows, because a data graph root must be Unified Individual, and a Calculated Insight attaches only at graph **root** with the root's primary key as a **dimension** on the insight. Category cannot be changed after stream creation, only dropped and recreated. Decide it first. DMO mapping Rule ### DMOs union rows, they do not join columns Mapping a second DLO into an existing DMO doubles its row count and breaks every segment counting it. To add attributes to the same people, map into the same standard DMO and let identity resolution collapse them, or build a related DMO. Never a second Profile DMO for the same population. - Trap **Identity resolution operates on one DMO.** "Add my object to identity resolution" always means mapping its DLO into `ssot__Individual__dlm` plus `ssot__ContactPointEmail__dlm`, not creating a parallel custom Profile DMO. - Trap **`auto-map-dlos` omits `ParentId` to `ssot__PartyId__c` on contact points.** 887 email and 836 phone rows landed with a null party, orphaned, and the match rule needs that link. Always assert `COUNT(ssot__PartyId__c) = COUNT(*)` after auto-map, before running identity resolution. Fix with the Aura `deploy` action and re-run a FULL refresh. - Trap **`auto-map-dlos` 500s while the DLO is still `PROCESSING`.** Wait about 60 seconds and retry. The 500 is a timing artefact, not a failure. It also only works for opinionated objects, Individual, ContactPointEmail, ContactPointPhone, Contact, Lead, Account, Order, and returns `mappedDloNames: []` for anything else. - Trap **There is no served DLO to DMO mapping API.** The only route is the Aura `DataModelingController/ACTION$deploy` action, which needs a live session. - Trap **Field names mangle across three hops.** CRM `Party_Title__c` becomes DLO `Party_Title_c` becomes SQL and deploy source `Party_Title_c__c`. Run `SELECT * … LIMIT 1` once and read the `metadata` block. Never guess. - Trap **Always FULL refresh** after a bulk source update or a new mapping. Incremental appends modified rows as new versions and silently duplicates. The tell is `COUNT(*) > COUNT(DISTINCT Id__c)`. Identity resolution Broken ### The source profile filter is inverted relative to its own UI text The UI describes an exclude filter. The behaviour is an include filter. `Title Not Equals Physician` produced 743 unified profiles containing blanks, Customer, CFO and CEO, while `WHERE ssot__TitleName__c = 'Physician'` returned **0**. It also silently drops NULL-valued profiles, because `NULL != 'Physician'` is UNKNOWN, not TRUE. Contacts arrive with empty-string titles so they process; every other source has a genuine NULL and is dropped. The result was 743 unified, which is exactly 889 Contacts minus 146 physicians. **The advertised 62% consolidation rate represented zero merging.** There is no null-safe operator, the builder offers exactly Equals, In, Not Equals and Not In. The fix is OR'd filter groups keyed on a field every source populates: ``` Group 1: Individual · Title · Not Equals · Physician (Trim Spaces on) OR Group 2: Individual · Data Source Object · Not Equals · Contact ``` `Data Source Object` is populated for every source and is never NULL. Filter groups are OR'd. This is also future-proof: adding a new source later needed no ruleset change at all. Rule ### Dry-run the match rule, not a proxy for it Before adding a source, reproduce the rule's criteria as a self-join and count how many rows will merge versus pile up. Doing that predicted 640 of 640 and correctly forecast unified staying flat. An earlier estimate based on email overlap alone predicted a jump of 120 and was wrong. **State the expected unified-profile count as an acceptance test before running the job**, then explain any gap. One pass predicted 784 and got 930, and the 146-record gap was the finding. A pass that only checks "did it succeed" ships that defect. - Trap **Read the rule, do not trust its label.** The reference ruleset is called "Fuzzy Name and Normalized Email" and contains no fuzzy matching. It is an AND of three exact criteria: first name exact, last name exact, email exactnormalized. Email alone will not resolve. A synthetic identity needs an Individual row with first and last name AND a linked ContactPointEmail row. `PATCH /ssot/identity-resolutions/{id}` writes filters over REST: Identity resolution PATCH contract | Constraint | Detail | | --- | --- | | Required | `label`, `matchRules`, `reconciliationRules`. Omitting any returns a named `INVALID_INPUT` | | Rejected | `objectApiName`, `dataSpaceName`, output-only, `JSON_PARSER_ERROR` | | Not allowed | Two definitions on the same field with the same operator inside one group, which returns "The filter contains a duplicate entry" | | Use instead | `NOT_IN` with a comma-joined value, for example `"Contact,Individual"` | | Reconciliation | Send `entityName`, `fields`, `ruleType` and `sources` only. Drop the `linkDmoName` and `unifiedDmoName` the GET returns | | After PATCH | `rulesetStatus` goes `PUBLISHING` to `PUBLISHED` in about two minutes. Wait for `PUBLISHED` before running | Broken ### There is no REST endpoint to run a ruleset `/actions/run` and `/jobs` both 404. Two working routes: the `Run Ruleset` button on the record page, or the Aura `IdentityResolutionController/ACTION$runNow`. Find the button without guessing, `GET /ui-api/actions/record/{id}` lists it as `IdentityResolutionRunNowAction`. The data graph The data graph is the grounding object. One retrieval answers who this member is, what their plan and risk look like, what their clinical picture is, and whether you may contact them. Nothing in the agent layer works properly without it. ``` Unified Individual HLS1 (Primary) 5 fields └── Unified Link Individual HLS1 5 └── Individual 3 (+ Title, so role is visible on the grounding object) ├── Payer Profile 16 ├── Patient Profile 14 └── Communication Consent 6 (Id, PartyId, ChannelTypeId, ConsentStatus, ContactPointValueText, ConsentCapturedSourceName) ``` Broken ### A node's join key must also be a projected field on that node The first build returned `status: ERROR` with no message anywhere. The cause was visible only by reading the stored graph back: the consent node's `path` had lost its `fieldName`. ``` "path":[{"devName":"DGRelationships_252_…","parentFieldName":"ssot__Id__c"}] // broken "path":[{"devName":"…","fieldName":"Id__c","parentFieldName":"ssot__Id__c"}] // every good node ``` The join was on `ssot__PartyId__c` without projecting it. The platform silently dropped the join key, accepted the create, and failed the build. Adding `ssot__PartyId__c` to the node's `fields` fixed it on the first retry. **GET the graph and assert that `path[0].fieldName` is present on every non-root node before waiting five minutes on a build.** - Trap **Verify counts on the ID DMO, not the fragments.** `*_FRAGMENT__dlm` DMOs are empty, zero rows, on every graph checked. The `*_ID__dlm` object carries one row per node and record and answers everything: ``` SELECT object_name__c AS node, COUNT(*) AS rows_, COUNT(DISTINCT root__c) AS members FROM __ID__dlm GROUP BY 1 ORDER BY 2 DESC ``` - Trap **The ID DMO's name carries a creation epoch you cannot derive.** Find it with `GET /ssot/metadata?entityType=DataModelObject&entityCategory=Related` and match on the display name. - Trap **Orgs have a data graph cap.** A failed build still occupies a slot, so delete it before retrying rather than creating a second. - Trap **`POST` creates on a 500 and `DELETE` deletes on a 500.** Read the state back rather than trusting the status code. - Trap **A standard DMO works as a graph node,** joined on its own foreign key to the parent. `dataType: "DATE_TIME"` is valid input casing, and the output renders as `DateTime`. Note ### Consent inside the graph is not self-describing `ssot__CommunicationSubscriptionChannelTypeId__c` is an opaque id, and the channel type DMO does not carry the ids consent actually uses. Streaming `CommSubscriptionChannelType` and `CommSubscription` from CRM is what makes the channel nameable from the graph. Resolve, do not hardcode ## Making it portable This build references 49 distinct record ids across 22 key prefixes, plus about 21 platform-generated API names. All of them are org-specific. A rebuild in another org gets none of them. The rule: hardcode only names you create yourself, resolve everything else, and make every resolve a named call rather than a guess. Org-specific anchors and how to resolve them | Anchor | Resolver | Read | | --- | --- | --- | | Identity ruleset id | `GET /ssot/identity-resolutions` | match `label` | | Ruleset short id | same call | `rulesetId` | | Unified DMO name | same call | `reconciliationRules[].unifiedDmoName` where `entityName` matches | | Link DMO name | same call | `reconciliationRules[].linkDmoName` | | Ruleset object API name | same call | `objectApiName` | | Data stream record id | `GET /ssot/data-streams/{name}` | `recordId` | | dseId | same call | `dataLakeObjectInfo.id`, **truncated to 15 characters** | | DLO API name | same call | `dataLakeObjectInfo.name` | | DMO relationship id | `GET /ssot/data-model-objects/{dmo}/relationships` | match `sourceField.name` plus `targetObject.name`, check `status: ACTIVE` | | Data graph | `SELECT Id, Name, DataGraphApiName, Status, LastRunStatus, RecordCount FROM DataGraph` | match `DataGraphApiName` | | Data graph ID DMO | `GET /ssot/metadata?entityType=DataModelObject&entityCategory=Related` | match `displayName` equal to `