---
name: user-action-contract
description: "Canonical contract for workflow user actions, Mystique rendering, field registry wiring, and override precedence."
version: 1.0
relevant-skills: workflow-builder, workflow-analyzer, pre-deploy-check, workflow-deploy, mystique-builder, mystique-validate, settings
load: on-demand
---

# Workflow User Action Contract

This document is the single source of truth for how workflow user actions surface in Mystique UI.

## 1) Visibility Contract (Button Appears or Not)

All of these must be valid for the button to appear:

| Field | Requirement |
|---|---|
| `eventName` | Required in each user action. Missing value makes the action invisible. |
| Ruleset `subtype` | Must exist and match workflow `entitySubtype`. |
| `context[].label` | Required button label. |
| `context[].type` | Must be `"PRIMARY"` or `"SECONDARY"` (uppercase). |
| `context[].modules` | Must be a non-empty array of valid app names. |
| `context[].confirm` | Must be boolean (`true`/`false`), not a string. |

For OMS App UI actions, use module alias `"adminconsole"` (not `"oms"`). This is because the OMS root manifest has `"orchestrationAlias": "adminconsole"`, which makes the OMS App send `module: "adminconsole"` to the Transition API. For Store App, use module alias `"servicepoint"` (legacy name: ServicePoint). Always present these as "OMS App" and "Store App" to users — `"adminconsole"` and `"servicepoint"` are deprecated internal aliases.

## 2) Configuration vs User Action vs Manifest

Use the right channel for the right job:

| Concern | Source of truth | Notes |
|---|---|---|
| Static rule configuration | Ruleset `props{}` + `@ParamString` | Design-time config. Same for all executions. |
| Runtime form input | User action `attributes[]` + `@EventAttribute` | Collected from user at click-time. |
| Button presentation | Manifest `CardAction` (`label`, `condition`, order) | UI-level override only. |

If Mystique needs to render a field, define it in workflow `attributes[]`, not in manifest.

## 3) Event Attribute Contract (Rule Wiring)

When a user action is triggered, the event is dispatched under the calling user's auth, but the **ruleset executes as the retailer's Rubix user** (`<ACCOUNT>-<RETAILER_ID>-rubix`, type `API`). The dispatching user's identity is NOT automatically available to rules — pass it explicitly via `@EventAttribute` (e.g., `actionedBy` with `defaultValue: "{{me.username}}"`).

```
userActions[].attributes[] --> event.getAttributes() --> @EventAttribute(name = "...")
ruleset.props{}           --> context.getProp()      --> @ParamString(name = "...")
```

Guardrails:

- Every `attributes[].name` must map to a rule `@EventAttribute(name = "...")`.
- Every `context.getProp("X")` must map to `@ParamString(name = "X")`.
- Never read event attributes with `DynamicUtils.getJsonPath(context, "event.X")`.

## 4) Field Registry Wiring (Mystique Custom Fields)

Form field component resolution is deterministic:

1. Workflow defines `userActions[].attributes[].type`.
2. Mystique resolves that `type` using `FieldRegistry`.
3. Matching registered component renders the field.

Example:

```typescript
FieldRegistry.register(
  ["company.inventory.stockReservation"],
  StockReservation
);
```

```json
{
  "name": "stockReservation",
  "type": "company.inventory.stockReservation"
}
```

`type` values must exactly match the registered field key.

## 5) Defaults and Override Precedence

### Workflow defaults

- `attributes[].defaultValue`: initial value passed to field.
- `attributes[].source`: optional source token for option-backed fields.

### Manifest overrides

- Allowed: button-level behavior (`CardAction.label`, `CardAction.condition`, ordering).
- Not allowed: form field contract (`attributes[].type`, `label`, `mandatory`, `defaultValue`, `source`).

If you need different form behavior for the same action, update workflow JSON and redeploy workflow.

## 6) Module Name Alignment

- `context[].modules[]` values are derived from manifest setting name suffixes: `fc.mystique.manifest.<module>` → `<module>`.
- The module value is the suffix after `fc.mystique.manifest.` in the setting name.
- The module value is usually the app name, but can be overridden by `orchestrationAlias` in the root manifest.
- OMS manifest has `"orchestrationAlias": "adminconsole"` → OMS App sends `module: "adminconsole"` to Transition API.
- Store manifest has `"orchestrationAlias": "servicepoint"` → Store App sends `module: "servicepoint"` to Transition API.
- Standard mapping: OMS App → `"adminconsole"` (legacy name: Admin Console), Store App → `"servicepoint"` (legacy name: ServicePoint). Always present as "OMS App" / "Store App" to users.

## 7) Safe Attribute Practices

- v4.80+ supports values like `source: "OVERRIDE"` and `defaultValue: "{{me.username}}"`.
- Safe default for predictable behavior remains:
  - `source: ""`
  - `defaultValue: ""`
- Missing ruleset `subtype` is a more common cause of invisible actions than attribute default/source values.

## 8) Settings-Driven Dropdown Contract

When a user action attribute has `source: "settings.<KEY>"`, the UI renders a dropdown populated from a Fluent setting. Three silent-failure gotchas:

### 8a) Case-sensitive `source` prefix
- **MUST be lowercase** `"settings.CANCELLATION_REASON"` — NOT `"Settings.CANCELLATION_REASON"` (capital S)
- Capital "S" silently fails — dropdown renders as empty, no error

### 8b) Setting JSON format — `"active"` wrapper required
The setting value MUST wrap the options array in an `"active"` key:
```json
{
  "active": [
    { "name": "Default", "value": "DEFAULT" },
    { "name": "Wrong Size", "value": "WRONG_SIZE" },
    { "name": "Change of mind", "value": "CHANGE_OF_MIND" }
  ]
}
```
A plain array `[{ "name": "...", "value": "..." }]` silently fails — dropdown appears empty. Each entry needs both `name` (display label) and `value` (submitted value).

### 8c) Cache behavior
After changing the setting value, the OMS caches dropdown options. A hard refresh (`F5` or `about:blank` bounce) is needed to pick up new values.

### 8d) Setting context must match
The setting's retailer context must match the workflow's retailer. A setting scoped to retailer A won't populate dropdowns for entities on retailer B.

## 8e) Persisting event attributes to entities

User action attributes are collected via the event but do NOT auto-persist to the entity. To save a collected value (e.g., cancellation reason from a settings dropdown) as an entity attribute, the ruleset MUST include one of these OOTB rules **before** the state change rule:

- `FLUENTRETAIL.base.AddStringEventAttributeToEntity` — single string. Props: `{ "attributeName": "cancelReason" }`
- `FLUENTRETAIL.base.AddEventAttributesToEntity` — multiple, any type. Props: `{ "attributeNames": [{ "name": "cancelReason", "type": "STRING" }] }`
- `FLUENTRETAIL.base.AddEventAttributesToOrderEntity` — ORDER-specific with defaults support

Without a persist rule, the dropdown selection fires the event successfully, but the entity never stores the value.

## 9) Custom Component Override Patterns

### The Contract Boundary

The event payload (`POST /event/sync`) is the contract boundary between UI and backend:

```
@EventAttribute(Java) ← event payload ← POST /event/sync ← UI Component
```

Rules read from `event.getAttributes().get("fieldName")`. They do not know or care whether a standard FieldRegistry input or a custom React widget collected the value. **Backend impact is zero** as long as the event payload has the same attribute names and values the rules expect.

### 9a) FieldRegistry Data Access Limitations in Dialogs

FieldRegistry fields in user action dialogs render in a **separate React tree** outside the page's data context provider. This means:

| Data source | Result in dialog | Use for FieldRegistry? |
|---|---|---|
| `data` prop | `undefined` | NO — only ComponentRegistry components receive this |
| `useData()` hook | `{ context: { data: {} }, loadingState: "ready" }` | NO — empty context |
| `useQuery()` hook | Full GraphQL response | YES — self-contained, works in any context |

**Required pattern for FieldRegistry fields that need entity data:**
1. Prefer `entityContext?.[0]?.entity.id` for the current entity ID
2. Fall back to URL hash parsing only when `entityContext` is unavailable or you need route values not carried in `entityContext` (for example `retailerId`)
3. Use `useQuery()` with a validated GraphQL query
4. Handle async data arrival with `useEffect` (data loads AFTER first render)
5. **MANDATORY**: Validate the GraphQL query via MCP `graphql.validate` before embedding in source

**Common GraphQL schema traps for Order/OrderItem:**
- `Order.totalPrice` is a scalar `Float` — NOT an object with `currency` sub-fields
- `Order` has NO `currency` field — use `OrderItem.currency` (String, often null)
- `OrderItem.product` is NON_NULL in schema but can error at runtime (broken refs)

See `/fluent-mystique-sdk-reference` for the user action dialog data-access pattern.

### Pattern A: Field-Level Override (recommended)

Replace ONE attribute's form field with a custom component. The standard Mystique form still auto-renders all other attributes from the Transition API response.

**How:** Register a custom field type via `FieldRegistry.register()`, then set the workflow attribute's `type` to your custom key.

```json
{
  "name": "amount",
  "type": "company.appeasement.amountCalculator",
  "label": "Appeasement Amount",
  "mandatory": true
}
```

```typescript
FieldRegistry.register(["company.appeasement.amountCalculator"], AmountCalculator);
```

**Contract impact:** None. The standard form handles all other attributes. Your custom field produces its value into the same event payload slot. Rules see no difference.

### Pattern B: ComponentRegistry + useUserActionForm (RECOMMENDED for full-form)

Wrap the framework's auto-generated form inside a custom ComponentRegistry component. This gives you full layout control while the framework handles field rendering, auth, retailerId, and event dispatch.

```typescript
import { useUserActionForm } from 'mystique/hooks/useUserActionForm';

const MyAppeasementCard: React.FC<Props> = ({ data }) => {
  const actionForm = useUserActionForm('RequestAppeasement');

  if (actionForm.status === 'loading') return <CircularProgress />;
  if (actionForm.status === 'error') return <ErrorDisplay />;

  const { result } = actionForm;
  if (!result.hasVisibleFields) return null; // action not available at current status

  return (
    <Card>
      <CardHeader title="Create Appeasement" />
      <CardContent>
        {/* Your custom additions — user info, role badge, limit display */}
        <UserRoleDisplay />
        <Divider />
        {/* Framework-generated form — renders ALL workflow attributes */}
        {/* Uses FieldRegistry for each attribute.type automatically */}
        {result.form}
        {/* Submit button — framework handles auth, retailerId, event dispatch */}
        <Button onClick={result.submit} disabled={actionForm.status === 'submitting'}>
          Submit
        </Button>
      </CardContent>
    </Card>
  );
};
```

**`useUserActionForm` API:**
- `status`: `'loading'` | `'error'` | `'ok'` | `'submitting'`
- `result.label`: action label from workflow
- `result.hasVisibleFields`: whether the form has fields to show (false if all have preset values)
- `result.form`: auto-generated React form with ALL workflow attributes as fields
- `result.submit()`: fires the event — handles auth, retailerId, entity context, success toast

**Contract impact:** None. The framework form auto-renders workflow attributes and dispatches the event correctly. If `appeasementAmount` has `type: "appeasement.fields.amount-calculator"` in the workflow, the form auto-uses the FieldRegistry calculator. Your component just adds custom UI around the form.

**Config options:**
```typescript
useUserActionForm('RequestAppeasement', {
  path: 'orderById',           // JSONPath to entity in query response
  noSuccessMessage: true,      // suppress default success toast
  extension: {
    postSubmit: { type: 'navigate', link: '/orders' }  // navigate after success
  },
  overrides: {                 // override field properties (label, options, etc.)
    appeasementAmount: { label: 'Custom Label' }
  }
});
```

**Manifest wiring (as content card on page):**
```json
{
  "component": "my-custom.AppeasementCard",
  "props": { "title": "Create Appeasement" }
}
```

### Pattern C: Full Manual Override (use only when framework form is insufficient)

Replace the ENTIRE user action form with completely custom UI and manual event dispatch. Use this ONLY when the form interaction model is fundamentally different (wizard, multi-step, drag-drop) and `useUserActionForm`'s generated form cannot be used.

```json
{
  "actions": {
    "primary": [{
      "type": "userAction",
      "name": "RequestAppeasement",
      "component": "my-custom.AppeasementWizard"
    }]
  }
}
```

**Dispatch the event yourself via `useRest()` or `getRest()`:**
```typescript
import { useRest } from 'mystique/hooks/useRest';

const rest = useRest();
await rest.post('/event/sync', {
  name: 'RequestAppeasement',
  entityType: 'ORDER',
  entityId: orderId,
  retailerId: retailerId,  // MUST include — useRest adds auth headers automatically
  attributes: {
    appeasementAmount: '150.00',
    appeasementReason: 'DAMAGED_GOODS',
    actionedBy: username,
    comment: 'Customer complaint'
  }
});
```

**Contract impact:** HIGH risk. You own ALL fields. Must dispatch every attribute name that rules expect via `@EventAttribute`. Miss one → rule gets null. Add one without a matching rule → value is silently ignored (no error, but not persisted).

**NEVER use raw `fetch()` for event dispatch** — it lacks auth headers and retailerId. Always use `useRest()`/`getRest()` which the Mystique host provides with pre-configured auth.

### Pattern D: Typed Contract Adapter (single fancy component, no rule changes)

Use this when you want **one complex UI component** to collect all inputs, while preserving the existing backend contract exactly.

This is the practical answer to: "Can one component submit multiple event attributes?" — **Yes**, as long as it is an action-level component (ComponentRegistry override), not a single FieldRegistry field.

**How it works:**
1. Keep workflow `userActions[].attributes[]` and rule `@EventAttribute` declarations unchanged.
2. Build one action component with a typed interface for the full event payload.
3. Validate locally in the component before submit.
4. Dispatch `/event/sync` with an `attributes` object whose keys exactly match the contract.

```typescript
interface AppeasementEventAttributes {
  appeasementAmount: string;
  appeasementReason: string;
  actionedBy: string;
  comment: string;
  billingAccountRef: string;
}
```

```typescript
await getRest('/api/v4.1/event/sync', 'POST', {
  name: actionName,
  entityType: 'ORDER',
  entityId,
  retailerId,
  attributes: payload // keys must match @EventAttribute names
});
```

**Important boundary:** A FieldRegistry component still owns one attribute slot (`value`/`onChange`) at a time. If you need one component to submit many attributes, use action-level dispatch (Pattern C/D), not field-level dispatch alone.

**Contract impact:** Backend-neutral when key names and value formats are preserved.

### onChange Options API (FieldRegistry)

`onChange` accepts a second argument for confirmation UI, validation, and button controls:

```typescript
onChange(value: T | null, options?: {
  summary?: {
    content: React.ReactElement;  // JSX rendered in the confirmation dialog
    title?: string;
  };
  isInvalid?: boolean;            // Blocks form submission (button stays visible)
  disableActionButton?: boolean;  // Grays out the submit button entirely
  confirm?: {
    title: string;                // Custom confirmation dialog title
    content: React.ReactElement;  // Replaces standard confirmation with custom JSX
  };
});
```

**Key facts:**
- `value` can be string, number, object, array, or `null` — the framework serializes it into the event attribute
- `onChange(null)` signals the field is not ready — form cannot submit (distinct from empty string)
- `summary.content` injects custom JSX into the confirmation dialog (e.g., selected items review panel)
- `isInvalid: true` prevents the user from submitting the form — use for client-side validation
- `disableActionButton: true` grays out the submit button entirely — use when workflow isn't complete (e.g., pack not finished)
- `confirm: { title, content }` replaces the standard confirmation dialog with custom JSX — use for review/summary screens
- Each field still owns ONE attribute slot — structured objects pack multiple logical values into one attribute

**Distinction table:**

| Option | Behavior | Use when |
|--------|----------|----------|
| `summary.content` | JSX appears IN the standard confirmation dialog | Review panel (selected items, totals) |
| `confirm.content` | REPLACES the standard confirmation dialog | Full custom confirmation (pick summary) |
| `isInvalid` | Blocks submission, button stays visible | Client-side validation (nothing selected) |
| `disableActionButton` | Grays out submit button entirely | Workflow not complete (items being packed) |

### FormFieldProps — Additional Props

Beyond `value`, `onChange`, `onBlur`, `name`, `label`, `mandatory`, `disabled`, `error`, the framework passes:

- **`lastSubmit`** — numeric timestamp set when user clicks submit. Use to defer validation display until first submit attempt. `undefined` initially, `Date.now()` after first click.
- **`entityContext`** — `entityContext[0].entity.{id, type, ref}` and `entityContext[0].rootEntity?.{id, type, ref}`. Available in user action dialogs (FieldRegistry). NOT available in ComponentRegistry (use `data`/`useData()` instead).
- **`source`** — source token from workflow attribute definition (e.g., `"settings.CANCELLATION_REASON"`)
- **`defaultValue`** — default value from workflow attribute definition

**Testing implication:** Unit tests for custom fields should include realistic `entityContext`, `source`, `defaultValue`, and `lastSubmit` props. That matches the real dialog contract more closely than testing with only `value` and `onChange`.

**Reference: Fluent Store plugin — Create Wave**

Fluent's own Store plugin uses two FieldRegistry fields in one user action dialog:

| Field | Registered as | onChange sends | Backend reads |
|---|---|---|---|
| `WaveCreationDetails` | `wave_creation_details` | `{ count, userName, fulfilmentTypes }` (object) | One attribute, parses JSON |
| `CreateWaveTable` | `FULFILMENT_IDS` | `number[]` (array of IDs) + `{ summary: <JSX />, isInvalid }` | One attribute, parses array |

Each field is self-contained — queries its own data, manages its own state, no shared context needed. This is simpler than coordinated field patterns and is the recommended approach.

### Choosing Between Patterns

| Consideration | Pattern A (field-level) | Pattern B (useUserActionForm) | Pattern C (manual) |
|---|---|---|---|
| Standard fields auto-render | Yes | Yes (via result.form) | No — you build everything |
| Auth + retailerId handled | By framework | By framework | You must use `useRest()` |
| FieldRegistry respected | Yes | Yes (auto-resolved per attribute type) | No — you render all fields |
| Backend impact | None | None | None (if event shape preserved) |
| Risk of missing attributes | None | None — form includes all attributes | HIGH — you own all fields |
| Custom UI around form | No (single field only) | Yes — wrap with any layout | Yes — total control |
| Use when | One field needs richer UI | Form layout/styling needs customization | Fundamental UX change (wizard, multi-step) |
| Workflow change needed | Attribute `type` = FieldRegistry alias | None (or attribute `type` for custom fields) | None |

**Option D mapping:** Pattern D is a constrained, typed form of Pattern C focused on "single fancy component, unchanged backend contract." Prefer it over ad-hoc manual forms.

### What Does NOT Change (Any Pattern)

| Layer | Changes? | Why |
|-------|----------|-----|
| Rules `@EventAttribute` | No | Reads from event payload — agnostic to UI |
| Ruleset `props` / `@ParamString` | No | Static config, unrelated to UI |
| Workflow `userActions[].attributes[]` | No | Still the contract. Transition API still returns them. |
| Event payload shape | No | Must match what rules expect |

### FieldRegistry Wiring: Confirmed Patterns

How to wire a custom FieldRegistry component so it renders in the user action form:

| Method | How | Status |
|---|---|---|
| Workflow `attributes[].type` | Set to FieldRegistry alias (e.g., `"appeasement.fields.amount-calculator"`) | **CONFIRMED WORKS** |
| Manifest `overrides.field.inputType` | Set `inputType` in manifest action overrides | **INCONCLUSIVE** — works for built-in types (`select` + `options`), untested for custom aliases |
| `useUserActionForm` `overrides` | Pass `overrides: { field: { ... } }` in config | Overrides field properties (label, options) but NOT the component type |

**Recommendation:** Always use workflow `attributes[].type` for custom FieldRegistry wiring. It is the only confirmed method. Manifest `inputType` works for built-in types like `select` but has not been verified for custom FieldRegistry aliases.

## 10) Validation Checklist

1. User action has `eventName`.
2. Ruleset has correct `subtype`.
3. `context` is an array.
4. Context object has valid `label`, `type`, `modules`, `confirm`.
5. `modules[]` contains valid app names and includes the target app.
6. `attributes[]` names match rule `@EventAttribute` declarations.
7. `attributes[].type` matches a registered Mystique field type (standard or custom).
8. If `source` starts with `"settings."`: lowercase prefix, target setting exists, setting has `{ "active": [...] }` format, setting retailer context matches workflow retailer.
9. If attributes collect values that should persist on the entity: ruleset includes `AddEventAttributesToEntity` or `AddStringEventAttributeToEntity` BEFORE the state change rule.
10. Manifest action overrides (if any) only adjust button behavior, not form field schema.
11. If using custom component: Pattern A/B use framework dispatch; Pattern C/D must dispatch ALL expected attribute names matching `@EventAttribute` declarations.
