From 3546c3ab1e339b84f735e75b906d0f5b089bde16 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Tue, 16 Jun 2026 23:58:08 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(automation):=20object-form=20screen-fl?= =?UTF-8?q?ow=20steps=20+=20CRM=20lead=E2=86=92customer/opportunity=20wiza?= =?UTF-8?q?rd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the screen-flow runtime so a `screen` node can render a target object's FULL create/edit form — including inline master-detail child grids — instead of a flat field list. A step that declares `config.objectName` emits an `object-form` ScreenSpec; the client renders the real ObjectForm, persists the record (and its children, atomically), and resumes the run with the new id bound to `config.idVariable` so a later step can reference it. CRM example: convert-lead.flow is now a two-step wizard — Step 1 a full Customer (Account) form prefilled from the lead, Step 2 a full Opportunity form WITH an editable product line-item grid (new crm_opportunity_line_item, master_detail + inlineEdit:'grid'; Opportunity gains a line_total rollup). The flow then marks the lead converted and links it to the new account + opportunity. Also fixes get_lead: get_record reads `config.filter`, not a bare `config.recordId`, so the node now filters by `{ id: '{recordId}' }` instead of silently returning the first record. - spec: ScreenSpec gains kind/objectName/mode/recordId/defaults/idVariable - service-automation: screen node emits object-form specs + interpolates title/description/field-defaults/object-form-defaults against flow vars - app-crm: new line-item object, Opportunity line_total summary, rewritten convert-lead wizard, smoke test updated for the 6th object Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app-crm/src/flows/convert-lead.flow.ts | 212 +++++++----------- examples/app-crm/src/objects/index.ts | 1 + .../objects/opportunity-line-item.object.ts | 64 ++++++ .../app-crm/src/objects/opportunity.object.ts | 8 + examples/app-crm/test/smoke.test.ts | 11 +- .../src/builtin/screen-nodes.ts | 52 ++++- .../spec/src/contracts/automation-service.ts | 25 +++ 7 files changed, 231 insertions(+), 142 deletions(-) create mode 100644 examples/app-crm/src/objects/opportunity-line-item.object.ts diff --git a/examples/app-crm/src/flows/convert-lead.flow.ts b/examples/app-crm/src/flows/convert-lead.flow.ts index 7ad40973f2..8a7755e621 100644 --- a/examples/app-crm/src/flows/convert-lead.flow.ts +++ b/examples/app-crm/src/flows/convert-lead.flow.ts @@ -3,64 +3,65 @@ import { defineFlow } from '@objectstack/spec'; /** - * Convert Lead to Opportunity — Screen Flow + * Convert Lead → Customer + Opportunity — Master-Detail Screen Flow * - * A user-triggered wizard that walks a sales rep through converting a - * qualified lead into an Opportunity record. Demonstrates every key - * screen-flow node type: + * A user-triggered wizard that walks a sales rep through converting a qualified + * lead into a full Account + Opportunity. Unlike a flat field-list wizard, each + * step renders the target object's COMPLETE create form via an `object-form` + * screen node (`config.objectName`): the client renders the real ObjectForm — + * including inline master-detail child grids — persists the record (and its + * children, atomically), and resumes the run with the new record's id bound to + * `config.idVariable`. * * start → get_lead → decision (already converted?) - * → already_converted_screen (abort path) - * → screen_qualify (collect opportunity details from rep) - * → screen_confirm (review + confirm before writing) - * → create_opp (create crm_opportunity) - * → update_lead (mark lead as converted) - * → screen_success (celebrate + navigate) + * → screen_already_converted (abort path) + * → screen_account (Step 1 — full Customer form → account_id) + * → screen_opportunity (Step 2 — full Opportunity form WITH product + * line-items grid, prefilled account → opportunity_id) + * → update_lead (mark converted + link account & opportunity) * → end */ export const ConvertLeadScreenFlow = defineFlow({ name: 'crm_convert_lead_wizard', - label: 'Convert Lead to Opportunity', + label: 'Convert Lead to Customer + Opportunity', description: - 'Screen flow wizard that converts a qualified CRM lead into an Opportunity, marks the lead as converted, and links the two records.', + 'Screen-flow wizard that walks the rep through a full Customer form then a full Opportunity form (with product line items), creates both, and marks the lead converted.', type: 'screen', status: 'active', runAs: 'user', variables: [ - // ── inputs (from action trigger) ─────────────────────────────────── - { name: 'recordId', type: 'text', isInput: true, isOutput: false }, - // ── screen-collected inputs (filled by wizard steps) ────────────── - // Marked isInput:true so the trigger API can pre-populate them for - // automated testing or when the Studio wizard submits all values at once. - { name: 'opp_name', type: 'text', isInput: true, isOutput: false }, - { name: 'opp_account', type: 'text', isInput: true, isOutput: false }, - { name: 'opp_amount', type: 'number', isInput: true, isOutput: false }, - { name: 'opp_close_date', type: 'date', isInput: true, isOutput: false }, - { name: 'opp_stage', type: 'text', isInput: true, isOutput: false }, - // ── intermediate (populated at runtime) ─────────────────────────── - { name: 'lead_record', type: 'object', isInput: false, isOutput: false }, - // ── outputs ─────────────────────────────────────────────────────── - { name: 'opportunity_id', type: 'text', isInput: false, isOutput: true }, + // ── input (from the action trigger) ─────────────────────────────────── + { name: 'recordId', type: 'text', isInput: true, isOutput: false }, + // ── intermediate (populated at runtime) ─────────────────────────────── + { name: 'lead_record', type: 'object', isInput: false, isOutput: false }, + // ── produced by the object-form steps (the saved record ids) ────────── + // `isInput: true` lets the trigger API pre-populate them for automated + // testing; at runtime each is bound by its step's `idVariable` on resume. + { name: 'account_id', type: 'text', isInput: true, isOutput: true }, + { name: 'opportunity_id', type: 'text', isInput: true, isOutput: true }, ], nodes: [ - // ── 1. Start ────────────────────────────────────────────────────── + // ── 1. Start ────────────────────────────────────────────────────────── { id: 'start', type: 'start', label: 'Start' }, - // ── 2. Load the lead record ─────────────────────────────────────── + // ── 2. Load the lead record ─────────────────────────────────────────── { id: 'get_lead', type: 'get_record', label: 'Load Lead', config: { objectName: 'crm_lead', - recordId: '{recordId}', + // get_record filters via `filter` (it ignores a bare `recordId`), so + // load THIS lead by id — otherwise findOne with an empty filter returns + // the first lead in the table. + filter: { id: '{recordId}' }, outputVariable: 'lead_record', }, }, - // ── 3. Guard: already converted? ────────────────────────────────── + // ── 3. Guard: already converted? ────────────────────────────────────── { id: 'check_converted', type: 'decision', @@ -73,102 +74,60 @@ export const ConvertLeadScreenFlow = defineFlow({ }, }, - // ── 3a. Already-converted abort screen ──────────────────────────── + // ── 3a. Already-converted abort screen ──────────────────────────────── { id: 'screen_already_converted', type: 'screen', label: 'Already Converted', config: { - message: 'This lead has already been converted to an opportunity.', - buttons: [ - { label: 'Close', action: 'finish' }, - ], + waitForInput: true, + title: 'Already Converted', + description: 'This lead has already been converted to an opportunity.', }, }, - // ── 4. Screen 1: Collect qualification + opportunity details ─────── + // ── 4. Step 1 — full Customer (Account) form ────────────────────────── + // `objectName` ⇒ object-form screen: the client renders the real + // crm_account create form, persists it, and resumes with the new id under + // `account_id`. { - id: 'screen_qualify', + id: 'screen_account', type: 'screen', - label: 'Opportunity Details', + label: 'Customer', config: { - fields: [ - { - name: 'opp_name', - label: 'Opportunity Name', - type: 'text', - required: true, - defaultValue: '{lead_record.name}', - helpText: 'Descriptive name for the new opportunity', - }, - { - name: 'opp_account', - label: 'Account', - type: 'lookup', - object: 'crm_account', - required: false, - defaultValue: '{lead_record.account}', - helpText: 'The account this opportunity belongs to', - }, - { - name: 'opp_amount', - label: 'Estimated Value ($)', - type: 'number', - required: false, - min: 0, - }, - { - name: 'opp_close_date', - label: 'Expected Close Date', - type: 'date', - required: false, - }, - { - name: 'opp_stage', - label: 'Stage', - type: 'select', - required: true, - options: ['prospecting', 'qualification', 'proposal', 'closed_won', 'closed_lost'], - defaultValue: 'qualification', - }, - ], + objectName: 'crm_account', + idVariable: 'account_id', + title: 'Step 1 of 2 · Customer', + description: 'Review and complete the customer record carried over from the lead.', + defaults: { + name: '{lead_record.company}', + }, }, }, - // ── 5. Screen 2: Confirm before write ───────────────────────────── + // ── 5. Step 2 — full Opportunity form WITH product line items ────────── + // crm_opportunity_line_item.opportunity declares inlineEdit: 'grid', so the + // standard Opportunity form (and therefore this step) renders an editable + // product line-item grid below the header — the master-detail entry the + // user asked for. The account FK is prefilled with Step 1's new id. { - id: 'screen_confirm', + id: 'screen_opportunity', type: 'screen', - label: 'Confirm Conversion', - config: { - message: 'Create opportunity "{opp_name}" for lead "{lead_record.name}"?\n\nStage: {opp_stage} · Amount: ${opp_amount} · Close: {opp_close_date}', - buttons: [ - { label: 'Convert', action: 'next' }, - { label: 'Back', action: 'back' }, - { label: 'Cancel', action: 'finish' }, - ], - }, - }, - - // ── 6. Create the Opportunity ───────────────────────────────────── - { - id: 'create_opp', - type: 'create_record', - label: 'Create Opportunity', + label: 'Opportunity', config: { objectName: 'crm_opportunity', - fields: { - name: '{opp_name}', - account: '{opp_account}', - amount: '{opp_amount}', - close_date: '{opp_close_date}', - stage: '{opp_stage}', + idVariable: 'opportunity_id', + title: 'Step 2 of 2 · Opportunity', + description: 'Add the opportunity and its product line items. Saved together in one transaction.', + defaults: { + name: '{lead_record.name}', + account: '{account_id}', + stage: 'qualification', }, - outputVariable: 'opportunity_id', }, }, - // ── 7. Mark the lead as converted ───────────────────────────────── + // ── 6. Mark the lead converted + link both new records ──────────────── { id: 'update_lead', type: 'update_record', @@ -177,47 +136,28 @@ export const ConvertLeadScreenFlow = defineFlow({ objectName: 'crm_lead', filter: { id: '{recordId}' }, fields: { - status: 'converted', - converted_opportunity: '{opportunity_id}', - is_closed: true, + status: 'converted', + account: '{account_id}', + converted_opportunity: '{opportunity_id}', }, }, }, - // ── 8. Success screen ───────────────────────────────────────────── - { - id: 'screen_success', - type: 'screen', - label: 'Conversion Complete', - config: { - message: '✅ Lead converted! Opportunity "{opp_name}" has been created.', - buttons: [ - { label: 'View Opportunity', action: 'navigate', target: '/crm_opportunity/{opportunity_id}' }, - { label: 'Done', action: 'finish' }, - ], - }, - }, - - // ── 9. End ──────────────────────────────────────────────────────── + // ── 7. End ──────────────────────────────────────────────────────────── { id: 'end', type: 'end', label: 'End' }, ], edges: [ - { id: 'e1', source: 'start', target: 'get_lead', type: 'default' }, - { id: 'e2', source: 'get_lead', target: 'check_converted', type: 'default' }, + { id: 'e1', source: 'start', target: 'get_lead', type: 'default' }, + { id: 'e2', source: 'get_lead', target: 'check_converted', type: 'default' }, // guard branches - { id: 'e3a', source: 'check_converted', target: 'screen_already_converted', type: 'default', condition: "lead_record.status == 'converted'", label: 'Yes' }, - { id: 'e3b', source: 'check_converted', target: 'screen_qualify', type: 'default', label: 'No' }, - { id: 'e3c', source: 'screen_already_converted', target: 'end', type: 'default' }, - // main path - { id: 'e4', source: 'screen_qualify', target: 'screen_confirm', type: 'default' }, - // "Convert" proceeds to create the record; "Back" is handled client-side - // by the screen-flow runner's history stack — no server back-edge needed - // (a back-edge would create a cycle and fail DAG validation). - { id: 'e5', source: 'screen_confirm', target: 'create_opp', type: 'default' }, - { id: 'e7', source: 'create_opp', target: 'update_lead', type: 'default' }, - { id: 'e8', source: 'update_lead', target: 'screen_success', type: 'default' }, - { id: 'e9', source: 'screen_success', target: 'end', type: 'default' }, + { id: 'e3a', source: 'check_converted', target: 'screen_already_converted', type: 'default', condition: "lead_record.status == 'converted'", label: 'Yes' }, + { id: 'e3b', source: 'check_converted', target: 'screen_account', type: 'default', label: 'No' }, + { id: 'e3c', source: 'screen_already_converted', target: 'end', type: 'default' }, + // main path — full Customer form → full Opportunity form → link + { id: 'e4', source: 'screen_account', target: 'screen_opportunity', type: 'default' }, + { id: 'e5', source: 'screen_opportunity', target: 'update_lead', type: 'default' }, + { id: 'e6', source: 'update_lead', target: 'end', type: 'default' }, ], errorHandling: { diff --git a/examples/app-crm/src/objects/index.ts b/examples/app-crm/src/objects/index.ts index c11c36b6b2..417a366953 100644 --- a/examples/app-crm/src/objects/index.ts +++ b/examples/app-crm/src/objects/index.ts @@ -3,5 +3,6 @@ export { Account } from './account.object.js'; export { Contact } from './contact.object.js'; export { Opportunity } from './opportunity.object.js'; +export { OpportunityLineItem } from './opportunity-line-item.object.js'; export { Lead } from './lead.object.js'; export { Activity } from './activity.object.js'; diff --git a/examples/app-crm/src/objects/opportunity-line-item.object.ts b/examples/app-crm/src/objects/opportunity-line-item.object.ts new file mode 100644 index 0000000000..c268f5bd82 --- /dev/null +++ b/examples/app-crm/src/objects/opportunity-line-item.object.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { cel } from '@objectstack/spec'; + +/** + * Opportunity Line Item — the product/quantity detail rows of an Opportunity. + * + * This is the canonical master-detail "header + line items" shape (mirrors the + * showcase Invoice ↔ InvoiceLine pair): an opportunity is quoted together with + * the products on it, entered in ONE atomic transaction. So the back-pointer + * `opportunity` field declares `inlineEdit: 'grid'` — every standard New/Edit + * Opportunity form (and the lead-conversion wizard's Opportunity step) renders + * an editable line-item grid, and the parent `Opportunity.line_total` rolls the + * line amounts up server-side. + */ +export const OpportunityLineItem = ObjectSchema.create({ + name: 'crm_opportunity_line_item', + label: 'Line Item', + pluralLabel: 'Line Items', + icon: 'list', + description: 'A single product line on an opportunity quote.', + + fields: { + opportunity: Field.masterDetail('crm_opportunity', { + label: 'Opportunity', + required: true, + deleteBehavior: 'cascade', + // Thin, high-volume product rows → the editable grid form factor. + inlineEdit: 'grid', + inlineTitle: 'Products', + inlineAmountField: 'amount', + }), + product: Field.text({ + label: 'Product', + required: true, + maxLength: 200, + }), + quantity: Field.number({ + label: 'Qty', + required: true, + min: 1, + defaultValue: 1, + }), + unit_price: Field.currency({ + label: 'Unit Price', + scale: 2, + min: 0, + }), + // Amount = Qty × Unit Price. Kept as a *stored* currency column (so the + // parent Opportunity.line_total summary can roll it up — summary aggregation + // reads stored columns, not on-read formula fields), but the `expression` + // makes the line-item grid render it READ-ONLY and recompute it live + // client-side as quantity/unit_price change, then persist the computed + // value. The server stores the client-sent value as-is. (Mirrors the + // showcase InvoiceLine.amount pattern.) + amount: Field.currency({ + label: 'Amount', + scale: 2, + min: 0, + expression: cel`record.quantity * record.unit_price`, + }), + }, +}); diff --git a/examples/app-crm/src/objects/opportunity.object.ts b/examples/app-crm/src/objects/opportunity.object.ts index 7ec8d1bf7e..6271837233 100644 --- a/examples/app-crm/src/objects/opportunity.object.ts +++ b/examples/app-crm/src/objects/opportunity.object.ts @@ -78,6 +78,14 @@ export const Opportunity = ObjectSchema.create({ renewal_of: Field.lookup('crm_opportunity', { label: 'Renewal Of', }), + // Roll-up of the opportunity's product line items — recomputed server-side + // as lines are inserted/updated/deleted (child FK auto-detected: + // crm_opportunity_line_item.opportunity). The line-item grid also shows a + // live running total during entry (inlineAmountField: 'amount'). + line_total: Field.summary({ + label: 'Products Total', + summaryOperations: { object: 'crm_opportunity_line_item', field: 'amount', function: 'sum' }, + }), }, validations: [ diff --git a/examples/app-crm/test/smoke.test.ts b/examples/app-crm/test/smoke.test.ts index 93fec78a3b..b147c61da5 100644 --- a/examples/app-crm/test/smoke.test.ts +++ b/examples/app-crm/test/smoke.test.ts @@ -11,9 +11,16 @@ describe('app-crm minimal metadata bundle', () => { expect(stack.manifest.type).toBe('app'); }); - it('registers the 5 core objects', () => { + it('registers the 6 core objects', () => { const names = (stack.objects ?? []).map((o) => o.name).sort(); - expect(names).toEqual(['crm_account', 'crm_activity', 'crm_contact', 'crm_lead', 'crm_opportunity']); + expect(names).toEqual([ + 'crm_account', + 'crm_activity', + 'crm_contact', + 'crm_lead', + 'crm_opportunity', + 'crm_opportunity_line_item', + ]); }); it('registers exactly one app, one dashboard, one hook, and at least 4 flows', () => { diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index c9c18af4ce..b3fb82c2ed 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -44,8 +44,52 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext // Human-input nodes suspend the flow awaiting input. supportsPause: true, isAsync: true, }), - async execute(node, _variables, _context) { + async execute(node, variables, context) { const cfg = (node.config ?? {}) as Record; + // `{var}` tokens in screen config resolve against the live flow + // variables here (the engine does NOT pre-interpolate node config) — so + // a step's title/description/field-default/object-form-default can pull + // from prior nodes (e.g. `{lead_record.company}`, `{account_id}`). + const interp = (v: unknown): string | undefined => { + if (v == null) return undefined; + const r = interpolate(v, variables, context); + return r == null ? undefined : String(r); + }; + + // ── Object-form screen (master-detail wizards) ────────────────────── + // When the step names an `objectName`, render that object's FULL + // create/edit form — including any inline master-detail child grids — + // instead of a flat field list. The client persists the record (and its + // children, atomically) and resumes the run with the new id bound to + // `idVariable`, so a later step can reference it (e.g. an Opportunity + // step prefilling its `account` FK from the Customer step's new id). + const objectName = + typeof cfg.objectName === 'string' && cfg.objectName.trim() ? cfg.objectName.trim() : undefined; + if (objectName) { + const defaults = + cfg.defaults && typeof cfg.defaults === 'object' + ? (interpolate(cfg.defaults, variables, context) as Record) + : undefined; + const idVariable = + typeof cfg.idVariable === 'string' && cfg.idVariable.trim() ? cfg.idVariable.trim() : undefined; + return { + success: true, + suspend: true, + screen: { + nodeId: node.id, + kind: 'object-form', + title: interp(cfg.title) ?? node.label ?? objectName, + description: interp(cfg.description), + objectName, + mode: cfg.mode === 'edit' ? 'edit' : 'create', + recordId: cfg.recordId != null ? interp(cfg.recordId) : undefined, + defaults, + idVariable, + fields: [], + }, + }; + } + const rawFields = Array.isArray(cfg.fields) ? (cfg.fields as Array>) : []; const hasFields = rawFields.length > 0; // Suspend to collect input when the screen declares fields, or opts in @@ -60,7 +104,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext type: f.type != null ? String(f.type) : undefined, required: f.required === true, options: Array.isArray(f.options) ? (f.options as Array<{ value: unknown; label: string }>) : undefined, - defaultValue: f.defaultValue, + defaultValue: f.defaultValue !== undefined ? interpolate(f.defaultValue, variables, context) : undefined, placeholder: f.placeholder != null ? String(f.placeholder) : undefined, })).filter((f) => f.name.length > 0); return { @@ -68,8 +112,8 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext suspend: true, screen: { nodeId: node.id, - title: (cfg.title as string | undefined) ?? node.label ?? 'Input', - description: cfg.description as string | undefined, + title: interp(cfg.title) ?? node.label ?? 'Input', + description: interp(cfg.description), fields, }, }; diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 4b6eb20939..94c4eda0ff 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -64,6 +64,31 @@ export interface ScreenSpec { title?: string; description?: string; fields: ScreenFieldSpec[]; + /** + * Rendering kind. `'fields'` (default) renders the flat {@link fields} list. + * `'object-form'` renders an object's full create/edit form — including any + * inline master-detail child grids (`subforms`) — so a screen-flow wizard + * can walk the user through one full object form per step (e.g. lead + * conversion: a Customer step, then an Opportunity-with-line-items step). + * The client renders the object form; on save it persists the record (and + * its children, atomically) itself, then resumes the run with the new + * record's id bound to {@link idVariable}. + */ + kind?: 'fields' | 'object-form'; + /** Object whose form to render (object-form screens). */ + objectName?: string; + /** Form mode for an object-form screen — defaults to `'create'`. */ + mode?: 'create' | 'edit'; + /** Record id to edit (object-form screens in `'edit'` mode). */ + recordId?: string; + /** Prefilled field values for the object form (already interpolated). */ + defaults?: Record; + /** + * Flow variable that receives the saved record's id when the client resumes + * the run — so a later step can reference it (e.g. the Opportunity form + * prefilling its `account` FK with the id created by the Customer step). + */ + idVariable?: string; } /** From 2a4ae7be4601436855732f626571d5a64b3fcd2a Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 17 Jun 2026 00:02:38 +0800 Subject: [PATCH 2/2] chore: changeset for object-form screen wizard Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/object-form-screen-wizard.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/object-form-screen-wizard.md diff --git a/.changeset/object-form-screen-wizard.md b/.changeset/object-form-screen-wizard.md new file mode 100644 index 0000000000..6fb3dc7dae --- /dev/null +++ b/.changeset/object-form-screen-wizard.md @@ -0,0 +1,20 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-automation": minor +--- + +feat(automation): object-form screen-flow steps + +A `screen` node that declares `config.objectName` now renders the named object's +FULL create/edit form (including inline master-detail child grids) instead of a +flat field list. The node emits an `object-form` `ScreenSpec` +(`kind`/`objectName`/`mode`/`recordId`/`defaults`/`idVariable`); the client +renders the real ObjectForm, persists the record (and its children, atomically), +and resumes the run with the saved id bound to `idVariable` so a later step can +reference it — e.g. a lead-conversion wizard: a full Customer step, then a full +Opportunity-with-line-items step. + +- **spec**: `ScreenSpec` gains `kind`/`objectName`/`mode`/`recordId`/`defaults`/`idVariable`. +- **service-automation**: the `screen` executor emits object-form specs and now + interpolates `title`/`description`/field `defaultValue`/object-form `defaults` + against live flow variables (the engine does not pre-interpolate node config).