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

# Tap schemas

> How a Tap schema and its x-* annotations become Provider Pool tables, and what each declaration does to the data you query.

Each [Tap](/connectors/taps.md) has a **schema**: a JSON Schema for one record
that the Tap emits. The schema lists the fields, and `x-*` annotations on the
fields say what each field means to Parable: which field is the key, which
version of a record wins, which field is a person's email, and who can open
the row. Other declarations sit on the Tap and on the Provider Plugin.

Parable maintains every Provider Plugin. This page is the reference for what
each declaration does to the tables you query. Read the declarations on the
plugin of one connection:

```bash theme={null}
curl "$PARABLE_API_URL/api/vendors/tenant-connector-instances/$CONNECTION_ID" \
  -H "Authorization: Bearer $PARABLE_API_TOKEN" \
  -H "X-Tenant: $PARABLE_WORKSPACE"
```

Each item of `connector.taps` has `schema`, the Tap declarations, and
`responseExtraction`. Your Workspace keeps its own copy of each schema as
`outputSchema` on the Workspace Tap. Some annotations on that copy are yours
to change. See [Taps](/connectors/taps.md#change-a-tap-schema-in-your-workspace).

## From Tap schema to SQL

```mermaid theme={null}
flowchart TD
  S["Tap schema and its annotations"] --> P["Table plan: tables, columns, keys, layout"]
  R["Records that a sync reads or a sender delivers"] --> V["Check each record against the schema"]
  V --> M["Merge each load into the Provider Pool"]
  P --> M
  M --> T["providers.{connector}.{table}"]
  M --> I["providers.identity.*"]
  M --> A["providers.access.*"]
  P --> C["Catalog: tables, columns, keys, foreign keys"]
  T --> Q["Your SQL"]
  C --> Q
```

1. **Plan.** Parable reads the schema and plans the tables of the Tap: the
   main table, child tables for arrays, the columns, the merge key, the
   ordering column, and the layout.
2. **Check.** A sync checks each record against the schema. A value that does
   not match lands as null, and the row records the reason in
   `_parable_validation`. A record without its merge key goes to a quarantine
   table instead.
3. **Merge.** Parable merges each load into the tables by the merge key and the
   ordering column, as `dataKind` and `deletionSemantics` say.
4. **Derive.** Taps that are identity directories feed `providers.identity.*`.
   Taps that carry access facts feed `providers.access.*`.
5. **Publish.** The catalog lists each table and column, with its keys and
   foreign keys. You query the tables with SQL.

[Tap tables](/connectors/tap-projection.md) shows the result in SQL: table and
column names, current rows, child tables, and identity joins.

## Annotations

An annotation is a key on one field of the schema, such as
`"x-transformDedupKey": true`. Unless a section says otherwise, an
annotation applies to a field of the record's root object.

### Keys and ordering

| Annotation | Value | What it does |
| - | - | - |
| `x-transformDedupKey` | `true` | Marks the field that identifies the entity. The main table keeps one current row for each key. |
| `x-transformOrdering` | `true` | Marks the field that picks the newer version of one key. The greater value wins. At most one for each table. |
| `x-transformPartitionDate` | `true` | Marks a date that every row must have. A row whose value is missing, or is not a date, goes to the quarantine table. At most one for each table. |
| `x-transformFingerprintInput` | `true` | Marks a field as an input to change detection. It adds no column and does not change how rows merge. |
| `x-transformForeignKey` | `{"tap": "<tap>", "idField"?: "<field>"}` | Marks a field that references a row of another Tap of the same plugin. |

**The merge key.** Parable merges the main table on one column:

1. The first field marked `x-transformDedupKey`, by field name.
2. Else the `outputField` of `responseExtraction.syntheticPrimaryKey`.
3. Else `responseExtraction.primaryKey`.
4. Else a field named `id`.

A Tap marks one root field, and it is the field that
`responseExtraction.primaryKey` names. A Tap that declares a `dataKind` must
mark one. Every part of the merge key's path is `required` in the schema,
because a row without its merge key goes to the quarantine table. A dedup key
inside a nested object identifies that object, not the row. It becomes the
merge key only when the root marks no field. Gong `calls_extensive` keys on
`metaData.id` this way.

A child table merges on `parent_pk` and `ordinal`: the parent's key and the
element's position in the array. A dedup key on a field of an array element
names the element's own key. It does not change the child table's merge key.

**Ordering.** When no field is marked `x-transformOrdering`, the Tap's
`sync.cursorField` is the ordering column, if the schema declares that field.
Otherwise the table has no ordering column. When two rows have the same key,
the row with the greater ordering value wins. A missing value counts as the
oldest. When both values are missing, the later load wins. A child table
carries its root row's ordering column.

**Foreign keys.** An object field marked `x-transformForeignKey` becomes one
`<field>_id` column that holds the referenced row's ID. Notion
`pages.created_by` becomes `created_by_id`. A scalar field that already holds
the key keeps its own column, and the annotation only records the reference.
`idField` names the field of the target Tap that the value matches. It
defaults to `id`.

```json theme={null}
{
  "created_by": {
    "$ref": "#/definitions/NotionPartialUser",
    "x-transformForeignKey": { "tap": "users" }
  },
  "primaryEmail": {
    "$ref": "#/definitions/scalars/Contact.Email",
    "x-transformForeignKey": { "tap": "users", "idField": "primaryEmail" }
  }
}
```

The catalog publishes the column as a foreign key when `idField` is the
target Tap's key, or the person email of an identity directory. A field whose
type is the Tap's own record type is a foreign key to the same table, with no
annotation: a comment's `parent` comment becomes `parent_id`. A foreign key never crosses
Providers.

### Structure

Parable decides the layout of each nested field by its shape:

* An object with at most 20 fields, at most 3 levels deep, flattens into
  `parent__child` columns. `assignee.email` becomes `assignee__email`.
* A wider or deeper object is one struct column. Read one of its fields with
  `profile."userType"` or `profile['userType']`.
* An array of objects at the top level becomes a child table,
  `{tap}__{field}`. A deeper array is one list column.
* An array of scalars is one list column.
* An object with no declared properties, or a map, is one JSON column.

| Annotation | Value | What it does |
| - | - | - |
| `x-json` | `true` | Keeps the field as one JSON column. Parable makes no columns or child tables from inside it. Use it for values whose keys change from record to record. |

`x-json` is the only layout annotation that a stored Tap schema keeps. A Tap
schema that sets `x-flatten`, `x-struct`, or `x-explode` is refused.

### Identity

Identity annotations say which fields are about a person or an account. They
do not make a row unique, and they do not change the columns. A field has at
most one identity role, on a scalar value.

| Annotation | Value | What it does |
| - | - | - |
| `x-transformAccountId` | `true` | The Provider's own ID of the account. |
| `x-transformPersonEmail` | `true` | An email that identifies a person. Parable matches people across Providers on an exact email. |
| `x-transformPersonName` | `true` | The person's name. Parable never matches on a name. |
| `x-transformPersonPhoto` | `true` | The URL of the person's profile photo. Parable never matches on it. |
| `x-transformExternalUserId` | `{"system": "<connector slug>", "by": "id"\|"email"}`, or `true` | The ID of the same person's account in another Provider. |

The typed form of `x-transformExternalUserId` links two accounts. With
`"by": "id"`, the value equals the `x-transformAccountId` of an account in the
named Provider. With `"by": "email"`, it equals that account's person email.
Linear `users.gitHubUserId` declares `{"system": "github", "by": "id"}`, so a
Linear user and the GitHub member with that ID are one person. The bare
`true` form names no Provider. It is a hint, and it links nothing. A plugin
uses the typed form only when the Provider sets the value from a verified
connection, such as an OAuth link.

Only a Tap with `identityDirectory: true` feeds `providers.identity.*`. Its
root table has an `x-transformAccountId` or an `x-transformPersonEmail`
field. An annotation on a field of a nested object counts, in a flattened
column or in a struct column. An annotation inside an array does not. When a
table has two fields with one role, the field nearest the root wins.

### Event layout

| Annotation | Value | What it does |
| - | - | - |
| `x-semantic-role` | `event_time`, `metadata_timestamp`, `business_key`, `effective_from`, or `effective_to` | Says what the field means. Data quality checks read every role. `event_time` also sets the layout of an event table. |

| Role | The field holds |
| - | - |
| `event_time` | When the event happened. |
| `metadata_timestamp` | A time that describes the record, such as when it was created or changed. |
| `business_key` | A key that people use for the record, such as an issue number. |
| `effective_from`, `effective_to` | The start and end of the period when the record is in effect. |

A field with the `event_time` or `metadata_timestamp` role has a date-time
type. On a Tap with `dataKind: event`, each table has at most one
`event_time` field.

Parable lays out each table of an `event` Tap by its event time. When no
field has the `event_time` role, a timestamp ordering column stands in. Each
write is sorted by event time, so a query for one day skips the files of
other days. A table that Parable creates this way is partitioned by
`_parable_event_date`, the UTC day of the row's event time. An older table
keeps its partitioning until a rebuild. Filter on the column to read whole
days:

```sql theme={null}
SELECT count(*)
FROM providers.google.chrome_activity
WHERE _parable_event_date = DATE '2026-10-01'
```

A child table whose rows carry the root row's event time gets the same
layout, and each child row gets the day of that event time. A row with no
event time has a null `_parable_event_date`. `snapshot` and `changelog`
tables have no event layout.

### Wire format

| Annotation | Value | What it does |
| - | - | - |
| `x-temporal-format` | `unix`, `unix_millis`, `unix_micros`, or `unix_nanos` | Says that a date-time field arrives as an epoch number in that unit: seconds, milliseconds, microseconds, or nanoseconds. Parable stores it as a timestamp. |

The annotation is valid only on a date-time field. An integer ordering field
declares a date-time type with `x-temporal-format`, so that it sorts as a
time.

### Lineage

| Annotation | Value | What it does |
| - | - | - |
| `x-injectedByParent` | `true` | A sync copies the value from the parent record through `parentContextFields`. The Provider does not return it on this record. |
| `x-syntheticField` | `true` | A sync computes the value, such as the output of a synthetic key. The Provider does not return it. |

Both fields land as ordinary columns. GitHub copies the repository's
`full_name` onto each pull request, so you can filter pull requests by
repository without a join.

### Reader text

| Annotation | Value | What it does |
| - | - | - |
| `x-purpose` | Markdown | What the field means and what it is good for. A search of the available data reads it, with the field's `title` and `description`. |
| `x-icon` | An icon name | The field's icon. |

Reader text never changes the stored data. A change to it changes only what
the catalog says (`CATALOG_ONLY` in
[When a Tap schema changes](#when-a-tap-schema-changes)).

### Search

| Annotation | Value | What it does |
| - | - | - |
| `x-transformEmbed` | `true`, or an object with `context`, `text`, `label`, `format`, and `order` | Marks free text that row search indexes. It does not change the stored column. |

```json theme={null}
{
  "body": {
    "$ref": "#/definitions/scalars/Text.Markdown",
    "x-transformEmbed": { "context": ["title"] }
  }
}
```

* `context` names fields of the same row whose values go before each piece of
  text, such as the title.
* `text` and `label` apply to an array of objects. `text` is the path to each
  item's text, and `label` is the path to one value for each item, such as
  the speaker.
* `format: "adf"` says that the text is an Atlassian Document Format
  document.
* `format: "notion"` with `order` goes on a foreign key. Row search then reads
  the rows that name each referenced row, in that order, as one document.

[Search](/data/search.md) explains how you search the rows.

### Access

Sixteen `x-access*` annotations declare who can open each row in the
Provider. [Source permissions](/connectors/source-permissions.md) describes each
one, with examples.

| Annotation | Declares |
| - | - |
| `x-accessPrincipal` | The row is a user, group, team, or role. |
| `x-accessPrincipalRef` | Another value, such as an email, that names the principal. |
| `x-accessInactiveWhen` | The value that makes the row inactive. |
| `x-accessGuestWhen` | The value that makes a user a guest. |
| `x-accessMemberOf` | The group, team, or role that the member belongs to. |
| `x-accessManager` | The direct manager of the row's user, for manager reads. |
| `x-accessGrants` | An array whose elements are grants on the row. |
| `x-accessResource` | The record that a grant row is about. |
| `x-accessSubject` | Who a grant or membership is for. |
| `x-accessKind` | The subject's kind for each value. |
| `x-accessLevel` | The Provider's role as a common level. |
| `x-accessDiscoverable` | Whether a link grant is link-only. |
| `x-accessExpiresAt` | When a grant stops. |
| `x-accessOwner` | The owner of the row. With `managers`, the owner's direct managers too. |
| `x-accessParent` | The row whose access this row inherits. |
| `x-accessVisibility` | An audience that the row's value adds. |

An access annotation never changes how a table is planned or stored.

### Workspace only

| Annotation | Value | What it does |
| - | - | - |
| `x-exclude` | `true` | Hides a top-level field from SQL in your Workspace. A sync still reads the field. |

Only your Workspace's `outputSchema` uses `x-exclude`.
[Taps](/connectors/taps.md#change-a-tap-schema-in-your-workspace) lists the
fields that you cannot exclude.

## Declarations on the Tap and the plugin

These declarations are not `x-*` keys, but they change the tables in the
same way.

### On the plugin

| Field | What it does |
| - | - |
| `visibilityModel` | How the Provider decides who sees a record: `record_acl`, `container_acl`, `source_users`, `unrestricted`, or `not_modeled`. See [Source permissions](/connectors/source-permissions.md#the-visibility-model). |
| `defaultAudience` | The audience of every Tap that has no anchor and no audience of its own: `source_users` or `unrestricted`. |

### On the Tap

| Field | What it does |
| - | - |
| `dataKind` | How versions combine: `snapshot` (a newer row for a key replaces the earlier row), `changelog` (rows merge by key and ordering), or `event` (rows are appended). |
| `deletionSemantics` | How a deletion in the Provider shows: `by_absence`, `tombstone`, or `undetectable`. See [Tap tables](/connectors/tap-projection.md#ordering-keys). |
| `deduplicateAcrossLoads` | `event` Taps only. When `true`, an event that two loads read is stored once. Otherwise each load appends its copy. |
| `identityDirectory` | When `true`, the Tap is the Provider's list of accounts and feeds `providers.identity.*`. |
| `responseExtraction.primaryKey` | The field that identifies a record when a sync reads it. It is the merge key when no field is marked `x-transformDedupKey`. |
| `responseExtraction.syntheticPrimaryKey` | Fields that a sync hashes into a key when the Provider sends no stable ID. `outputField` names the field that holds the hash. |
| `parentContextFields` | Fields that a sync copies from the parent record onto each child record. Each copied field is marked `x-injectedByParent`. |
| `sync.cursorField` | The Provider's change time. It bounds each request, and it is the ordering column when no field is marked `x-transformOrdering`. |
| `access.roles` | Which access facts the Tap's rows are: `principals`, `memberships`, `grants`, or `resources`. |
| `access.defaultAudience` | Replaces the plugin's `defaultAudience` for this Tap. |
| `requiredAccess` | Read-only. The scopes or objects that the credential needs to read the Tap. See [Required access](/connectors/taps.md#required-access). |
| `docs.title`, `purpose`, `icon` | The Tap's reader text: a short title of 2 to 80 characters, a Markdown explanation of why you use the data, and an icon. See [Taps](/connectors/taps.md#reader-text). |

A synthetic key hashes the listed fields. Google `group_members` keys each
membership on the group and the member:

```json theme={null}
{
  "responseExtraction": {
    "primaryKey": "membership_key",
    "responsePath": "members",
    "syntheticPrimaryKey": { "fields": ["group_id", "id"], "separator": "|" }
  }
}
```

A membership that a sync reads once for each parent repeats each member's ID
once for each group. A key over the parent ID and the member ID keeps one row
for each membership. Without `outputField`, the hash goes into the
`primaryKey` field. A single `"*"` entry hashes every top-level field of the
record.

Some reports have columns that only your Workspace knows, such as a custom
Workday report. `syntheticPrimaryKey.fieldsConfigKey` names a field of the
connection's `ingestionConfig` that lists the key columns. The columns that
you list there replace `fields` for your Workspace. A record that lacks one
of those columns fails the read.

## When a Tap schema changes

When a plugin's Tap schema changes, or your Workspace changes its
`outputSchema`, Parable compares the new table plan with the plan that serves
your queries now. Each table gets one class. A table with several changes
takes the class that needs the most work.

| Class | The change | What happens to stored rows |
| - | - | - |
| `NONE` | The plan is the same. An access declaration can change at this class. | Nothing. |
| `CATALOG_ONLY` | Only reader text changed, or the foreign key that a scalar column declares. | Nothing. The catalog shows the new text. |
| `ADDITIVE` | A new column or struct field, or a wider type that the stored type keeps. | Nothing. Rows written earlier read the new column as null. |
| `REMOVED` | A column, struct field, or table is no longer planned. | Hidden, not rewritten. |
| `NEW_TABLE` | A table that the current plan does not have. | Needs a rebuild. |
| `TABLE_REBUILD` | A renamed table or column, an incompatible type, or a changed dedup key, ordering column, or column encoding. | Needs a rebuild of that table. |
| `TAP_REBUILD` | A changed root merge key, partition date, `dataKind`, `deletionSemantics`, `deduplicateAcrossLoads`, or synthetic key. | Needs a rebuild of every table of the Tap. |

A rebuild rewrites a Tap's stored tables from the records that Parable kept
from earlier syncs. It waits for approval. Until a rebuild finishes, queries
read the tables as they were before the change. Ask your Parable
representative to turn on rebuild approval for your Workspace.

| Action | Operation |
| - | - |
| List the rebuilds of your Workspace, newest first. Filter with `state`. | `GET /api/tenant-connector-taps/provider-pool-rebuilds` |
| Approve a rebuild. Send the `classificationHash` that you were shown. Add `notBefore` to schedule it. | `POST /api/tenant-connector-taps/provider-pool-rebuilds/{id}/approve` |
| Decline a rebuild, with a `reason`. The Tap keeps its current tables, and the same change is not queued again. | `POST /api/tenant-connector-taps/provider-pool-rebuilds/{id}/decline` |

Listing needs permission to view rebuilds. Approving and declining need
permission to approve them. A rebuild names its Tap, its `reasons`, the
tables it rebuilds (`rebuildTableIds`), and its `state`:

| `state` | Meaning |
| - | - |
| `PENDING_APPROVAL` | Waiting for approval. A Tap has at most one. |
| `SUPERSEDED` | A later change replaced it before approval. `supersededById` names the new rebuild. |
| `DECLINED` | Declined. The Tap keeps its current tables. |
| `APPROVED` | Approved, and waiting for its `notBefore` time, if it has one. |
| `BUILDING` | Parable writes the new tables beside the current ones. |
| `READY` | The new tables are built and wait for the swap. |
| `SWAPPED` | Queries read the new tables. |
| `CLEANED` | Parable deleted the replaced tables. |
| `FAILED`, `ABORTED` | The rebuild stopped. Queries still read the current tables. |

An approval that sends an old `classificationHash` fails with `WA-TCT-031`,
because the rebuild changed after you read it. Read it again and send the new
hash. Approving a rebuild that a later change replaced fails with
`WA-TCT-032`, and its details name the new rebuild. While another rebuild of
the same Tap runs, an approval fails with `WA-TCT-033`. A rebuild that is not
waiting for approval fails with `WA-TCT-034`.

The [API Reference](/api-reference/overview.md) has the full request and
response shapes.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.