@@ -289,6 +289,24 @@ export type FlowUserGrantsResolver = (
289289 tenantId : string | undefined ,
290290) => Promise < FlowUserGrants | undefined > | FlowUserGrants | undefined ;
291291
292+ /**
293+ * Re-reads the specified single-hop lookup relations of a record-change flow's
294+ * triggering record so `{record.<lookup>.<field>}` templates can traverse them
295+ * (#3475). Injected by the host — the automation plugin bridges it to a
296+ * data-engine `findOne(..., { expand, context })` scoped by the run's identity
297+ * ({@link resolveRunDataContext}), so the referenced object's RLS/FLS are
298+ * enforced as the RUN (never system-elevated for a `runAs:'user'` run). Returns
299+ * the re-read record (with the requested relations expanded to objects) or
300+ * `undefined`; only the declared relation keys are grafted onto the run record.
301+ * When unwired, lookup traversal stays unresolved (the pre-#3475 behavior).
302+ */
303+ export type FlowRecordExpander = (
304+ objectName : string ,
305+ id : unknown ,
306+ expandFields : readonly string [ ] ,
307+ runContext : AutomationContext ,
308+ ) => Promise < Record < string , unknown > | undefined > | Record < string , unknown > | undefined ;
309+
292310/**
293311 * A designer-facing view of one connector action — identity + its JSON-Schema
294312 * input/output. The runtime handler is intentionally omitted; this is metadata.
@@ -667,6 +685,9 @@ export class AutomationEngine implements IAutomationService {
667685 /** Bridge to the host authz resolver so a `runAs:'user'` run enforces the
668686 * triggering user's real grants (#3356), if wired. See {@link FlowUserGrantsResolver}. */
669687 private userGrantsResolver : FlowUserGrantsResolver | null = null ;
688+ /** Bridge to a host data read that expands declared lookup relations on the
689+ * run record (#3475), if wired. See {@link FlowRecordExpander}. */
690+ private recordExpander : FlowRecordExpander | null = null ;
670691 private executionLogs : ExecutionLogEntry [ ] = [ ] ;
671692 private readonly maxLogSize : number ;
672693 private logger : Logger ;
@@ -1136,6 +1157,16 @@ export class AutomationEngine implements IAutomationService {
11361157 this . userGrantsResolver = resolver ;
11371158 }
11381159
1160+ /**
1161+ * Wire the record lookup-expander (#3475). The automation plugin bridges it
1162+ * to a `findOne(..., { expand, context })` on the same data engine the CRUD
1163+ * nodes use, scoped by the run's identity. Passing `null` detaches it (lookup
1164+ * traversal in templates then stays unresolved).
1165+ */
1166+ setRecordExpander ( expander : FlowRecordExpander | null ) : void {
1167+ this . recordExpander = expander ;
1168+ }
1169+
11391170 /**
11401171 * Resolve a named function for a `script` node. Returns `undefined` when no
11411172 * resolver is wired or the name is unregistered — the node then fails the
@@ -1570,9 +1601,69 @@ export class AutomationEngine implements IAutomationService {
15701601 `(ADR-0049, #1888).` ,
15711602 ) ;
15721603 }
1604+
1605+ // #3475 — opt-in single-hop lookup expansion, AFTER identity resolution so
1606+ // the re-read runs as this run's own principal (see helper).
1607+ await this . expandDeclaredLookups ( flow , runContext ) ;
15731608 return runContext ;
15741609 }
15751610
1611+ /**
1612+ * Enrich `runContext.record` with opt-in single-hop lookup expansions the
1613+ * start node declares as `config.expand: string[]` (#3475). Re-reads just
1614+ * those relations through the injected {@link setRecordExpander} — which the
1615+ * host wires to a data-engine read scoped by {@link resolveRunDataContext},
1616+ * so the referenced object's RLS/FLS are enforced as the RUN's identity (the
1617+ * triggering user for `runAs:'user'`, elevated for `runAs:'system'`). Grafts
1618+ * ONLY the declared relation keys, and only when the re-read actually returned
1619+ * an object/array, so bare lookup ids and #1872 multi-lookup arrays on other
1620+ * relations — and the formula fields the trigger already hydrated — stay
1621+ * untouched. Mutates `record` in place (the same object the run's variable map
1622+ * already references). Best-effort: any failure leaves `record` unexpanded
1623+ * (the template then renders the scalar id) — expansion must never break the
1624+ * flow it feeds.
1625+ */
1626+ private async expandDeclaredLookups ( flow : FlowParsed , runContext : AutomationContext ) : Promise < void > {
1627+ if ( ! this . recordExpander ) return ;
1628+ const record = runContext . record as Record < string , unknown > | undefined ;
1629+ const id = record ?. id ;
1630+ const object = runContext . object ;
1631+ if ( ! record || id == null || id === '' || ! object ) return ;
1632+
1633+ const startNode = flow . nodes . find ( ( n ) => n . type === 'start' ) ;
1634+ const raw = ( startNode ?. config as { expand ?: unknown } | undefined ) ?. expand ;
1635+ const expandFields =
1636+ typeof raw === 'string'
1637+ ? raw
1638+ ? [ raw ]
1639+ : [ ]
1640+ : Array . isArray ( raw )
1641+ ? raw . filter ( ( f ) : f is string => typeof f === 'string' && f . length > 0 )
1642+ : [ ] ;
1643+ if ( expandFields . length === 0 ) return ;
1644+
1645+ try {
1646+ const full = await this . recordExpander ( object , id , expandFields , runContext ) ;
1647+ if ( full && typeof full === 'object' ) {
1648+ for ( const field of expandFields ) {
1649+ const expanded = ( full as Record < string , unknown > ) [ field ] ;
1650+ // Graft only a genuinely expanded relation (object/array); a
1651+ // scalar means the field is not a resolvable lookup — leave the
1652+ // raw id in place rather than overwrite it.
1653+ if ( expanded !== null && typeof expanded === 'object' ) {
1654+ record [ field ] = expanded ;
1655+ }
1656+ }
1657+ }
1658+ } catch ( err ) {
1659+ this . logger . warn (
1660+ `[expand] flow '${ flow . name } ' could not expand lookups [${ expandFields . join ( ', ' ) } ] on ` +
1661+ `'${ object } #${ String ( id ) } ': ${ ( err as Error ) ?. message ?? String ( err ) } . Templates referencing ` +
1662+ `these relations resolve to the scalar id.` ,
1663+ ) ;
1664+ }
1665+ }
1666+
15761667 async execute ( flowName : string , context ?: AutomationContext ) : Promise < AutomationResult > {
15771668 const startTime = Date . now ( ) ;
15781669 const flow = this . flows . get ( flowName ) ;
0 commit comments