Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions skills/objectstack-data/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -625,14 +625,17 @@ tenant-authored metadata.

## Seed Data & Fixtures (`defineSeed()`)

Object definition and its seed data live together — writing a `*.object.ts`
almost always goes with a `*.seed.ts` (test fixtures, reference rows,
bootstrap data). `defineSeed()` is type-safe: pass the object definition
and TypeScript checks every record's field keys at compile time.
Object definition and seed data live together — a `*.object.ts` usually
pairs with a `*.seed.ts` (fixtures, reference rows, bootstrap data).
`defineSeed()` is type-safe: TypeScript checks every record's field keys
against the object definition.

> The factory is named `defineSeed` — **not** `defineDataset`. The `dataset`
> name is reserved for the unrelated ADR-0021 analytics semantic layer
> (`defineDataset` from `@objectstack/spec/ui`), which is not a seed factory.
> The factory is `defineSeed` — **not** `defineDataset`, which is the
> unrelated ADR-0021 analytics semantic layer (`@objectstack/spec/ui`).

> ⛔ `sys_organization` is platform-bootstrapped — never a seed target; the
> deployment posture decides how many exist (`rules/security.md`
> § Multi-tenancy).

### Quick start

Expand Down Expand Up @@ -689,8 +692,8 @@ Full Zod shape: `node_modules/@objectstack/spec/src/data/seed.zod.ts`.

### `externalId` selection

Pick a stable natural business key. **Never use `id`** — UUIDs differ
across environments.
Pick a stable natural key. **Never use `id`** — UUIDs differ across
environments.

| Scenario | Key |
|:---------|:----|
Expand All @@ -705,11 +708,11 @@ For `lookup` fields, supply the **natural key** of the target record (not
its UUID). The seed runner resolves at load time. Order seeds so parents
appear before children in the exported array:

> If a lookup value matches no natural key, the loader now falls back to
> resolving it as the target's `id` — so a reference to a real existing
> record by internal id resolves instead of dangling to null. Natural keys
> remain the portable default; rely on the id fallback only for records you
> didn't seed (e.g. a system user).
> If a lookup value matches no natural key, the loader falls back to
> resolving it as the target's `id` — so a reference to an existing record
> by internal id resolves instead of dangling to null. Natural keys remain
> the portable default; rely on the id fallback only for records you didn't
> seed (e.g. a system user).

```typescript
const contacts = defineSeed(Contact, {
Expand Down
8 changes: 5 additions & 3 deletions skills/objectstack-data/references/data-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -620,14 +620,16 @@ when a hook must work regardless of whether a user resolved.
> **Two isolation axes — don't conflate them.** `organization_id` is
> **org row-scoping**: many organizations share one database and every row
> carries its owning org (`current_user.organizationId` filters reads/writes;
> multi-org needs cloud + `@objectstack/organizations`). That is different from
> it needs a walled posture — `OS_TENANCY_POSTURE=group|isolated` — with
> `@objectstack/organizations` declared by the app, open core since
> ADR-0132). That is different from
> **environment / database-per-tenant** isolation (`service-tenant`,
> `driver-turso`), where "tenant" means an entire environment/database and the
> generic driver-layer `tenantId` knob can carry that environment id. The
> object-metadata `tenancy.*` knob configures the *mechanism* (isolation on/off
> + which column); the *value* you read and write is your `organization_id`
> column. Community edition never populates an org, so `organizationId` is
> `undefined` there.
> column. Under `single` the bootstrapped Default Organization is the only
> one, so there is nothing to scope by (`rules/security.md` § Multi-tenancy).

### `input` — Operation Parameters

Expand Down
39 changes: 20 additions & 19 deletions skills/objectstack-data/rules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,33 +152,34 @@ fields: {

## Multi-tenancy

For SaaS, set `tenancy` on the object schema for row-level tenant isolation
(the tenant field is injected on write and enforced on read). The block is
**strict** — exactly two keys:
Organization count is a **deployment posture** fact, never object metadata.
`single` (default) = one logical tenant: the bootstrapped Default Organization
only, a second refused (`403`); sub-units are business units. Tenants as
`sys_organization` rows need a walled posture (`group`/`isolated`; open core,
ADR-0132) — ⛔ never `single` + your own RLS. See ADR-0093, ADR-0105 §"Today's
two postures", https://objectstack.ai/docs/deployment/tenancy-modes.

Within a posture, `tenancy` on the object is the row-level knob (stamped on
write, enforced on read); **strict**, two keys:

```typescript
tenancy: {
enabled: true, // enable row-level tenant isolation
// tenantField — NO default; omit it and the driver uses `organization_id`
enabled: true,
// tenantField: no defaultomit it `organization_id`
}
```

- **Database-per-tenant isolation is not object metadata** — it is an
environment/deployment choice (each environment carries its own database URL).

## Platform-global / admin-only objects (visibility posture)

Some system/config objects are **env-global** (not partitioned per org) and
should be visible to a **platform admin env-wide** but hidden from members —
e.g. identity tables a plugin writes via its own adapter (`sys_sso_provider`,
OAuth clients). These hit a non-obvious interaction:

- Reads of a tenant object pass the **Layer 0 tenant wall** (ADR-0095 D1): an
`organization_id == <the caller's organization>` filter AND-composed ahead of
every business RLS policy. Any row whose `organization_id` is **null or
absent** (common for adapter-written rows that never get the tenant stamp) is
**denied** — the list renders empty. Single-tenant deployments never hit this;
the wall is inert there.
Some system objects are **env-global** — **platform admin** sees all, members
none — e.g. identity tables a plugin writes via its own adapter
(`sys_sso_provider`, OAuth clients):

- Reads of a tenant object pass the **Layer 0 tenant wall** (ADR-0095 D1):
`organization_id == <caller's organization>`, AND-composed ahead of all
business RLS. A row with **null or absent** `organization_id`
(adapter-written rows often lack it) is **denied** — the list renders empty.
Under `single` the wall is inert.
- The `viewAllRecords` superuser bit is **posture-gated and wall-blind**: it
short-circuits **business RLS only**, and only on objects whose posture allows
it (`access.default: 'private'`, `tenancy: { enabled: false }`, or a
Expand Down
Loading