RFC: User Variable Model for Workflow Authors¶
- Status: Draft
- Scope: Workflow designer, shared schemas, engine run creation, catalog submit endpoint, portal catalog form
- Related code:
packages/shared/src/workflow.types.ts,packages/server/src/modules/workflows/run-creator.ts,packages/web/src/app/features/workflows/workflow-config-tab.component.ts
1. Status and context¶
Floh's "user-type" workflows today surface three overlapping concepts to authors, and they fail along predictable lines:
submitter.*— a form-render-only template namespace. Supported attributes are declared inSUBMITTER_INTERPOLATION_ATTRIBUTESand resolved when the catalog form renders.{{submitter.firstName}}works inside a variable's Default Value field; it does not resolve anywhere else — including notification bodies, transform scripts, or approver refs.requestor— a naming convention, not a primitive. By convention, authors declare a variable namedrequestorwithtype: "user"andselfPopulate: true. The portal designer exposes this combination as a "Requestor" preset (seeworkflow-config-tab.component.tsaroundisRequestor).subjectVariable— a workflow-level pointer that tells the engine which variable is the run's subject. Resolved inrun-creator.tsresolveSubjectand written torun.subject_idfor filtering, reporting, and inbox scoping.
Observed pain points¶
- Authors routinely try to reference
{{submitter.*}}from a notification body or a transform script and silently get unresolved text. - The "Requestor" preset reads as an actor ("the person requesting"), but the typical usage is as the subject of a user-type workflow. When the actor and subject genuinely differ (e.g. HR onboards a new hire), authors have to invent a second
uservariable and feel like they are working against the framework. selfPopulateis an implementation detail name. New authors read it as "the field populates itself" without understanding the security invariant it encodes.- There is no first-class "submit on behalf of another user" capability. The only way to build one today is to declare a non-
selfPopulateuservariable and hope the caller picks the right person — defeating the anti-spoofing invariant the framework was designed around.
Security invariant today (must be preserved)¶
applySelfPopulateDefaults unconditionally overrides any caller-supplied value for a selfPopulate variable with the authenticated initiator's ID:
SECURITY: selfPopulate binds the variable to the authenticated initiator. Always override any caller-provided value to prevent requestor spoofing (caller could otherwise impersonate another user in approvals, subject resolution, and notifications).
Any proposal that relaxes this rule must replace it with an explicit, permission-gated, audited path. See Section 8 (Proposal 4 — on-behalf-of).
2. Glossary¶
| Term | One-line definition |
|---|---|
| submitter | The authenticated user who initiated the run. Server-trusted. Never spoofable. Equivalent to today's initiatorId. |
| targetUser | The explicit subject variable of a user-category workflow. Often equal to the submitter; may differ in on-behalf-of. |
| subject | The generalized run-level "who is this workflow about" concept. For user-category workflows, equals targetUser. |
| self-service | A mode meaning "this workflow's targetUser is bound to the submitter at run start." Expressed today as a variable flag (selfPopulate); Proposal 1 renames it to selfService; Proposal 5 lifts it to a workflow-level declaration. |
| on-behalf-of | A new mode where a permission-gated submitter picks a different targetUser at the catalog form. |
| initiator | Engine-internal synonym for submitter. Kept as an implementation term; not surfaced to authors. |
| user self-service (category / flag) | The Proposal 5 workflow-level declaration that a workflow is intended for portal self-service. Variant A: a new user_self_service category. Variant B: a selfService: true flag on the existing user category. |
3. Current model¶
flowchart LR
session[Authenticated session] --> formRender
subgraph formRender [Catalog form render]
submitterNs["{{submitter.*}}<br/>session-only namespace"]
end
formRender --> submit[POST /api/request-catalog/:id/submit]
submit --> runCreator[run-creator.ts]
runCreator -->|selfPopulate: true overrides any value| requestorVar[requestor<br/>user variable]
runCreator -->|reads subjectVariable| subjectCol[run.subject_id]
requestorVar --> subjectCol
Key observations:
submitter.*andrequestorlive on opposite sides of the submit boundary. They are not linked.requestoris the subject by convention (viasubjectVariable: "requestor"), but its name reads as an actor.- The HR-onboarding pattern requires a second
uservariable (e.g.newEmployee) becauserequestoris forced to equal the submitter.
4. Proposed conceptual model¶
Two first-class concepts, always distinct at the engine level, often equal at runtime:
flowchart LR
session[Authenticated session] --> submitter[submitter<br/>server-trusted, implicit]
submitter -->|self-service mode| targetUser[targetUser<br/>explicit user variable]
submitter -->|on-behalf-of mode<br/>permission-gated| picker[Submit-as picker]
picker --> targetUser
targetUser --> subjectCol[run.subject_id]
submitter -.->|audit trail| runRow[run.initiator_id]
Invariants preserved:
run.initiator_idalways records the real authenticated caller. Never spoofable. (This is today'sinitiatorId; Proposal 3 is what gives it an author-facing surface —{{submitter.*}}at runtime — which today does not exist outside the catalog form.){{submitter.*}}resolves against the initiator record at both form render and runtime. One surface, one mental model.selfService: trueon auservariable keeps today's "override caller-supplied value with initiator" behavior by default. On-behalf-of is an opt-in path that requires a permission check.
The engine's two-user separation (initiator vs subject) is unchanged; this RFC reshapes only the author-facing names and UX, plus adds one new permission-gated runtime path (on-behalf-of). Proposal 5 further considers lifting the self-service intent from the variable to the workflow itself (as a category or top-level flag) — see Section 9.
5. Proposal 1 — Rename selfPopulate to selfService¶
Rename the flag on VariableDefinition from selfPopulate to selfService.
- Call sites:
VariableDefinition.selfPopulateinworkflow.types.ts, the override inapplySelfPopulateDefaults, and the designer preset mapping inworkflow-config-tab.component.ts(getVariableDisplayType,onVariableTypeChange,isRequestor). - Keep
selfPopulateas a read-accepted deprecated alias for one major version. Writes normalize toselfService. Log a one-shot deprecation warning when an imported workflow uses the old key. - Schema validators accept either key during the alias window; serializers always emit
selfService.
Why: selfService names the intent (the variable binds to the submitter because the submitter is the target); selfPopulate names the mechanism (the field auto-fills). Authors choose a mode, not a mechanism.
6. Proposal 2 — targetUser replaces requestor as the canonical preset¶
Pure convention and UX change; zero schema migration.
Relationship to Proposal 5. Proposal 2 stands alone only if Proposal 5 is rejected. If Proposal 5 is adopted (either variant), the implicit subject variable is already named targetUser and this proposal is merged into Proposal 5's implicit-variable wiring — no separate preset rename is required.
- Designer: rename the "Requestor" preset to "Target User" in
workflow-config-tab.component.ts. Default variable name in the preset becomestargetUserinstead ofrequestor. - Docs sweep: update
docs/user-guide/google-workspace-account-request.md,docs/workflows/examples/*, and the reference in.cursor/rules/domain/floh-workflows.mdcto use{{targetUser.*}}in new examples. - Existing saved workflows named
requestorkeep working unchanged — the engine never cared about the name; only theselfService(formerlyselfPopulate) flag and thesubjectVariablepointer matter. Old docs remain accurate for legacy workflows; add a single "Legacy name" callout.
Why: "Target User" reads as the subject of the workflow, which matches the actual usage. It also generalizes cleanly — "on-behalf-of" is now "the submitter picks a different target user," which is intuitive.
7. Proposal 3 — {{submitter.*}} at runtime¶
Make {{submitter.*}} resolvable at both form render and runtime, using the same attribute set declared in SUBMITTER_INTERPOLATION_ATTRIBUTES (firstName, lastName, email, displayName, id).
Mechanics:
- At run start,
run-creator.tspopulates an implicitsubmitterobject in the run's variable scope from the initiator's user row (same attributes as the form-render namespace, resolved once). - The object is read-only and cannot be declared as a variable name (reserved). Attempting to declare
submitterinvariables[]fails validation. - Downstream step configs, notification bodies, transform scripts, and approver refs may use
{{submitter.id}},{{submitter.email}}, etc.
Why: eliminates the "form-only" footgun. Authors no longer have to remember two namespaces or introduce a dummy user variable to carry submitter attributes into the workflow body.
Caveat: this creates two valid ways to refer to the submitter in self-service workflows — {{submitter.*}} (always the actor) and {{targetUser.*}} (the subject, which equals the submitter in self-service mode). That is intentional: the names encode different author intents, and the names diverge correctly in on-behalf-of mode.
8. Proposal 4 — "Submit on behalf of" capability¶
A new permission-gated runtime path that lets authorized callers override the targetUser at catalog submit time.
Permission¶
Introduce workflow:submit_on_behalf_of as a new permission. Grantable via the existing permission system; typically held by admins, HR, and help-desk roles.
Catalog form UX¶
On the catalog form for any workflow whose targetUser variable has selfService: true:
- Callers without
workflow:submit_on_behalf_of: form renders unchanged.targetUserfield is hidden; server forcestargetUser = submitter.id. - Callers with
workflow:submit_on_behalf_of: form shows a "Submit as" control (segmented control: Myself / Someone else). Default is Myself, preserving today's behavior. Selecting Someone else reveals a user picker.
Server contract¶
Both POST /api/request-catalog/:id/submit and POST /api/workflows/:id/start
accept an optional onBehalfOfUserId body field. The contract is identical on
both endpoints — a workflow can be driven from the admin "Start Run" dialog or
from a portal catalog submission with the same semantics. The server:
- Always records the authenticated caller as
run.initiated_by(unchanged). - If
onBehalfOfUserIdis absent or equals the caller's ID: apply today'sselfServiceoverride →targetUser = initiator.id. - If
onBehalfOfUserIdis present and differs from the caller's ID: verify the caller hasworkflow:submit_on_behalf_of. On success, bindtargetUserto the looked-up user (found viausers.findById) and override any caller-supplied value for that variable. If the permission check fails, reject with403(conventional Fastify{ message }body, wheremessageincludes theON_BEHALF_OF_FORBIDDENsentinel). If the user does not exist, reject with400. {{submitter.*}}is always bound from the real authenticated caller; the on-behalf-of override only affectstargetUserand the subject fields derived from it. This is enforced by tests inpackages/server/test/unit/run-creator.test.ts.
Audit¶
- Run row keeps
initiator_idandsubject_idseparate (already the case). - Run timeline emits an explicit event:
"Alice (initiator) submitted on behalf of Bob (subject)"wheneverinitiator_id !== subject_id. - Log line at INFO with both IDs and the permission that authorized the override.
Non-goals for Proposal 4¶
- Scoped on-behalf-of (e.g. "managers can submit for their reports only") is out of scope for this RFC — flagged as an open question below.
- Per-workflow opt-out of on-behalf-of is flagged as an open question.
9. Proposal 5 — "User self-service" as a workflow-level declaration¶
The previous four proposals move self-service from an implementation-detail flag (selfPopulate) to a clearer variable-level flag (selfService). Proposal 5 goes one step further: promote the self-service intent from the variable to the workflow itself, so an author declares "this is a self-service workflow" up front instead of constructing it from lower-level primitives.
Two variants of this idea are plausible; the RFC captures both and defers the choice to review.
Variant A — New category user_self_service¶
Add user_self_service to the existing category enum alongside user, group, project, and general. Semantics:
- Implicit
targetUservariable. The engine auto-provides atargetUservariable of typeuserwithselfService: trueandsubjectVariable: "targetUser"already wired. Authors never declare it; they cannot rename or remove it. {{submitter.*}}and{{targetUser.*}}are guaranteed to refer to the same person in pure self-service mode. In on-behalf-of mode (if enabled — see interaction below),{{submitter.*}}is the actor and{{targetUser.*}}is the acted-upon user.- Catalog presentation. The portal catalog can render a dedicated "Self-service" section (password reset, account request, profile updates), distinct from admin-run
user-category workflows. - Validation. Publishing a
user_self_serviceworkflow asserts: exactly onetargetUserbinding, no competingselfServicevariables,subjectVariablemust betargetUser(or null with auto-wiring). The designer enforces these invariants before save.
Variant B — Workflow-level selfService: true flag on the existing user category¶
Keep the category enum unchanged; add a top-level selfService?: boolean field to the workflow definition. Semantics identical to Variant A (implicit targetUser, auto-wired subjectVariable, same validation), but the workflow schema reads:
Comparison¶
| Aspect | Variant A (new category) | Variant B (workflow-level flag) |
|---|---|---|
| Mental model | "I'm building a self-service workflow." (one choice in the category dropdown) | "I'm building a user workflow, and it's self-service." (two choices) |
| Axis cleanliness | Mixes subject-kind × submitter-relationship on a single enum | Keeps the two axes orthogonal |
| Migration effort | Existing user + selfPopulate: true workflows are semantically user_self_service; needs a sweep |
Existing user + selfPopulate: true workflows get a derived selfService: true at read time |
| Catalog filtering | Trivial — filter by category | Requires a secondary filter on the flag |
| Docs / discoverability | High — category dropdown is the first thing an author sees | Medium — flag is one checkbox on the workflow config |
| Validation surface | Easiest — category implies the full contract | Equivalent, just predicated on the flag |
| Extensibility | Requires a new category for each new submitter-relationship (e.g. group_self_service) |
Flag composes with any category |
Interaction with on-behalf-of (Proposal 4)¶
Two coherent choices, either compatible with Variant A or B:
- Self-service forbids on-behalf-of. A
user_self_serviceworkflow (Variant A) or aselfService: trueworkflow (Variant B) is strictly self-service. To allow on-behalf-of, the author drops back to the pre-Proposal-5 primitives: categoryuserplus auservariable withselfService: true(the variable-level flag from Proposal 1). This preserves the clean "self-service = the submitter acts on themselves" story at the cost of forcing authors to hand-construct the subject variable whenever they want on-behalf-of. - Self-service allows on-behalf-of under the existing permission gate. Proposal 4's
workflow:submit_on_behalf_ofpermission still applies — a permitted caller sees the "Submit as" picker on any self-service workflow. The self-service declaration becomes "by default, the submitter is the target; privileged callers may deviate."
Recommendation: Option 2 (allow on-behalf-of with the permission). It preserves Proposal 4's single permission gate and doesn't force authors to choose between ergonomics (self-service shortcut) and capability (on-behalf-of). Authors who want strict self-service can address it at the permission level (don't grant workflow:submit_on_behalf_of) or via the per-workflow opt-out discussed in the open questions.
Coexistence of workflow-level and variable-level selfService (Variant B only)¶
Variant B introduces a workflow-level selfService: true flag while the variable-level selfService flag from Proposal 1 continues to exist. The two can coexist, which requires a precedence rule:
- If the workflow-level flag is
true: the engine auto-provides atargetUservariable with the same semantics as Variant A. The workflow's ownvariables[]MUST NOT also declare a variable namedtargetUser, MUST NOT setselfService: trueon any otheruservariable, and MUST NOT setsubjectVariableto anything other thantargetUser(or null). Validation rejects conflicting declarations at publish time. - If the workflow-level flag is absent or
false: variable-levelselfServicebehaves exactly as Proposal 1 defines. This is the path authors take when they need a non-targetUsernaming convention or multiple user variables with distinct roles (e.g. HR onboarding with both arequestorand anewEmployee).
This makes the workflow-level flag a strict shortcut for the common case; the variable-level flag remains the general-purpose mechanism. Variant A does not have this concern because the new category alone determines the subject-variable shape.
Tradeoffs and recommendation¶
Variant A is more discoverable and self-describing but mixes two conceptual axes on one enum. Variant B is structurally cleaner and composes better if Floh ever adds group_self_service, project_self_service, etc. For today's workflow surface (the only self-service shape in practice is user-acting-on-self), Variant A's discoverability probably wins. If the product ever grows a second self-service axis, Variant B would retroactively look better.
The RFC recommends Variant A as the primary direction, with Variant B listed as the fallback if reviewers prefer axis cleanliness over discoverability. Either variant fully subsumes Proposal 2's targetUser rename — the implicit variable is named targetUser in both.
Migration impact for Proposal 5¶
- Variant A adds a new enum value; existing
user-category workflows withselfPopulate: trueget a soft migration path — either an opt-in tool that rewrites them touser_self_service, or a compatibility mode where the engine treatscategory: "user" + selfService: true variableas equivalent touser_self_serviceat runtime. - Variant B is purely additive — a new optional flag on the workflow definition.
- Either way, this proposal does not change existing DB columns (category is already a string).
10. Security invariants (preserved)¶
The RFC explicitly preserves the current security posture:
- The submitter is never spoofable.
run.initiator_idis always the authenticated session user. No API surface allows overriding it. This matches the invariant inrun-creator.tsand the project-wide rule in.cursor/rules/core/security-invariants.mdc. - The target may diverge from the submitter only through an explicit permission.
workflow:submit_on_behalf_ofis required; absent it,selfServicebehavior is identical to today. {{submitter.*}}is always the real submitter. In on-behalf-of mode,{{submitter.*}}resolves to the actor and{{targetUser.*}}resolves to the acted-upon user. Approvers, notifications, and transforms can rely on this split.- Audit completeness. Every on-behalf-of run carries both IDs on the run row and an explicit timeline event.
11. Migration plan¶
All changes are additive at the schema and storage layers. No DB migration required.
| Change | Breaking? | Back-compat |
|---|---|---|
selfPopulate → selfService |
No (alias window) | Accept both keys on read for one major version; serialize selfService; warn on deprecated use. |
| "Requestor" preset → "Target User" preset | No | Pure label/default-name change in the designer. Old workflows continue to resolve correctly. |
{{submitter.*}} resolvable at runtime |
No | Purely additive; existing workflows that only used it at form-render continue to work. |
workflow:submit_on_behalf_of permission |
No | New permission; no caller has it by default; on-behalf-of UI is hidden until granted. |
onBehalfOfUserId field on catalog submit |
No (optional field) | Older clients omit it and get today's self-service behavior. |
user_self_service category (Variant A) |
No (new enum value) | Existing user + selfService workflows keep working; optional rewrite tool promotes them. |
workflow-level selfService flag (Variant B) |
No (optional field) | New optional flag; absent on every existing workflow. Legacy workflows keep using the variable-level flag from Proposal 1 with no change in behavior. |
Documentation sweep (non-code):
docs/user-guide/google-workspace-account-request.md— swaprequestorfortargetUserin the walkthrough; add a "Submit on behalf of" subsection.docs/workflows/examples/*— update{{requestor.*}}references in new examples; leave legacy examples with a compatibility note..cursor/rules/domain/floh-workflows.mdc— update section 6 (Variable Interpolation) and section 9 (Common Patterns) with the new names and the runtime{{submitter.*}}surface.- A new doc
docs/workflows/requestor-vs-submitter.md(or this RFC's "implemented" form) serves as the canonical author-facing explainer.
12. Open questions¶
targetUseras convention or primitive? Should the engine special-case the name (e.g. auto-setsubjectVariablewhen a variable is namedtargetUser), or keep it as a pure documentation convention? Convention is zero-risk; a primitive is clearer but creates a reserved name.- Per-workflow opt-out of on-behalf-of. Should workflow authors be able to disable on-behalf-of for specific workflows (e.g. "password reset must be self-service only"), or is the permission the only gate?
- Scoped on-behalf-of. Should the user picker respect an authorization scope (e.g. "managers can only pick their direct reports," "help-desk can pick anyone in the same org unit")? If yes, is this a new permission scope or a policy hook?
- Legacy
requestorauto-rename. Do we ship a tool that rewritesrequestortotargetUserin saved workflow JSON, or leave legacy workflows alone? A tool keeps the codebase clean but risks breaking external references (MCP resources, docs links). - Designer UX for self-service. Checkbox ("Self-service") vs segmented control ("Self-service" / "Someone picks the target" / "System-populated")? The segmented control is more discoverable but adds complexity.
submitteras a reserved name. Proposal 3 reservessubmitterinvariables[]. Do we also reserve it across legacy workflows (forcing a rename on upgrade), or only for newly-created workflows?- Proposal 5 variant choice. Pick Variant A (new
user_self_servicecategory) or Variant B (workflow-levelselfServiceflag on theusercategory)? Variant A wins on discoverability; Variant B wins on axis cleanliness and future extensibility. - Does self-service forbid on-behalf-of? If a workflow is declared self-service (either variant), should the
workflow:submit_on_behalf_ofpermission still enable a "Submit as" picker, or is self-service strictly the submitter acting on themselves? The RFC recommends "permission still applies" but flags the decision for review. - Auto-migrate
user+selfServicetouser_self_service? If we adopt Variant A, do existing workflows with this shape get automatically rewritten on next publish, offered an opt-in tool, or left untouched?
13. Non-goals¶
- Changes to
subjectVariablesemantics forgroup,project, orgeneralcategories. - Reworking form pre-fill for non-user variable types (e.g.
{{submitter.firstName}}intonumberordatedefaults). - Changes to
autoProvisionByEmailor the auto-provisioning flow. - Changes to the step-up / MFA contract on catalog submit.
- Any changes to MCP resource schemas that aren't strict supersets of today's.
Authors of this RFC: (assign on review) Reviewers: workflow engine, portal UX, security Target decision date: TBD