---
name: rule-development-contract
description: "Rubix rule annotation contract, reading patterns, and enhancement protocol. Derived from fc-plugin-order-2.1.0 OOTB analysis (103 rules)."
version: 1.1
relevant-skills: rule-scaffold, custom-code, feature-plan, feature-explain, build, workflow-builder, workflow-analyzer, pre-deploy-check
load: on-demand
---

# Rubix Rule Development Contract

## 0. Execution Context — The Rubix User

Workflow rules do NOT execute as the user who dispatched the event. Each retailer has a dedicated **Rubix user** — a platform-managed API service account that the workflow engine authenticates as when running rules.

**Naming pattern:** `<ACCOUNT_NAME>-<RETAILER_ID>-rubix` (e.g., `ACME-2-rubix`)

| Property | Value |
|----------|-------|
| Type | `API` (service account) |
| Scope | One per retailer, bound to `primaryRetailer` |
| Created | Auto-created when a retailer is created |
| Typical roles | `ADMIN` + `GRAPHQL` |

**Why this matters for rule development:**
- `context.api()` calls inside rules authenticate as the Rubix user. If the Rubix user lacks `GRAPHQL` role, all GraphQL queries/mutations from rules will fail.
- The dispatching user's identity is NOT available to rules by default. To capture who clicked the button, explicitly pass it via `@EventAttribute` (e.g., `actionedBy` from user action `defaultValue: "{{me.username}}"`).
- Permission errors in rule execution often trace back to the Rubix user's roles, not the dispatching user.

**Discovery query:**
```graphql
{ users(first: 10, type: ["API"]) { edges { node { username primaryRetailer { ref } roles { role { name } } } } } }
```
Filter for `*-rubix` pattern.

---

## 1. Two Input Channels — NEVER Mix

| Annotation | Purpose | Declared on | Read via | Configured in |
|---|---|---|---|---|
| `@ParamString` | Rule properties (static) | Class | `context.getProp("name")` | Workflow JSON `rules[].props{}` |
| `@EventAttribute` | Event attributes (dynamic) | Class | `event.getAttributes().get("name")` | Event payload / user action form |

**They are different input channels.** A `@ParamString` is static config set at workflow design time. An `@EventAttribute` is dynamic data sent at runtime via events or user action forms.

### Runtime Behaviour of Event Attributes

- **Shared across all rules in a ruleset.** When an event triggers a ruleset, every rule in that ruleset receives the same `event.getAttributes()` map. If a user action sends `appeasementAmount`, `appeasementReason`, and `comment`, all rules in that ruleset can read all three — regardless of which rule "needs" which.
- **Immutable within that event execution.** Rules MUST NOT modify the event attributes map. Rule 1 cannot change `appeasementAmount` and have Rule 2 see the new value. To pass data between rules within the same ruleset, write to entity attributes instead (the entity is the shared mutable state, the event is not).

### Imports

```java
// Rule props (always available in rubix-plugin-base)
import com.fluentretail.rubix.rule.meta.ParamString;
import com.fluentretail.rubix.rule.meta.multi.ParamStrings;       // wrapper for multiple

// Event attributes
import com.fluentretail.rubix.rule.meta.EventAttribute;
import com.fluentretail.rubix.rule.meta.multi.EventAttributes;   // wrapper for multiple

// Metadata only (entity type the rule accepts)
import com.fluentretail.rubix.rule.meta.EventInfo;
import com.fluentretail.rubix.rule.meta.RuleInfo;
```

## 2. Deciding What Goes Where

Ask these questions for EACH input the rule needs:

| Question | If YES → | If NO → |
|---|---|---|
| Is it set once at workflow design time and never changes? | `@ParamString` | ↓ |
| Does it come from a user action form field? | `@EventAttribute` | ↓ |
| Does it come from an upstream rule's event? | `@EventAttribute` | ↓ |
| Is it a default/fallback for an event attribute? | `@ParamString` (with event attr taking priority) | ↓ |

**Common `@ParamString` patterns** (from OOTB):
- `eventName` — event to send after rule completes (most common, ~40 rules)
- `fulfilmentType`, `creditMemoType`, `invoiceType` — entity subtype to create
- `excludedFulfilmentStatuses` — comma-separated status list
- `networkRef`, `virtualCatalogueRef` — reference lookups
- `defaultBillingAccountRef`, `defaultCurrency` — fallback values

**Common `@EventAttribute` patterns** (from OOTB):
- User action form fields: `appeasementAmount`, `appeasementReason`, `actionedBy`, `comment`
- Structured payloads: `returnItems` (array), `proposedFulfilment` (object), `pickupLocation` (address)
- Upstream rule outputs: `locations` (map), `virtualPositions` (list), `packedItems` (list)
- Entity references: `entityReference`, `fulfilmentArticles`

### 2.1 Configuration vs User Action Input (Guardrail)

Treat workflow configuration and user action input as different concerns:

| Question | Channel |
|---|---|
| Should this value stay constant for all executions of the ruleset? | Ruleset `props{}` + `@ParamString` |
| Should this value be captured from a person at click-time? | User action `attributes[]` + `@EventAttribute` |
| Should this value default but still be overrideable by user input? | Keep default in `@ParamString`, read override from `@EventAttribute` first |

This separation is required if Mystique needs to render the right input controls later.

## 3. Reading Patterns

### Rule props — always String via getProp
```java
String eventName = context.getProp("eventName");
String type = StringUtils.defaultIfEmpty(context.getProp("creditMemoType"), "CREDIT_MEMO");
```

### Event attributes — three OOTB patterns

```java
// Pattern 1: Direct map access (simple values — strings, numbers)
Map<String, Object> attrs = context.getEvent().getAttributes();
String amount = attrs.get("appeasementAmount").toString();
String reason = (String) attrs.get("appeasementReason");

// Pattern 2: Typed single attribute (structured objects)
Map locations = (Map) event.getAttribute("locations", Map.class);
Map dispositionMap = (Map) event.getAttribute("returnAuthorisationDisposition", Map.class);

// Pattern 3: Convert full attributes map to typed POJO (when many fields)
AppeasementAttributes appeasement = (AppeasementAttributes) ValueConverter.convert(
    event.getAttributes(), AppeasementAttributes.class);
double amount = Double.parseDouble(appeasement.getAppeasementAmount());
```

### ANTI-PATTERN — never do this
```java
// WRONG: fragile JSON serialization roundtrip, may return null
JsonNode node = DynamicUtils.getJsonPath(context, "event.appeasementAmount");
```

## 3.1 Designing for Reusability

Before writing a rule, decide: **generic or entity-specific?**

| Question | If YES | If NO |
|---|---|---|
| Could this logic run on a different entity type (ORDER, FULFILMENT, RETURN_ORDER)? | Design generic — parameterize entity-varying values | Design scoped — entity prefix in name |
| Could this rule be used in multiple workflows with different prop values? | Externalize config via `@ParamString` | Hardcoding is acceptable |
| Does the rule read entity-specific GraphQL fields (e.g., `order.items`, `fulfilment.fromAddress`)? | Scoped — entity-specific fields prevent generalization | Generic is possible |

### Parameterization for reuse

Make variable parts configurable instead of hardcoded:

```java
// WRONG — hardcoded, only works for one workflow
String eventName = "FulfilmentCreated";
String status = "AWAITING_PICKUP";

// RIGHT — parameterized, reusable across workflows
String eventName = context.getProp("eventName");
String status = context.getProp("targetStatus");
```

### Naming convention

- **Generic rules:** No entity prefix — `SetEntityStatus`, `ValidateRequiredAttributes`, `SendEventWithAttributes`
- **Entity-specific rules:** Entity prefix — `CalculateAppeasementAmount`, `CreateFulfilmentFromSourcingLocation`
- **Test:** If the name still makes sense with a different entity type, it should be generic

### Document reuse in @RuleInfo

```java
@RuleInfo(
    name = "SetEntityStatus",
    description = "Sets entity status from prop. Reusable across ORDER, FULFILMENT, CONSIGNMENT workflows.",
    accepts = {@EventInfo(entityType = "ORDER"), @EventInfo(entityType = "FULFILMENT")}
)
```

Full patterns and examples: `knowledge/platform/reusability-patterns.md`.

## 4. Rule Class Template

```java
@RuleInfo(
    name = "RuleName",
    description = "What this rule does. Uses props {propName}. Reads event attrs.",
    accepts = {@EventInfo(entityType = "ORDER")}
)
@ParamString(name = "eventName", description = "Event to fire on completion")
@ParamString(name = "defaultFallback", description = "Fallback when event attr missing")
@EventAttributes(value = {
    @EventAttribute(name = "dynamicField1"),
    @EventAttribute(name = "dynamicField2"),
    @EventAttribute(name = "structuredPayload", type = "CUSTOM_TYPE")
})
public class RuleName extends BaseRule {
    @Override
    public void run(ContextWrapper context) {
        // 1. Read rule props (static config)
        String eventName = context.getProp("eventName");
        String fallback = context.getProp("defaultFallback");

        // 2. Read event attributes (dynamic data)
        Map<String, Object> attrs = context.getEvent().getAttributes();
        String field1 = (String) attrs.get("dynamicField1");
        String field2 = (String) attrs.get("dynamicField2");

        // 3. Business logic...

        // 4. Send downstream event (if applicable)
        context.action().sendEvent(eventName, ...);
    }
}
```

## 5. User Action ↔ Rule Contract

When a workflow user action triggers a rule, the **contract** is:

```
User Action attributes[] ──→ event.getAttributes() ──→ @EventAttribute declares
Workflow ruleset props{}  ──→ context.getProp()     ──→ @ParamString declares
```

The user action `attributes[]` in workflow JSON defines the form fields:
```json
"attributes": [
    { "name": "appeasementAmount", "label": "Amount", "type": "STRING", "mandatory": true },
    { "name": "appeasementReason", "label": "Reason", "type": "STRING", "mandatory": true }
]
```

Each attribute name MUST have a matching `@EventAttribute` on the rule class. Each ruleset prop MUST have a matching `@ParamString`.
For full UI visibility + module mapping guardrails, see `knowledge/platform/user-action-contract.md`.

### 5.1 Four-Layer Wiring Chain

The full path from rule code to rendered UI has four layers — each must be consistent:

```
Layer 1: @EventAttribute (Java)  →  Layer 2: workflow attributes[] (JSON)  →  Layer 3: Transition API  →  Layer 4: Mystique fc.page (UI)
```

| Layer | What it defines | Configured in | Failure mode if wrong |
|---|---|---|---|
| 1. Rule annotation | `@EventAttribute(name = "X")` — what the rule expects | Java class | Rule gets null, silent logic failure |
| 2. Workflow JSON | `attributes[].name = "X"`, `type`, `source`, `mandatory` — form field schema | Workflow JSON `userActions[]` | Field missing from modal, or wrong input type |
| 3. Transition API | Returns enriched `userActions[]` with options from settings | Platform runtime | Buttons invisible (missing eventName/subtype/modules) |
| 4. Manifest override | `overrides.X.component`, `overrides.X.defaultValue` — custom rendering | Manifest fragment JSON | Wrong component, missing pre-population |

**Layer 2 is the source of truth for the form contract.** Layer 4 can override presentation but cannot add/remove fields.

### 5.2 Workflow Attribute Schema (Layer 2)

```json
{
  "name": "appeasementAmount",     // MUST match @EventAttribute name in rule
  "label": "Amount",               // UI label (supports i18n keys)
  "type": "STRING",                // Field type: STRING, INTEGER, BOOLEAN, FLOAT
  "source": "",                    // "" = free text, "settings.<key>" = dropdown from setting
  "defaultValue": "",              // Pre-fill value ("" recommended, {{template}} works on v4.80+)
  "mandatory": true                // Required field
}
```

**Attribute `type` → FieldRegistry auto-selection:**
- `STRING` → text input (or dropdown if `source` is a setting reference)
- `INTEGER` → number input
- `BOOLEAN` → checkbox/toggle
- `FLOAT` → decimal number input
- Custom types (e.g., `ADDRESS`, `FULFILMENT_PACK`, `RETURNITEMS`) → specialized SDK field components

**Dropdown creation:** Set `source: "settings.<KEY>"` — platform populates `options[]` in Transition API response from the setting value. Three requirements for the dropdown to work:
1. **Lowercase prefix**: `"settings."` (NOT `"Settings."` — capital S silently fails)
2. **Setting value format**: Must be `{ "active": [{ "name": "Display Label", "value": "SUBMITTED_VALUE" }] }` — a plain array without the `"active"` wrapper silently renders an empty dropdown
3. **Setting retailer context**: Must match the workflow's retailer

### 5.3 Manifest Overrides (Layer 4)

Mystique manifests can override how user action fields render without changing the workflow contract:

```json
{
  "component": "fc.page",
  "props": {
    "actions": {
      "primary": [{
        "type": "userAction",
        "name": "OrderAddressChange",
        "overrides": {
          "deliveryAddress": {
            "label": "i18n:fc.om.orders.detail.userAction.deliveryAddress.label",
            "component": "custom.AddressField",
            "defaultValue": {
              "street": "{{orderById.fulfilmentChoice.deliveryAddress.street}}",
              "city": "{{orderById.fulfilmentChoice.deliveryAddress.city}}"
            }
          }
        }
      }]
    }
  }
}
```

**Override properties:**

| Property | Purpose | Example |
|---|---|---|
| `label` | Custom label (supports `i18n:` prefix) | `"i18n:fc.om.cancel.reason"` |
| `component` | Replace FieldRegistry auto-selection with custom component | `"custom.CurrencyField"` |
| `defaultValue` | Pre-populate from page query context (supports `{{template}}`) | `"{{orderById.ref}}"` |
| `inputTemplate` | Transform value before sending as event attribute | `"{\"ref\": \"{{value}}\"}"` |
| `multiple` | Allow multiple selections | `true` |

**Three levels of field customization:**
1. **Auto** — `actions: true` on fc.page, FieldRegistry selects component from `attributes[].type`
2. **Selective override** — `actions: { primary: [{ overrides: { fieldName: { label: "..." } } }] }`
3. **Full custom** — `overrides: { fieldName: { component: "custom.Widget" } }` replaces the auto-selected component

### 5.4 fc.page Requirements for User Actions to Appear

For Mystique to render user action buttons, the page MUST have:
1. `"actions": true` (or explicit actions config) on the `fc.page` component
2. `retailerId` accessible — either from URL path param (`:retailerId`) or entity's `retailer.id` in query response
3. `me` query in page `dataSources` — `{ "me": { "query": "me { username }" } }`
4. Entity returned as first result in the page's GraphQL query

Without any of these, buttons silently don't appear — no error, no warning.

## 5.5 Designing for Reusability

**Default assumption: reusable unless proven otherwise.** Before naming a rule or finalizing its inputs, consider whether the same logic could serve multiple entity types.

### Decision checklist

| Question | If YES | If NO |
|----------|--------|-------|
| Does the rule read entity-specific fields via hardcoded paths? | Extract field paths into `@ParamString` props | Already generic |
| Does the rule only make sense for one entity type's data model? | Entity-specific naming is correct | Use generic naming |
| Could another entity type benefit from the same logic with different props? | Parameterize with `@ParamString` | Keep entity-specific |

### Generic rule pattern

```java
@RuleInfo(name = "SetEntityStatus", description = "Transition entity to a configured status")
@ParamString(name = "targetStatus", description = "Status to set")
@ParamString(name = "targetEntityType", description = "Entity type (ORDER, FULFILMENT, etc.)")
public class SetEntityStatus implements Rule {
    // Logic uses context.getProp("targetStatus") — works for any entity
}
```

### Entity-specific rule pattern (when correct)

```java
@RuleInfo(name = "CalculateAppeasementAmount", description = "Calculate appeasement against order total")
@EventAttribute(name = "appeasementAmount")
public class CalculateAppeasementAmount implements Rule {
    // Logic deeply tied to ORDER.totalPrice, CREDIT_MEMO.totalAmount — not generalizable
}
```

### Naming convention

| Type | Pattern | Example |
|------|---------|---------|
| Generic (reusable) | Verb + general noun | `SetEntityStatus`, `ExtractAttributeToEvent`, `ValidateRequiredAttributes` |
| Entity-specific | Verb + entity noun | `CalculateAppeasementAmount`, `ValidateReturnOrderItems` |

See `knowledge/platform/reusability-patterns.md` for the full OOTB-first protocol and component reusability patterns.

---

## 6. Enhancement Protocol

When modifying an existing rule to accept new inputs:

### Adding a new event attribute
1. Add `@EventAttribute(name = "newField")` to class annotations
2. Add reading code: `attrs.get("newField")`
3. Update workflow user action `attributes[]` with new form field
4. Bump POM version (mandatory — Fluent caches JARs)
5. Build → deploy → redeploy workflow with new attribute

### Adding a new rule prop
1. Add `@ParamString(name = "newProp", description = "...")` to class annotations
2. Add reading code: `context.getProp("newProp")`
3. Update workflow ruleset `props{}` with new value
4. Bump POM version → build → deploy → redeploy workflow

### Changing an event attribute to a rule prop (or vice versa)
1. This is a **breaking change** — the input source changes
2. Must update BOTH the rule code AND the workflow JSON
3. Consider keeping both channels with priority: event attr > rule prop (fallback pattern)

### Version bump is MANDATORY
- Fluent caches JARs by version+filename — same version = stale code runs
- Bump ALL POMs + module.json → build → deploy
- Never reuse a version number

## 7. Audit Checklist (for existing rules)

When reviewing custom rules, check:

- [ ] Every `event.getAttributes().get("X")` call has a matching `@EventAttribute(name = "X")`
- [ ] Every `context.getProp("X")` call has a matching `@ParamString(name = "X")`
- [ ] No `DynamicUtils.getJsonPath(context, "event.X")` for reading event attributes
- [ ] Workflow user action `attributes[].name` matches `@EventAttribute` names
- [ ] Workflow ruleset `props{}` keys match `@ParamString` names
- [ ] Rule has `@RuleInfo` with `accepts = {@EventInfo(entityType = "...")}`

## 8. GraphQL Operations in Rules

Rules query and mutate entities via `context.api()`. Two approaches exist — **DynamicUtils** (dynamic, no codegen) and **Apollo-generated classes** (typed, compile-time safe). See:
- `knowledge/platform/graphql-in-rules.md` — Decision framework, execution model, gotchas
- `knowledge/platform/graphql-in-rules-reference.md` — Full API reference with code examples

**Key rule:** `context.action().mutation()` only QUEUES — the mutation executes AFTER the rule's `run()` method returns. You cannot read the result of a mutation in the same rule.

## 9. Guardrail Enforcement by Skills

When these checks are automated by skills, enforce this minimum:

- `workflow-builder`: validate user action `attributes[].name` contract against expected rule input names.
- `workflow-analyzer`: audit mismatches between declared user action attributes and rule annotations.
- `pre-deploy-check`: block deployments with missing required visibility fields and broken annotation mapping where source is available.
