Skip to content

RFC: User Variable Model for Workflow Authors

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 in SUBMITTER_INTERPOLATION_ATTRIBUTES and 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 named requestor with type: "user" and selfPopulate: true. The portal designer exposes this combination as a "Requestor" preset (see workflow-config-tab.component.ts around isRequestor).
  • subjectVariable — a workflow-level pointer that tells the engine which variable is the run's subject. Resolved in run-creator.ts resolveSubject and written to run.subject_id for 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 user variable and feel like they are working against the framework.
  • selfPopulate is 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-selfPopulate user variable 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.* and requestor live on opposite sides of the submit boundary. They are not linked.
  • requestor is the subject by convention (via subjectVariable: "requestor"), but its name reads as an actor.
  • The HR-onboarding pattern requires a second user variable (e.g. newEmployee) because requestor is 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_id always records the real authenticated caller. Never spoofable. (This is today's initiatorId; 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: true on a user variable 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.selfPopulate in workflow.types.ts, the override in applySelfPopulateDefaults, and the designer preset mapping in workflow-config-tab.component.ts (getVariableDisplayType, onVariableTypeChange, isRequestor).
  • Keep selfPopulate as a read-accepted deprecated alias for one major version. Writes normalize to selfService. 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.

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.ts populates an implicit submitter object 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 submitter in variables[] 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. targetUser field is hidden; server forces targetUser = 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:

  1. Always records the authenticated caller as run.initiated_by (unchanged).
  2. If onBehalfOfUserId is absent or equals the caller's ID: apply today's selfService override → targetUser = initiator.id.
  3. If onBehalfOfUserId is present and differs from the caller's ID: verify the caller has workflow:submit_on_behalf_of. On success, bind targetUser to the looked-up user (found via users.findById) and override any caller-supplied value for that variable. If the permission check fails, reject with 403 (conventional Fastify { message } body, where message includes the ON_BEHALF_OF_FORBIDDEN sentinel). If the user does not exist, reject with 400.
  4. {{submitter.*}} is always bound from the real authenticated caller; the on-behalf-of override only affects targetUser and the subject fields derived from it. This is enforced by tests in packages/server/test/unit/run-creator.test.ts.

Audit

  • Run row keeps initiator_id and subject_id separate (already the case).
  • Run timeline emits an explicit event: "Alice (initiator) submitted on behalf of Bob (subject)" whenever initiator_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 targetUser variable. The engine auto-provides a targetUser variable of type user with selfService: true and subjectVariable: "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_service workflow asserts: exactly one targetUser binding, no competing selfService variables, subjectVariable must be targetUser (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:

{
  "category": "user",
  "selfService": true
  // no variable declaration needed for the subject
}

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:

  1. Self-service forbids on-behalf-of. A user_self_service workflow (Variant A) or a selfService: true workflow (Variant B) is strictly self-service. To allow on-behalf-of, the author drops back to the pre-Proposal-5 primitives: category user plus a user variable with selfService: 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.
  2. Self-service allows on-behalf-of under the existing permission gate. Proposal 4's workflow:submit_on_behalf_of permission 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 a targetUser variable with the same semantics as Variant A. The workflow's own variables[] MUST NOT also declare a variable named targetUser, MUST NOT set selfService: true on any other user variable, and MUST NOT set subjectVariable to anything other than targetUser (or null). Validation rejects conflicting declarations at publish time.
  • If the workflow-level flag is absent or false: variable-level selfService behaves exactly as Proposal 1 defines. This is the path authors take when they need a non-targetUser naming convention or multiple user variables with distinct roles (e.g. HR onboarding with both a requestor and a newEmployee).

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 with selfPopulate: true get a soft migration path — either an opt-in tool that rewrites them to user_self_service, or a compatibility mode where the engine treats category: "user" + selfService: true variable as equivalent to user_self_service at 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:

  1. The submitter is never spoofable. run.initiator_id is always the authenticated session user. No API surface allows overriding it. This matches the invariant in run-creator.ts and the project-wide rule in .cursor/rules/core/security-invariants.mdc.
  2. The target may diverge from the submitter only through an explicit permission. workflow:submit_on_behalf_of is required; absent it, selfService behavior is identical to today.
  3. {{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.
  4. 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
selfPopulateselfService 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):

12. Open questions

  1. targetUser as convention or primitive? Should the engine special-case the name (e.g. auto-set subjectVariable when a variable is named targetUser), or keep it as a pure documentation convention? Convention is zero-risk; a primitive is clearer but creates a reserved name.
  2. 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?
  3. 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?
  4. Legacy requestor auto-rename. Do we ship a tool that rewrites requestor to targetUser in saved workflow JSON, or leave legacy workflows alone? A tool keeps the codebase clean but risks breaking external references (MCP resources, docs links).
  5. 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.
  6. submitter as a reserved name. Proposal 3 reserves submitter in variables[]. Do we also reserve it across legacy workflows (forcing a rename on upgrade), or only for newly-created workflows?
  7. Proposal 5 variant choice. Pick Variant A (new user_self_service category) or Variant B (workflow-level selfService flag on the user category)? Variant A wins on discoverability; Variant B wins on axis cleanliness and future extensibility.
  8. Does self-service forbid on-behalf-of? If a workflow is declared self-service (either variant), should the workflow:submit_on_behalf_of permission 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.
  9. Auto-migrate user + selfService to user_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 subjectVariable semantics for group, project, or general categories.
  • Reworking form pre-fill for non-user variable types (e.g. {{submitter.firstName}} into number or date defaults).
  • Changes to autoProvisionByEmail or 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