From 8cd54ab1d52c9d2f182553ef500c415774cf9c38 Mon Sep 17 00:00:00 2001
From: Brun Christophe <christophe.brun@forestadmin.com>
Date: Wed, 10 Jun 2026 15:26:19 +0200
Subject: [PATCH] feat(workflow-executor): handle polymorphic relations in
 load-related (skip + follow) [PRD-493]
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The load-related step now handles polymorphic BelongsTo relations (e.g. Rails
`commentable`), which carry no single static target. A relation is followable
when it has a relatedCollectionName OR a polymorphicTypeField; unfollowable ones
are no longer offered to the AI or in the awaiting-input card.

For a polymorphic relation the concrete target is resolved per record: the
target type is read from the raw JSON:API linkage (data.relationships.<rel>.data
= { type, id }) — not a discriminator column, which Forest does not expose as a
field — then mapped to one of polymorphicReferencedModels (exact, then
case-insensitive). resolveTargetCollection centralises static + polymorphic
resolution and is used by buildRelationCandidates, buildTarget and the Branch A
re-entry path.

Depends on the orchestrator exposing polymorphicTypeField /
polymorphicReferencedModels (forestadmin-server PRD-493).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .../src/adapters/agent-client-agent-port.ts   |  42 ++++-
 .../src/executors/agent-with-log.ts           |   9 ++
 .../load-related-record-step-executor.ts      |  74 +++++++--
 .../workflow-executor/src/ports/agent-port.ts |   8 +
 .../src/types/validated/collection.ts         |   3 +
 .../adapters/agent-client-agent-port.test.ts  |  68 ++++++++
 .../test/executors/agent-with-log.test.ts     |  21 +++
 .../load-related-record-step-executor.test.ts | 147 ++++++++++++++++++
 .../integration/workflow-execution.test.ts    |   1 +
 9 files changed, 354 insertions(+), 19 deletions(-)

diff --git a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts
index c478813986..8046319029 100644
--- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts
+++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts
@@ -5,6 +5,7 @@ import type {
   GetRecordQuery,
   GetRelatedDataQuery,
   GetSingleRelatedDataQuery,
+  ResolvePolymorphicTypeQuery,
   UpdateRecordQuery,
 } from '../ports/agent-port';
 import type SchemaCache from '../schema-cache';
@@ -222,9 +223,9 @@ export default class AgentClientAgentPort implements AgentPort {
     }
   }
 
-  private createClient(user: StepUser) {
+  private mintToken(user: StepUser): string {
     // snake_case aliases: Ruby/Python agents splat JWT claims into Caller.new (snake_case kwargs).
-    const token = jsonwebtoken.sign(
+    return jsonwebtoken.sign(
       {
         ...user,
         first_name: user.firstName,
@@ -236,14 +237,49 @@ export default class AgentClientAgentPort implements AgentPort {
       this.authSecret,
       { expiresIn: '5m' },
     );
+  }
 
+  private createClient(user: StepUser) {
     return createRemoteAgentClient({
       url: this.agentUrl,
-      token,
+      token: this.mintToken(user),
       actionEndpoints: this.buildActionEndpoints(),
     });
   }
 
+  // The agent-client deserializer drops relationship `type`, so read the raw record-with-projection
+  // response: `data.relationships.<relation>.data = { type, id }`. No UI-exposed discriminator needed.
+  async resolvePolymorphicType(
+    { collection, id, relation }: ResolvePolymorphicTypeQuery,
+    user: StepUser,
+  ): Promise<{ type: string; id: string } | null> {
+    return this.callAgent('resolvePolymorphicType', async () => {
+      const recordId = id.map(String).join('|');
+      const params = new URLSearchParams({
+        [`fields[${collection}]`]: relation,
+        [`fields[${relation}]`]: 'id',
+        timezone: 'Europe/Paris', // matches HttpRequester's default
+      });
+      const base = this.agentUrl.replace(/\/+$/, '');
+      const response = await fetch(`${base}/forest/${collection}/${recordId}?${params}`, {
+        headers: { Authorization: `Bearer ${this.mintToken(user)}`, Accept: 'application/json' },
+      });
+
+      if (!response.ok) {
+        throw new Error(
+          `resolvePolymorphicType ${collection}/${recordId}: HTTP ${response.status}`,
+        );
+      }
+
+      const body = (await response.json()) as {
+        data?: { relationships?: Record<string, { data?: { type?: string; id?: string } | null }> };
+      };
+      const linkage = body?.data?.relationships?.[relation]?.data;
+
+      return linkage?.type ? { type: String(linkage.type), id: String(linkage.id) } : null;
+    });
+  }
+
   // Hits GET /forest/ (public, no auth required across all agent versions). A 4xx here means
   // the URL points to something that isn't a Forest agent. JWT is validated naturally on first step.
   async probe(): Promise<void> {
diff --git a/packages/workflow-executor/src/executors/agent-with-log.ts b/packages/workflow-executor/src/executors/agent-with-log.ts
index a2f2fb16cd..fa9ef13cb6 100644
--- a/packages/workflow-executor/src/executors/agent-with-log.ts
+++ b/packages/workflow-executor/src/executors/agent-with-log.ts
@@ -6,6 +6,7 @@ import type {
   GetRecordQuery,
   GetRelatedDataQuery,
   GetSingleRelatedDataQuery,
+  ResolvePolymorphicTypeQuery,
   UpdateRecordQuery,
 } from '../ports/agent-port';
 import type SchemaResolver from '../schema-resolver';
@@ -116,6 +117,14 @@ export default class AgentWithLog {
     return this.agentPort.getActionFormInfo(query, this.user);
   }
 
+  // Unaudited passthrough: resolves a polymorphic relation's target type (metadata probe). The
+  // actual related-record load is audited separately, so this records NO activity-log entry.
+  resolvePolymorphicType(
+    query: ResolvePolymorphicTypeQuery,
+  ): Promise<{ type: string; id: string } | null> {
+    return this.agentPort.resolvePolymorphicType(query, this.user);
+  }
+
   // ISO with the browser engine: `list relation "<displayName>"`. The query carries the technical
   // relation name; resolve its displayName from the source schema, falling back to the technical
   // name when the field is absent (resilient to orchestrator schema drift).
diff --git a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts
index ecccf20adc..3ee7e2c02b 100644
--- a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts
+++ b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts
@@ -62,13 +62,20 @@ interface RelationTarget extends RelationRef {
 }
 
 // A relationship reachable from one available record — the unit the AI chooses among.
-// `relatedCollectionName` is guaranteed non-null (buildRelationCandidates filters on it).
+// `relatedCollectionName` is guaranteed non-null (buildRelationCandidates resolves it, statically
+// or — for polymorphic relations — per record from the discriminator).
 interface RelationCandidate {
   record: RecordRef;
   schema: CollectionSchema;
   field: FieldSchema & { relatedCollectionName: string };
 }
 
+// Followable = has a static target (relatedCollectionName) or is a polymorphic relation resolvable
+// per record (polymorphicTypeField names the discriminator). The concrete target is resolved later.
+function isFollowableRelation(field: FieldSchema): boolean {
+  return field.isRelationship && Boolean(field.relatedCollectionName || field.polymorphicTypeField);
+}
+
 export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<LoadRelatedRecordStepDefinition> {
   protected async doExecute(): Promise<StepExecutionResult> {
     // Branch A -- Re-entry after pending execution found in RunStore
@@ -101,7 +108,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
     }
 
     const schema = await this.getCollectionSchema(execution.selectedRecordRef.collectionName);
-    const target = this.buildTarget(schema, fieldName, execution.selectedRecordRef);
+    const target = await this.buildTarget(schema, fieldName, execution.selectedRecordRef);
     const { availableRecordIds, suggestedRecord } = await this.collectCandidateIds(target);
 
     await this.context.runStore.saveStepExecution(this.context.runId, {
@@ -198,12 +205,14 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
       const schema = await this.getCollectionSchema(record.collectionName);
 
       for (const field of schema.fields) {
-        if (field.isRelationship && field.relatedCollectionName) {
-          candidates.push({
-            record,
-            schema,
-            field: { ...field, relatedCollectionName: field.relatedCollectionName },
-          });
+        if (isFollowableRelation(field)) {
+          // eslint-disable-next-line no-await-in-loop
+          const relatedCollectionName = await this.resolveTargetCollection(field, record);
+
+          // A polymorphic relation with no linked target on this record can't be followed → skip it.
+          if (relatedCollectionName) {
+            candidates.push({ record, schema, field: { ...field, relatedCollectionName } });
+          }
         }
       }
     }
@@ -211,18 +220,48 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
     return candidates;
   }
 
-  private buildTarget(
+  // Resolves the concrete target collection of a relation for a given source record, or null when
+  // it can't be followed. Static relations expose `relatedCollectionName`; multi-target polymorphic
+  // ones expose only `polymorphicTypeField` + candidate models, so the linkage type is read per
+  // record and mapped to one of the candidates.
+  private async resolveTargetCollection(
+    field: FieldSchema,
+    record: RecordRef,
+  ): Promise<string | null> {
+    if (field.relatedCollectionName) return field.relatedCollectionName;
+    if (!field.polymorphicTypeField) return null;
+
+    const linkage = await this.context.agent.resolvePolymorphicType({
+      collection: record.collectionName,
+      id: record.recordId,
+      relation: field.fieldName,
+    });
+    const discriminator = linkage?.type;
+    if (!discriminator) return null;
+
+    const models = field.polymorphicReferencedModels ?? [];
+
+    return (
+      models.find(m => m === discriminator) ??
+      models.find(m => m.toLowerCase() === discriminator.toLowerCase()) ??
+      null
+    );
+  }
+
+  private async buildTarget(
     schema: CollectionSchema,
     relationName: string,
     selectedRecordRef: RecordRef,
-  ): RelationTarget {
+  ): Promise<RelationTarget> {
     const field = this.findFieldByTechnicalName(schema, relationName);
 
     if (!field) {
       throw new RelationNotFoundError(relationName, schema.collectionName);
     }
 
-    if (!field.relatedCollectionName) {
+    const relatedCollectionName = await this.resolveTargetCollection(field, selectedRecordRef);
+
+    if (!relatedCollectionName) {
       throw new StepStateError(
         `Step at index ${this.context.stepIndex} could not resolve relatedCollectionName for relation "${relationName}"`,
       );
@@ -233,7 +272,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
       displayName: field.displayName,
       name: field.fieldName,
       relationType: field.relationType,
-      relatedCollectionName: field.relatedCollectionName,
+      relatedCollectionName,
     };
   }
 
@@ -248,7 +287,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
     const { availableRecordIds, suggestedRecord } = await this.collectCandidateIds(target);
 
     const availableFields: RelationRef[] = sourceSchema.fields
-      .filter(f => f.isRelationship)
+      .filter(isFollowableRelation)
       .map(f => ({ name: f.fieldName, displayName: f.displayName }));
 
     await this.context.runStore.saveStepExecution(this.context.runId, {
@@ -395,18 +434,21 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo
       throw new RelatedRecordNotFoundError(selectedRecordRef.collectionName, name);
     }
 
-    // Re-derive relatedCollectionName from the live schema — frontend never sends it.
+    // Re-derive the target collection from the live schema — frontend never sends it. Handles both
+    // static relations and polymorphic ones (resolved per record from the discriminator).
     const schema = await this.getCollectionSchema(selectedRecordRef.collectionName);
     const field = schema.fields.find(f => f.fieldName === name);
+    const relatedCollectionName =
+      field && (await this.resolveTargetCollection(field, selectedRecordRef));
 
-    if (!field?.relatedCollectionName) {
+    if (!relatedCollectionName) {
       throw new StepStateError(
         `Step at index ${this.context.stepIndex} could not resolve relatedCollectionName for relation "${name}"`,
       );
     }
 
     const record: RecordRef = {
-      collectionName: field.relatedCollectionName,
+      collectionName: relatedCollectionName,
       recordId: selectedRecordId,
       stepIndex: this.context.stepIndex,
     };
diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts
index 51ba9e2c4e..f4c7fc2463 100644
--- a/packages/workflow-executor/src/ports/agent-port.ts
+++ b/packages/workflow-executor/src/ports/agent-port.ts
@@ -39,6 +39,8 @@ export type ExecuteActionQuery = { collection: string; action: string; id?: Id[]
 
 export type GetActionFormInfoQuery = { collection: string; action: string; id: Id[] };
 
+export type ResolvePolymorphicTypeQuery = { collection: string; id: Id[]; relation: string };
+
 export interface AgentPort {
   getRecord(query: GetRecordQuery, user: StepUser): Promise<RecordData>;
   updateRecord(query: UpdateRecordQuery, user: StepUser): Promise<RecordData>;
@@ -48,6 +50,12 @@ export interface AgentPort {
     query: GetSingleRelatedDataQuery,
     user: StepUser,
   ): Promise<RecordData | null>;
+  // Reads a polymorphic relation's target from the raw JSON:API linkage ({ type, id }), which the
+  // agent-client deserializer drops. Returns null when the record has no linked target.
+  resolvePolymorphicType(
+    query: ResolvePolymorphicTypeQuery,
+    user: StepUser,
+  ): Promise<{ type: string; id: string } | null>;
   executeAction(query: ExecuteActionQuery, user: StepUser): Promise<unknown>;
   // Old Ruby agents with hooks.load=false return 404; agent-client falls back to the fields
   // passed via ActionEndpointsByCollection (populated from the orchestrator's schema).
diff --git a/packages/workflow-executor/src/types/validated/collection.ts b/packages/workflow-executor/src/types/validated/collection.ts
index ce60ff3a4e..64e79fb5c8 100644
--- a/packages/workflow-executor/src/types/validated/collection.ts
+++ b/packages/workflow-executor/src/types/validated/collection.ts
@@ -49,6 +49,9 @@ export const FieldSchemaSchema = z.object({
   isRelationship: z.boolean(),
   relationType: z.enum(['BelongsTo', 'HasMany', 'HasOne', 'BelongsToMany']).optional(),
   relatedCollectionName: z.string().optional(),
+  // Polymorphic relations: discriminator column + candidate target collections, resolved per record.
+  polymorphicTypeField: z.string().optional(),
+  polymorphicReferencedModels: z.array(z.string()).optional(),
   type: ColumnTypeSchema.nullable().optional(),
   enumValues: z.array(z.string()).min(1).optional(),
 });
diff --git a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts
index d913902ec4..6f8892f1c6 100644
--- a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts
+++ b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts
@@ -823,6 +823,74 @@ describe('AgentClientAgentPort', () => {
     });
   });
 
+  describe('resolvePolymorphicType', () => {
+    let fetchSpy: jest.SpyInstance;
+
+    beforeEach(() => {
+      fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation(jest.fn());
+    });
+
+    afterEach(() => {
+      fetchSpy.mockRestore();
+    });
+
+    function linkageResponse(data: unknown) {
+      return new Response(JSON.stringify({ data: { relationships: { commentable: { data } } } }), {
+        status: 200,
+      });
+    }
+
+    it('reads the linkage type/id and projects the relation on the by-id route', async () => {
+      fetchSpy.mockResolvedValue(linkageResponse({ type: 'orders', id: '99' }));
+
+      const result = await port.resolvePolymorphicType(
+        { collection: 'comments', id: [7], relation: 'commentable' },
+        user,
+      );
+
+      expect(result).toEqual({ type: 'orders', id: '99' });
+
+      const [url, options] = fetchSpy.mock.calls[0];
+      expect(url).toContain('http://localhost:3310/forest/comments/7?');
+      expect(url).toContain(`${encodeURIComponent('fields[comments]')}=commentable`);
+      expect(url).toContain(`${encodeURIComponent('fields[commentable]')}=id`);
+      expect(options.headers.Authorization).toMatch(/^Bearer /);
+    });
+
+    it('joins composite ids with "|" in the by-id route', async () => {
+      fetchSpy.mockResolvedValue(linkageResponse({ type: 'orders', id: '1|2' }));
+
+      await port.resolvePolymorphicType(
+        { collection: 'comments', id: ['tenant-1', 5], relation: 'commentable' },
+        user,
+      );
+
+      expect(fetchSpy.mock.calls[0][0]).toContain('/forest/comments/tenant-1|5?');
+    });
+
+    it('returns null when the relation has no linkage', async () => {
+      fetchSpy.mockResolvedValue(linkageResponse(null));
+
+      const result = await port.resolvePolymorphicType(
+        { collection: 'comments', id: [7], relation: 'commentable' },
+        user,
+      );
+
+      expect(result).toBeNull();
+    });
+
+    it('throws AgentPortError when the agent responds non-2xx', async () => {
+      fetchSpy.mockResolvedValue(new Response(null, { status: 500 }));
+
+      await expect(
+        port.resolvePolymorphicType(
+          { collection: 'comments', id: [7], relation: 'commentable' },
+          user,
+        ),
+      ).rejects.toThrow(AgentPortError);
+    });
+  });
+
   describe('probe', () => {
     let fetchSpy: jest.SpyInstance;
 
diff --git a/packages/workflow-executor/test/executors/agent-with-log.test.ts b/packages/workflow-executor/test/executors/agent-with-log.test.ts
index e0b0482f35..45d35c2ec6 100644
--- a/packages/workflow-executor/test/executors/agent-with-log.test.ts
+++ b/packages/workflow-executor/test/executors/agent-with-log.test.ts
@@ -62,6 +62,7 @@ function makeDeps(overrides: Partial<AgentWithLogDeps> = {}) {
       .mockResolvedValue({ collectionName: 'customers', recordId: [42], values: {} }),
     getRelatedData: jest.fn().mockResolvedValue([]),
     getSingleRelatedData: jest.fn().mockResolvedValue(null),
+    resolvePolymorphicType: jest.fn().mockResolvedValue({ type: 'orders', id: '99' }),
     executeAction: jest.fn().mockResolvedValue({ ok: true }),
     getActionFormInfo: jest.fn().mockResolvedValue({ hasForm: true }),
   } as unknown as AgentPort;
@@ -265,4 +266,24 @@ describe('AgentWithLog', () => {
       expect(activityLogPort.createPending).not.toHaveBeenCalled();
     });
   });
+
+  describe('resolvePolymorphicType (unaudited passthrough)', () => {
+    it('forwards to the agent port with the injected user and emits no activity log', async () => {
+      const { deps, agentPort, activityLogPort } = makeDeps();
+      const agent = new AgentWithLog(deps);
+
+      const result = await agent.resolvePolymorphicType({
+        collection: 'comments',
+        id: [7],
+        relation: 'commentable',
+      });
+
+      expect(agentPort.resolvePolymorphicType).toHaveBeenCalledWith(
+        { collection: 'comments', id: [7], relation: 'commentable' },
+        expect.objectContaining({ id: 1 }),
+      );
+      expect(result).toEqual({ type: 'orders', id: '99' });
+      expect(activityLogPort.createPending).not.toHaveBeenCalled();
+    });
+  });
 });
diff --git a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts
index 289e949497..266d6d46d4 100644
--- a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts
+++ b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts
@@ -86,6 +86,7 @@ function makeMockAgentPort(relatedData: RecordData[] = [makeRelatedRecordData()]
     updateRecord: jest.fn(),
     getRelatedData: jest.fn().mockResolvedValue(relatedData),
     getSingleRelatedData,
+    resolvePolymorphicType: jest.fn().mockResolvedValue(null),
     executeAction: jest.fn(),
   } as unknown as AgentPort;
 }
@@ -328,6 +329,152 @@ describe('LoadRelatedRecordStepExecutor', () => {
     });
   });
 
+  describe('multi-target polymorphic relation (resolved per record)', () => {
+    // Customers' only relation is polymorphic: no relatedCollectionName, just the discriminator
+    // column + candidate models. The concrete target is read per record at follow time.
+    function makePolymorphicSchema(models: string[] = ['orders', 'addresses']): CollectionSchema {
+      return makeCollectionSchema({
+        fields: [
+          { fieldName: 'email', displayName: 'Email', isRelationship: false },
+          {
+            fieldName: 'imageable',
+            displayName: 'Imageable',
+            isRelationship: true,
+            relationType: 'BelongsTo',
+            polymorphicTypeField: 'imageable_type',
+            polymorphicReferencedModels: models,
+          },
+        ],
+      });
+    }
+
+    it('reads the discriminator linkage and follows into the resolved collection', async () => {
+      const agentPort = makeMockAgentPort(); // getSingleRelatedData → orders #99
+      (agentPort.resolvePolymorphicType as jest.Mock).mockResolvedValue({
+        type: 'orders',
+        id: '99',
+      });
+      const runStore = makeMockRunStore();
+      const context = makeContext({
+        agentPort,
+        runStore,
+        workflowPort: makeMockWorkflowPort({ customers: makePolymorphicSchema() }),
+        stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }),
+      });
+      const executor = new LoadRelatedRecordStepExecutor(context);
+
+      const result = await executor.execute();
+
+      expect(result.stepOutcome.status).toBe('success');
+      // The discriminator is read from the linkage on the source record...
+      expect(agentPort.resolvePolymorphicType).toHaveBeenCalledWith(
+        { collection: 'customers', id: [42], relation: 'imageable' },
+        expect.objectContaining({ id: 1 }),
+      );
+      // ...and "orders" becomes the target collection the record is fetched from.
+      expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith(
+        expect.objectContaining({
+          relation: 'imageable',
+          relatedSchema: expect.objectContaining({ collectionName: 'orders' }),
+        }),
+        expect.anything(),
+      );
+      expect(runStore.saveStepExecution).toHaveBeenCalledWith(
+        'run-1',
+        expect.objectContaining({
+          executionResult: expect.objectContaining({
+            record: expect.objectContaining({ collectionName: 'orders', recordId: ['99'] }),
+          }),
+        }),
+      );
+    });
+
+    it('maps the discriminator to a candidate case-insensitively (e.g. "Orders" → "orders")', async () => {
+      const agentPort = makeMockAgentPort();
+      (agentPort.resolvePolymorphicType as jest.Mock).mockResolvedValue({
+        type: 'Orders',
+        id: '99',
+      });
+      const context = makeContext({
+        agentPort,
+        workflowPort: makeMockWorkflowPort({ customers: makePolymorphicSchema(['orders']) }),
+        stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }),
+      });
+
+      const result = await new LoadRelatedRecordStepExecutor(context).execute();
+
+      expect(result.stepOutcome.status).toBe('success');
+      expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith(
+        expect.objectContaining({
+          relatedSchema: expect.objectContaining({ collectionName: 'orders' }),
+        }),
+        expect.anything(),
+      );
+    });
+
+    it('errors when the only relation is polymorphic and has no linked target on the record', async () => {
+      const agentPort = makeMockAgentPort();
+      (agentPort.resolvePolymorphicType as jest.Mock).mockResolvedValue(null); // no linkage
+      const context = makeContext({
+        agentPort,
+        workflowPort: makeMockWorkflowPort({ customers: makePolymorphicSchema() }),
+        stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }),
+      });
+
+      const result = await new LoadRelatedRecordStepExecutor(context).execute();
+
+      expect(result.stepOutcome.status).toBe('error');
+      expect(agentPort.getSingleRelatedData).not.toHaveBeenCalled();
+    });
+
+    it('errors when the discriminator value is not among the candidate models', async () => {
+      const agentPort = makeMockAgentPort();
+      (agentPort.resolvePolymorphicType as jest.Mock).mockResolvedValue({
+        type: 'comments',
+        id: '1',
+      });
+      const context = makeContext({
+        agentPort,
+        workflowPort: makeMockWorkflowPort({
+          customers: makePolymorphicSchema(['orders', 'addresses']),
+        }),
+        stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }),
+      });
+
+      const result = await new LoadRelatedRecordStepExecutor(context).execute();
+
+      expect(result.stepOutcome.status).toBe('error');
+      expect(agentPort.getSingleRelatedData).not.toHaveBeenCalled();
+    });
+
+    it('does not offer an unfollowable relation (no relatedCollectionName, not polymorphic)', async () => {
+      const agentPort = makeMockAgentPort();
+      const schema = makeCollectionSchema({
+        fields: [
+          { fieldName: 'email', displayName: 'Email', isRelationship: false },
+          // Relationship with neither a static target nor a polymorphic discriminator → unfollowable.
+          {
+            fieldName: 'ghost',
+            displayName: 'Ghost',
+            isRelationship: true,
+            relationType: 'BelongsTo',
+          },
+        ],
+      });
+      const context = makeContext({
+        agentPort,
+        workflowPort: makeMockWorkflowPort({ customers: schema }),
+        stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }),
+      });
+
+      const result = await new LoadRelatedRecordStepExecutor(context).execute();
+
+      expect(result.stepOutcome.status).toBe('error');
+      expect(agentPort.resolvePolymorphicType).not.toHaveBeenCalled();
+      expect(agentPort.getSingleRelatedData).not.toHaveBeenCalled();
+    });
+  });
+
   describe('executionType=FullyAutomated: HasMany — 2 AI calls (Branch B)', () => {
     it('runs selectRelevantFields + selectBestRecord to pick the best candidate', async () => {
       const hasManySchema = makeCollectionSchema({
diff --git a/packages/workflow-executor/test/integration/workflow-execution.test.ts b/packages/workflow-executor/test/integration/workflow-execution.test.ts
index 08f8b99514..cd340b437d 100644
--- a/packages/workflow-executor/test/integration/workflow-execution.test.ts
+++ b/packages/workflow-executor/test/integration/workflow-execution.test.ts
@@ -164,6 +164,7 @@ function createMockAgentPort(): jest.Mocked<AgentPort> {
     }),
     getRelatedData: jest.fn().mockResolvedValue([]),
     getSingleRelatedData: jest.fn().mockResolvedValue(null),
+    resolvePolymorphicType: jest.fn().mockResolvedValue(null),
     executeAction: jest.fn().mockResolvedValue(undefined),
     getActionFormInfo: jest.fn().mockResolvedValue({ hasForm: false }),
     probe: jest.fn().mockResolvedValue(undefined),
