diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 48f3fdc9c4..4b4ffa9232 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -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 @@ -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 | |:---------|:----| @@ -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, { diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index 7ea17981ee..8e1fa560eb 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -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 diff --git a/skills/objectstack-data/rules/security.md b/skills/objectstack-data/rules/security.md index bdee23925a..62b7752c4a 100644 --- a/skills/objectstack-data/rules/security.md +++ b/skills/objectstack-data/rules/security.md @@ -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 default — omit 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 == ` 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 == `, 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