> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fga.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Attributes and metadata

> Sync a field on a user, organization, or connection, such as a metadata value or a profile attribute, into an FGA relation as it changes, by diffing against the previous value or reconciling with tuple filters.

Use this when a field on a user, organization, or connection should drive an FGA relation. Metadata is the most common case: a user's `department_id` in `app_metadata` controlling what they can access, or an organization's `plan` in `metadata` gating a feature. The same pattern works for any other attribute on these objects; see [Other attributes](#other-attributes).

Auth0 doesn't emit a dedicated event per field. Instead, `user.updated`, `organization.updated`, and `connection.updated` fire on any change to the object. The event carries the object's *entire* new state in `data.object`, together with the state before the change in `data.previous_object`. The tuples that result from a change have to be derived from these two sources, and there are two ways to do it: [Option 1](#option-1-diff-against-previous_object) and [Option 2](#option-2-reconcile-with-tuple-filters), compared under [Choosing an approach](#choosing-an-approach).

**This only works when the value maps onto a bounded, enumerable relation**: a department, a cost center, a plan tier, something you'd be comfortable writing as `sometype:{value}` in your model. Free-form or high-cardinality values (an arbitrary text note, a user's date of birth, anything with effectively unlimited distinct values) don't translate into FGA tuples usefully.

**Use `app_metadata`, not `user_metadata`, for anything that grants access.** `user_metadata` is intended for data the user can edit, so a permission derived from it can be changed by the user it applies to.

## Model

FGA model · DSL

The same shape applies to organization metadata: swap `user`/`department` for `organization`/`plan`, as in the [subscription plan example](https://docs.fga.dev/relationship-sync/mapping-language.md#tuple-filters).

## Option 1: Diff against `previous_object`

Compare the field in `data.object` with the same field in `data.previous_object`, and act only when they differ. Use `?.` so a user with no `app_metadata` yet doesn't make the rule fail.

```yaml theme={null}
version: "1"

rules:
  - name: "Set department when it changes"
    when: >-
      input.type == "user.updated" &&
      input.data.object.app_metadata?.department_id != nil &&
      input.data.object.app_metadata?.department_id !=
        input.data.previous_object?.app_metadata?.department_id
    variables:
      user_id: input.data.object.user_id
      department_id: input.data.object.app_metadata.department_id
    tuples:
      - user: "department:{{ variables.department_id }}"
        relation: "department"
        object: "user:{{ variables.user_id }}"

  - name: "Remove the previous department when it changes or is cleared"
    when: >-
      input.type == "user.updated" &&
      input.data.previous_object?.app_metadata?.department_id != nil &&
      input.data.object.app_metadata?.department_id !=
        input.data.previous_object?.app_metadata?.department_id
    variables:
      user_id: input.data.object.user_id
      previous_department_id: input.data.previous_object.app_metadata.department_id
    tuples:
      - action: delete
        user: "department:{{ variables.previous_department_id }}"
        relation: "department"
        object: "user:{{ variables.user_id }}"

tests:
  - name: "user.updated sets department when it first appears"
    input:
      type: "user.updated"
      data:
        object:
          user_id: "u_alice"
          app_metadata:
            department_id: "dept_eng"
        previous_object:
          user_id: "u_alice"
          app_metadata: {}
    expect_tuples:
      - user: "department:dept_eng"
        relation: "department"
        object: "user:u_alice"

  - name: "user.updated does nothing when the department is unchanged"
    input:
      type: "user.updated"
      data:
        object:
          user_id: "u_alice"
          name: "Alice Smith"
          app_metadata:
            department_id: "dept_eng"
        previous_object:
          user_id: "u_alice"
          name: "Alice"
          app_metadata:
            department_id: "dept_eng"
    expect_tuples: []
```

The two rules together cover every case:

| Change | Set rule | Remove rule |
| - | - | - |
| Field appears (`nil` to `a`) | writes `a` | skipped |
| Field changes (`a` to `b`) | writes `b` | deletes `a` |
| Field is cleared (`a` to `nil`) | skipped | deletes `a` |
| Field is unchanged, or the update is for another field | skipped | skipped |

When the field changes from `a` to `b`, both rules fire on the same event. Every write and delete ignores conflicts (see [Idempotent writes and deletes](https://docs.fga.dev/relationship-sync/mapping-language.md#idempotent-writes-and-deletes)), so reprocessing an event is safe.

## Option 2: Reconcile with tuple filters

Ignore `previous_object` and reconcile against what FGA holds now. On every update, the mapping engine reads the tuples matching the filter, compares them to the desired state in `tuples`, and writes the minimum changes. This is the [reconcile pattern](https://docs.fga.dev/relationship-sync/mapping-language.md#tuple-filters) (read-diff-write), the same one used for the organization plan in the mapping language reference.

```yaml theme={null}
version: "1"

rules:
  - name: "Set department from user attribute"
    when: >-
      input.type == "user.updated" &&
      input.data.object.app_metadata?.department_id != nil
    variables:
      user_id: input.data.object.user_id
      department_id: input.data.object.app_metadata.department_id
    tuple_filters:
      - relation: department
        object: "user:{{ variables.user_id }}"
    tuples:
      - user: "department:{{ variables.department_id }}"
        relation: "department"
        object: "user:{{ variables.user_id }}"

  - name: "Remove department when cleared from user attribute"
    when: >-
      input.type == "user.updated" &&
      input.data.object.app_metadata?.department_id == nil
    variables:
      user_id: input.data.object.user_id
    tuple_filters:
      - relation: department
        object: "user:{{ variables.user_id }}"
        action: delete

tests:
  - name: "user.updated sets department from app_metadata"
    input:
      type: "user.updated"
      data:
        object:
          user_id: "u_alice"
          app_metadata:
            department_id: "dept_eng"
    expect_tuples:
      - user: "department:dept_eng"
        relation: "department"
        object: "user:u_alice"
    expect_tuple_filters:
      - relation: department
        object: "user:u_alice"

  - name: "user.updated removes department when app_metadata is absent"
    input:
      type: "user.updated"
      data:
        object:
          user_id: "u_alice"
    expect_tuples: []
    expect_tuple_filters:
      - relation: department
        object: "user:u_alice"
        action: delete

  - name: "user.updated removes department when app_metadata clears it"
    input:
      type: "user.updated"
      data:
        object:
          user_id: "u_alice"
          app_metadata: {}
    expect_tuples: []
    expect_tuple_filters:
      - relation: department
        object: "user:u_alice"
        action: delete
```

Both rules key off the same field, split by whether it's present: the first reconciles a new value in; the second, guarded by the opposite condition, deletes it outright when the field is cleared. A reconciling filter can't also be told "and if there's nothing to write, delete instead"; that needs its own rule with `action: delete`.

Because the second rule matches every `user.updated` event where the field is absent, it deletes `department` tuples for all those users, including ones your application wrote. This is the incompatibility noted in the table above.

## Choosing an approach

| | Option 1: Diff against `previous_object` | Option 2: Reconcile with tuple filters |
| - | - | - |
| **Speed and writes** | Faster. Does nothing when the field didn't change, and never reads from FGA. Fewer calls, which helps if you're close to rate limits. | Slower. Every update event reads the current tuples from FGA and writes the difference, even when nothing changed. |
| **Self-healing** | No. It only removes the exact value Auth0 says was there before. If an earlier event was missed, a stale tuple stays until you [reconcile](https://docs.fga.dev/relationship-sync/managing.md#reconciling). | Yes. Every update event brings FGA back to what Auth0 says now, including after a missed event. |
| **Tuples your app writes** | Compatible. It only touches the tuple for the old and new value, so your application can write other `department` tuples on the same user. | Not compatible. The filter claims every `department` tuple on that user and deletes any that aren't the current value, including ones your application wrote. |

If Auth0 is the only writer of the relation and you'd rather it correct itself, choose Option 2. If your application writes tuples of the same shape, or event volume is a concern, choose Option 1 and plan to reconcile periodically.

## Other entities

The examples above use a user's `app_metadata`. Swap the event and the path for other entities:

| Entity | Event | Metadata path | Notes |
| - | - | - | - |
| User | `user.updated` | `input.data.object.app_metadata.*` | Also `user_metadata`, but see the note above about access. |
| Organization | `organization.updated` | `input.data.object.metadata.*` (use `metadata?.` in guards) | See the [subscription plan example](https://docs.fga.dev/relationship-sync/mapping-language.md#tuple-filters) for the write side of Option 2. Add a matching "remove when cleared" rule if you need one. |
| Connection | `connection.updated` | `input.data.object.metadata.*` | The `metadata` field is defined separately for each connection type, so use `json_path` (see [Expression language](https://docs.fga.dev/relationship-sync/mapping-language.md#expression-language)) if one mapping handles several types. |

For Option 1, read the same path from `input.data.previous_object` instead of `input.data.object`.

## Other attributes

Nothing here is specific to metadata. Any field on `data.object` works the same way, as long as its value maps onto a bounded relation. On a user, for example, a boolean such as `blocked` or `email_verified` can drive a relation, and so can an identity-provider field such as the connection a user signed up through.

Point the rules at the field's path instead of `app_metadata.*`. For a top-level user field, that is `input.data.object.<field>` and `input.data.previous_object.<field>`. Check the field's exact path in the [event schema](https://auth0.com/docs/events), since nested fields such as `identities` are arrays and need an [iterator](https://docs.fga.dev/relationship-sync/mapping-language.md#iterator-fan-out) rather than a direct path.

Don't use an email address, phone number, or other personally identifiable attribute as the value that ends up in a tuple; see [Data privacy](https://docs.fga.dev/relationship-sync/overview.md#data-privacy). Map it to an opaque value, such as a domain ID, first.

## Known limitations

* **Both options handle one field at a time.** If several attribute-driven relations change in the same update, each needs its own rule set. For Option 2, that can be its own `tuple_filters` entry within one rule, scoped to that relation; a single filter can't reconcile two different relations against two different desired values at once.
