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

# Data hygiene

> The data-layer automation Pylon ships today: automatic multi-tenancy, owner stamping, schema behaviors, and field-level constraints.

Pylon's data-layer automation is deliberately small and mostly **automatic**. Two built-in plugins wire themselves from your schema — you never name them in a config block — and the SDK adds a few field- and entity-level helpers. Everything else (cascades, slugs, derived fields, versioning) is a [server function](/concepts/functions) or a [policy](/concepts/policies), not a plugin.

## `tenant_scope`

Row-level multi-tenancy. **Automatic** — the signal is a `tenantId` field on the entity. No config entry.

```ts theme={null}
import { entity, field } from "@pylonsync/sdk";

export const Project = entity("Project", {
  tenantId: field.string(),   // ← presence of this field turns on scoping
  name:     field.string(),
});
```

Once an entity has a `tenantId` (or `tenant_id`) field:

* **Inserts** auto-fill `tenantId = auth.tenantId` when the caller doesn't provide it, and **reject** a write whose `tenantId` doesn't match the caller's — you can't insert into another tenant.
* **Reads / updates / deletes** are scoped to the caller's tenant.
* **Admin contexts** bypass scoping.

This makes tenant isolation a default posture rather than a `where` clause you have to remember on every query. In your own `query`/`mutation` handlers you still have `auth.tenantId` available to scope further.

## `owner_stamp`

Per-row ownership. **Automatic** — the signal is `field.X().owner()` on the field.

```ts theme={null}
export const Listing = entity("Listing", {
  sellerId: field.string().owner(),   // ← stamped from the session on insert
  title:    field.string(),
});
```

On insert the runtime overwrites `sellerId` with `auth.userId` from the session and **rejects** any client attempt to set it to a different user. This is what makes optimistic, local-first writes safe for owned data: the client inserts with its own id for an instant local paint, and the server validates the owner from the session. Pair it with a policy like `allowUpdate: "data.sellerId == auth.userId"`.

## Schema behaviors

`behaviors([...])` on the entity builder run field-injection helpers before the schema is registered. Two ship today:

```ts theme={null}
import { e, field, timestamps, softDelete } from "@pylonsync/sdk";

export const Post = e.entity("Post", {
  title: field.string(),
  body:  field.string(),
}).behaviors([timestamps, softDelete]);
```

| Behavior     | Adds                                                              |
| ------------ | ----------------------------------------------------------------- |
| `timestamps` | `createdAt` + `updatedAt` datetime fields (marked `defaultNow()`) |
| `softDelete` | a `deletedAt` datetime field                                      |

Behaviors mutate the `EntityDefinition`'s fields, so the rest of the framework (storage, sync, policies) treats the injected columns as ordinary columns.

<Note>
  Behaviors add the **columns**. Automatic value-stamping is driven by the `defaultNow()` marker and is still being wired end-to-end — until it lands, set the values in your function handler (or on the client) and the columns will persist normally. `softDelete` adds the `deletedAt` column; the DELETE-sets-`deletedAt` and list-filtering semantics are app-driven for now (filter `deletedAt == null` in your query).
</Note>

## Field-level constraints & defaults

The real "validation" surface is on the field builder — it's recorded in the manifest and enforced by the runtime + codegen:

```ts theme={null}
export const User = entity("User", {
  email:     field.string().unique(),
  role:      field.enum(["member", "admin"]),   // constrained set
  bio:       field.string().optional(),
  createdAt: field.datetime().defaultNow(),
  ownerId:   field.string().owner(),
});
```

| Helper                          | Effect                                                     |
| ------------------------------- | ---------------------------------------------------------- |
| `.unique()`                     | Unique index; duplicate inserts rejected                   |
| `.optional()`                   | Field may be omitted / null                                |
| `field.enum([...])`             | Value must be one of the listed strings                    |
| `.default(v)` / `.defaultNow()` | Insert-time default value                                  |
| `.owner()`                      | Auth-stamped ownership (see [`owner_stamp`](#owner_stamp)) |

For richer validation (length limits, regex, cross-field rules), do the check in a `mutation` handler before writing, and gate access with [policies](/concepts/policies).

## What is *not* a plugin

These were previously documented as data plugins; they don't exist as configurable plugins. Here's where the real capability lives:

| Was documented as | Reality                                                                                                          |
| ----------------- | ---------------------------------------------------------------------------------------------------------------- |
| `validation`      | Field constraints (above) + checks in your `mutation` handler                                                    |
| `slugify`         | Compute the slug in your handler / a `field.string().default(...)`                                               |
| `computed`        | Derive the value in your `mutation` handler before writing                                                       |
| `cascade`         | Delete children explicitly in a `mutation` (runs in one transaction)                                             |
| `versioning`      | Write snapshot rows from an `after`-write step in a function                                                     |
| `organizations`   | Real framework feature — `Org`/`OrgMember` + `/api/auth/orgs/*`. See [Auth → Organizations](/auth/organizations) |

## Full-text search

Search is configured per-entity (not as a data plugin) — see [Search & AI](/plugins/search-ai#search).
