Merged
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
10 changes: 9 additions & 1 deletion scripts/check-skills-token-ratchet.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,7 +313,11 @@ export const CEILINGS = new Map([
// a transition gate written as a `validations[]` invariant bricks rows that
// were legal when stored, and an invariant written as `requiredWhen` never
// enforces itself at all. +110 tokens, 1 absorbed by headroom, ceiling +109.
['skills/objectstack-data/SKILL.md', 13892],
// 13892 -> 10009: RE-LOCK at the landed count after the `rules/security.md`
// split (#14296 item 1 = A, condition (b)). The raise recorded above is spent
// and its headroom leaves with it; the moved text is priced in its own row
// below, so the package total is unchanged by the re-lock itself.
['skills/objectstack-data/SKILL.md', 10009],
['skills/objectstack-formula/SKILL.md', 6002], // -53 (was 6055)
['skills/objectstack-i18n/SKILL.md', 6338], // -11 (was 6349)
// 12705 -> 12984 (2026-08-31 app-repo-principles raise, see the block above).
Expand DownExpand Up@@ -408,6 +412,10 @@ export const CEILINGS = new Map([
['skills/objectstack-data/rules/lifecycle.md', 1590],
['skills/objectstack-data/rules/naming.md', 773],
['skills/objectstack-data/rules/relationships.md', 3778],
// NEW FILE (#14296 item 1 = A, condition (b)): the entry's Security & Access
// Control block moved here whole. Pinned at its landed count — no headroom,
// because a split that arrives with budget is a raise wearing a new path.
['skills/objectstack-data/rules/security.md', 2480],
// 3024 -> 3109 (2026-08-31 app-repo-principles raise, see the block above).
// Severity Levels listed the three values and left the CHOICE unstated: a
// block rests on a judgement a person made, so a machine-inferred signal — a
Expand Down
2 changes: 1 addition & 1 deletion scripts/role-word-baseline.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,7 +35,7 @@
"content/docs/ui/forms.mdx": 3,
"skills/objectstack-ai/SKILL.md": 1,
"skills/objectstack-automation/SKILL.md": 1,
"skills/objectstack-data/SKILL.md": 2,
"skills/objectstack-data/SKILL.md": 1,
"skills/objectstack-data/rules/relationships.md": 1,
"skills/objectstack-platform/SKILL.md": 2,
"skills/objectstack-query/rules/filters.md": 8,
Expand Down
250 changes: 17 additions & 233 deletions skills/objectstack-data/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,22 @@ metadata:

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects
- **[Security & Access Control](./rules/security.md)** — permission sets, assignment rows, RLS policies, `secret` / `requiredPermissions`, `tenancy`, platform-global posture

---

## Core Concepts

### Object Definition
Expand DownExpand Up@@ -280,21 +296,6 @@ export const Invoice = ObjectSchema.create({

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects

---

## Quick-Start Template

<!-- os:check -->
Expand DownExpand Up@@ -544,67 +545,7 @@ Per-object access control is authored in **permission sets**, not on the object
schema. There is no object-level `permissions` key (and no `hooks` key either) —
`ObjectSchema.create()` **rejects** both as unknown keys.

### Object-level permissions (RBAC)

Grant CRUD access per object with boolean bits on a permission set:

<!-- os:check -->
```typescript
import { definePermissionSet } from '@objectstack/spec';

export const salesUser = definePermissionSet({
name: 'sales_user',
objects: {
account: { allowRead: true, allowCreate: true, allowEdit: true },
contact: { allowRead: true },
},
});

// Register it on the stack root under `permissions` — NOT `permissionSets`:
// defineStack({ permissions: [salesUser], ... })
```

- **Stack key: `permissions`.** The collection is named for the metadata kind,
not for the factory, so `definePermissionSet()` output goes into
`defineStack({ permissions: [...] })`. `permissionSets:` is **refused at
load** — the top level is strict, so the stack fails with an
`Unrecognized key(s) on this stack definition` error naming the key, never a
silent drop. `ObjectStackDefinitionSchema`
(`node_modules/@objectstack/spec/src/stack.zod.ts`) is the enumeration of
record; `objectstack-platform` lists every top-level key.
- Bits: `allowCreate` / `allowRead` / `allowEdit` / `allowDelete`, plus
`allowTransfer` (ownership change), `viewAllRecords` / `modifyAllRecords`
(super-user, bypass sharing).
- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts`
- Combine with `enable.apiMethods` to also restrict the HTTP surface.

### Assigning a permission set to a user

Declaring a set grants nobody anything — an assignment is **data**: one row in
the join object **`sys_user_permission_set`** (`@objectstack/plugin-security`),
carrying `user_id`, `permission_set_id`, and an optional `organization_id`
(`null` = every org context). Optional `valid_from` / `valid_until` bound a
half-open window checked at resolution time; `granted_by` is stamped by the
gate on insert — never author it.

⚠️ **`permission_set_id` takes the `sys_permission_set` RECORD ID, not the set's
`name`.** Grants resolve by loading `sys_permission_set` **by `id`**, so a `name`
in that field matches nothing, raises no error, and silently grants nothing.
Declared sets are upserted by `name` with a **generated** `id` on `kernel:ready`
(ADR-0086 D5) — that id differs per environment, so resolve it first.

Assignment is therefore two calls, both `POST /api/v1/data/{object}`
(`…/query` with a QueryAST body for the read): look up the set's `id` in
`sys_permission_set` by `name`, then insert
`{ user_id, permission_set_id, organization_id }` into
`sys_user_permission_set`. Only a tenant admin — or a delegated `adminScope`
carrying `manageAssignments` for that set and user (ADR-0090 D12) — may write
it; plain CRUD bits on the table are not enough.

**Grant looks inert?** Check in order: a `name` in `permission_set_id`; the set
is `active: false`; the validity window has passed; `organization_id` mismatch.
`GET /api/v1/security/explain?object=&operation=&userId=` answers from the
enforcing code path (explaining another user needs `manage_users`).
Full rules: **[Security & Access Control](./rules/security.md)**.

### Access depth (scope-depth) — the ERP "see my unit / my unit and below" axis

Expand All@@ -618,163 +559,6 @@ paid `@objectstack/security-enterprise` plugin, **fail closed to `own`** without
it, and `defineStack` errors unless the grant declares
`requires: ['hierarchy-security']`. Schema: `PermissionSetSchema.objects.*`.

### Row-Level Security (RLS)

The **enforced** RLS surface is a list of `rowLevelSecurity` policies on a
**permission set / profile** (`PermissionSetSchema.rowLevelSecurity`), *not* a
CEL predicate on the object. Each policy carries a `using` (read filter) and/or
`check` (write filter) **string** predicate. The compiler ANDs `using` into
every read for users carrying that set; `check` gates writes. (`@objectstack/plugin-security`
re-reads the target row through the write filter before single-id `update`/`delete`.)

```typescript
// in a permission set (definePermissionSet)
rowLevelSecurity: [
{
name: 'own_records',
object: 'account', // REQUIRED per policy
operation: 'all', // singular: select|insert|update|delete|all
using: 'owner_id == current_user.id', // read scope
check: 'owner_id == current_user.id', // write scope
},
{
name: 'org_isolation',
object: 'contact',
operation: 'select',
using: 'organization_id == current_user.organization_id',
},
]
```

Predicates are **canonical CEL** (ADR-0058): `field == current_user.<prop>`,
`field == 'literal'`, `field in current_user.<array>`, comparisons (`>`/`<`/`>=`/`<=`),
`&&`/`||`/`!`, and `== null` checks all lower to a pushdown filter. **No** cross-object
traversal or subqueries — those are a compile error (ADR-0055), never silently dropped.
A legacy SQL-style `=` / `IN (...)` predicate still compiles via a **deprecated** bridge
(emits a warning) but should be authored in CEL. The compiler resolves these
`current_user.*` placeholders:

| Placeholder | Resolves to |
|:--|:--|
| `current_user.id` | the caller's user id (ownership) |
| `current_user.email` | the caller's email (ADR-0056) |
| `current_user.organization_id` | the caller's tenant |
| `current_user.org_user_ids` | ids of users in the same org (for `IN`) |
| `current_user.positions` | the caller's positions (for `IN`; ADR-0090 D3) |

- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts` (policy shape),
`node_modules/@objectstack/spec/src/security/rls.zod.ts` (predicate grammar).
- Owner-scoping shortcut: the built-in `member_default` set already owner-scopes
writes via `owner_only_writes` / `owner_only_deletes`, and an object's
`sharingModel` (`private` / `public_read` / `public_read_write` / `controlled_by_parent`, ADR-0056 D1)
is the declarative way to set the org-wide default — prefer those over
hand-written policies for the common cases.

### Sensitive fields — `secret` type + `requiredPermissions`

`maskingRule` is **live** (plugin-security's FieldMasker enforces it). The real
channels are:

**Encrypted-at-rest values — `type: 'secret'` (ADR-0100).** For reversible
machine credentials (DB passwords, API keys, tokens): the engine encrypts the
value on write via the registered `ICryptoProvider`, stores the ciphertext
handle in `sys_secret`, persists only an opaque ref on the row, and masks the
value on read. **Fail-closed:** with no crypto provider registered, writes
throw rather than persist cleartext.

```typescript
fields: {
api_key: { type: 'secret', label: 'API Key' },
}
```

**Per-field access gating — `requiredPermissions` (ADR-0066 D3).** Capabilities
required to READ/EDIT the field. A field declaring `requiredPermissions` is
**masked on read and denied on write** unless the caller holds ALL listed
capabilities — an AND-gate that is strictest-wins over permission-set field
grants. Enforced by plugin-security's FieldMasker.

```typescript
fields: {
ssn: {
type: 'text',
requiredPermissions: ['view_pii'], // mask on read / deny on write without it
},
}
```

- Source: `node_modules/@objectstack/spec/src/data/field.zod.ts`
(`secret` field type, `requiredPermissions`)

### 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:

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

- **Database-per-tenant isolation is not object metadata** — it is an
environment/deployment choice (each environment carries its own database URL).
- Platform/env-global objects declare `tenancy: { enabled: false }` to opt out
of org row-scoping (see the visibility-posture recipe below).

### 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.
- 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
better-auth-managed identity table). It never crosses the Layer 0 wall —
crossing takes a *true platform admin* (the superuser bit **and** a
platform-exclusive capability: `manage_metadata`, `manage_platform_settings`,
`studio.access`, `manage_users`) on one of those same postures. So an org
admin holding the superuser bit stays org-scoped, and on an ordinary tenant
object nobody crosses — the admin sees 0 rows too.

**Recipe — env-global, admin-only object that admins can fully see:**

```typescript
tenancy: { enabled: false }, // not a tenant object → Layer 0 contributes nothing
requiredPermissions: ['manage_platform_settings'], // capability AND-gate → members get 403
```

> ⚠️ **Both keys are load-bearing — neither works alone.**
> `tenancy: { enabled: false }` *by itself* switches the wall off for **every**
> caller, and any permission set carrying a wildcard (`'*'`) read grant then
> reads every row env-wide — the shipped `viewer_readonly` still carries one, as
> may an app-declared default profile or a customer-authored set. (The
> `member_default` baseline is **not** one of them: it is explicit-allow and
> grants only the objects it names.) `requiredPermissions` *by itself* leaves the
> object a tenant object, so the wall keeps denying the untagged rows and even a
> platform admin sees nothing. The pair is the correct combo (admin sees all,
> non-admins 403), and `requiredPermissions` is the half that holds however
> permissive the caller's grants are — it is an AND-gate checked **before** the
> CRUD grant. Posture model: ADR-0066; tenant wall: ADR-0095 D1.

### Cross-skill notes

- **API auth providers** (OIDC, JWT, API key) live in **objectstack-api**.
- **Kernel-level RBAC services** (role inheritance, custom policy engines)
live in **objectstack-platform**.
- **CEL predicate syntax** (`P\`...\``, operators, functions) lives in
**objectstack-formula**.

---

## Metadata Protection (`protection`)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
10 changes: 9 additions & 1 deletion scripts/check-skills-token-ratchet.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,7 +313,11 @@ export const CEILINGS = new Map([
// a transition gate written as a `validations[]` invariant bricks rows that
// were legal when stored, and an invariant written as `requiredWhen` never
// enforces itself at all. +110 tokens, 1 absorbed by headroom, ceiling +109.
['skills/objectstack-data/SKILL.md', 13892],
// 13892 -> 10009: RE-LOCK at the landed count after the `rules/security.md`
// split (#14296 item 1 = A, condition (b)). The raise recorded above is spent
// and its headroom leaves with it; the moved text is priced in its own row
// below, so the package total is unchanged by the re-lock itself.
['skills/objectstack-data/SKILL.md', 10009],
['skills/objectstack-formula/SKILL.md', 6002], // -53 (was 6055)
['skills/objectstack-i18n/SKILL.md', 6338], // -11 (was 6349)
// 12705 -> 12984 (2026-08-31 app-repo-principles raise, see the block above).
Expand DownExpand Up@@ -408,6 +412,10 @@ export const CEILINGS = new Map([
['skills/objectstack-data/rules/lifecycle.md', 1590],
['skills/objectstack-data/rules/naming.md', 773],
['skills/objectstack-data/rules/relationships.md', 3778],
// NEW FILE (#14296 item 1 = A, condition (b)): the entry's Security & Access
// Control block moved here whole. Pinned at its landed count — no headroom,
// because a split that arrives with budget is a raise wearing a new path.
['skills/objectstack-data/rules/security.md', 2480],
// 3024 -> 3109 (2026-08-31 app-repo-principles raise, see the block above).
// Severity Levels listed the three values and left the CHOICE unstated: a
// block rests on a judgement a person made, so a machine-inferred signal — a
Expand Down
2 changes: 1 addition & 1 deletion scripts/role-word-baseline.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,7 +35,7 @@
"content/docs/ui/forms.mdx": 3,
"skills/objectstack-ai/SKILL.md": 1,
"skills/objectstack-automation/SKILL.md": 1,
"skills/objectstack-data/SKILL.md": 2,
"skills/objectstack-data/SKILL.md": 1,
"skills/objectstack-data/rules/relationships.md": 1,
"skills/objectstack-platform/SKILL.md": 2,
"skills/objectstack-query/rules/filters.md": 8,
Expand Down
250 changes: 17 additions & 233 deletions skills/objectstack-data/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,22 @@ metadata:

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects
- **[Security & Access Control](./rules/security.md)** — permission sets, assignment rows, RLS policies, `secret` / `requiredPermissions`, `tenancy`, platform-global posture

---

## Core Concepts

### Object Definition
Expand DownExpand Up@@ -280,21 +296,6 @@ export const Invoice = ObjectSchema.create({

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects

---

## Quick-Start Template

<!-- os:check -->
Expand DownExpand Up@@ -544,67 +545,7 @@ Per-object access control is authored in **permission sets**, not on the object
schema. There is no object-level `permissions` key (and no `hooks` key either) —
`ObjectSchema.create()` **rejects** both as unknown keys.

### Object-level permissions (RBAC)

Grant CRUD access per object with boolean bits on a permission set:

<!-- os:check -->
```typescript
import { definePermissionSet } from '@objectstack/spec';

export const salesUser = definePermissionSet({
name: 'sales_user',
objects: {
account: { allowRead: true, allowCreate: true, allowEdit: true },
contact: { allowRead: true },
},
});

// Register it on the stack root under `permissions` — NOT `permissionSets`:
// defineStack({ permissions: [salesUser], ... })
```

- **Stack key: `permissions`.** The collection is named for the metadata kind,
not for the factory, so `definePermissionSet()` output goes into
`defineStack({ permissions: [...] })`. `permissionSets:` is **refused at
load** — the top level is strict, so the stack fails with an
`Unrecognized key(s) on this stack definition` error naming the key, never a
silent drop. `ObjectStackDefinitionSchema`
(`node_modules/@objectstack/spec/src/stack.zod.ts`) is the enumeration of
record; `objectstack-platform` lists every top-level key.
- Bits: `allowCreate` / `allowRead` / `allowEdit` / `allowDelete`, plus
`allowTransfer` (ownership change), `viewAllRecords` / `modifyAllRecords`
(super-user, bypass sharing).
- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts`
- Combine with `enable.apiMethods` to also restrict the HTTP surface.

### Assigning a permission set to a user

Declaring a set grants nobody anything — an assignment is **data**: one row in
the join object **`sys_user_permission_set`** (`@objectstack/plugin-security`),
carrying `user_id`, `permission_set_id`, and an optional `organization_id`
(`null` = every org context). Optional `valid_from` / `valid_until` bound a
half-open window checked at resolution time; `granted_by` is stamped by the
gate on insert — never author it.

⚠️ **`permission_set_id` takes the `sys_permission_set` RECORD ID, not the set's
`name`.** Grants resolve by loading `sys_permission_set` **by `id`**, so a `name`
in that field matches nothing, raises no error, and silently grants nothing.
Declared sets are upserted by `name` with a **generated** `id` on `kernel:ready`
(ADR-0086 D5) — that id differs per environment, so resolve it first.

Assignment is therefore two calls, both `POST /api/v1/data/{object}`
(`…/query` with a QueryAST body for the read): look up the set's `id` in
`sys_permission_set` by `name`, then insert
`{ user_id, permission_set_id, organization_id }` into
`sys_user_permission_set`. Only a tenant admin — or a delegated `adminScope`
carrying `manageAssignments` for that set and user (ADR-0090 D12) — may write
it; plain CRUD bits on the table are not enough.

**Grant looks inert?** Check in order: a `name` in `permission_set_id`; the set
is `active: false`; the validity window has passed; `organization_id` mismatch.
`GET /api/v1/security/explain?object=&operation=&userId=` answers from the
enforcing code path (explaining another user needs `manage_users`).
Full rules: **[Security & Access Control](./rules/security.md)**.

### Access depth (scope-depth) — the ERP "see my unit / my unit and below" axis

Expand All@@ -618,163 +559,6 @@ paid `@objectstack/security-enterprise` plugin, **fail closed to `own`** without
it, and `defineStack` errors unless the grant declares
`requires: ['hierarchy-security']`. Schema: `PermissionSetSchema.objects.*`.

### Row-Level Security (RLS)

The **enforced** RLS surface is a list of `rowLevelSecurity` policies on a
**permission set / profile** (`PermissionSetSchema.rowLevelSecurity`), *not* a
CEL predicate on the object. Each policy carries a `using` (read filter) and/or
`check` (write filter) **string** predicate. The compiler ANDs `using` into
every read for users carrying that set; `check` gates writes. (`@objectstack/plugin-security`
re-reads the target row through the write filter before single-id `update`/`delete`.)

```typescript
// in a permission set (definePermissionSet)
rowLevelSecurity: [
{
name: 'own_records',
object: 'account', // REQUIRED per policy
operation: 'all', // singular: select|insert|update|delete|all
using: 'owner_id == current_user.id', // read scope
check: 'owner_id == current_user.id', // write scope
},
{
name: 'org_isolation',
object: 'contact',
operation: 'select',
using: 'organization_id == current_user.organization_id',
},
]
```

Predicates are **canonical CEL** (ADR-0058): `field == current_user.<prop>`,
`field == 'literal'`, `field in current_user.<array>`, comparisons (`>`/`<`/`>=`/`<=`),
`&&`/`||`/`!`, and `== null` checks all lower to a pushdown filter. **No** cross-object
traversal or subqueries — those are a compile error (ADR-0055), never silently dropped.
A legacy SQL-style `=` / `IN (...)` predicate still compiles via a **deprecated** bridge
(emits a warning) but should be authored in CEL. The compiler resolves these
`current_user.*` placeholders:

| Placeholder | Resolves to |
|:--|:--|
| `current_user.id` | the caller's user id (ownership) |
| `current_user.email` | the caller's email (ADR-0056) |
| `current_user.organization_id` | the caller's tenant |
| `current_user.org_user_ids` | ids of users in the same org (for `IN`) |
| `current_user.positions` | the caller's positions (for `IN`; ADR-0090 D3) |

- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts` (policy shape),
`node_modules/@objectstack/spec/src/security/rls.zod.ts` (predicate grammar).
- Owner-scoping shortcut: the built-in `member_default` set already owner-scopes
writes via `owner_only_writes` / `owner_only_deletes`, and an object's
`sharingModel` (`private` / `public_read` / `public_read_write` / `controlled_by_parent`, ADR-0056 D1)
is the declarative way to set the org-wide default — prefer those over
hand-written policies for the common cases.

### Sensitive fields — `secret` type + `requiredPermissions`

`maskingRule` is **live** (plugin-security's FieldMasker enforces it). The real
channels are:

**Encrypted-at-rest values — `type: 'secret'` (ADR-0100).** For reversible
machine credentials (DB passwords, API keys, tokens): the engine encrypts the
value on write via the registered `ICryptoProvider`, stores the ciphertext
handle in `sys_secret`, persists only an opaque ref on the row, and masks the
value on read. **Fail-closed:** with no crypto provider registered, writes
throw rather than persist cleartext.

```typescript
fields: {
api_key: { type: 'secret', label: 'API Key' },
}
```

**Per-field access gating — `requiredPermissions` (ADR-0066 D3).** Capabilities
required to READ/EDIT the field. A field declaring `requiredPermissions` is
**masked on read and denied on write** unless the caller holds ALL listed
capabilities — an AND-gate that is strictest-wins over permission-set field
grants. Enforced by plugin-security's FieldMasker.

```typescript
fields: {
ssn: {
type: 'text',
requiredPermissions: ['view_pii'], // mask on read / deny on write without it
},
}
```

- Source: `node_modules/@objectstack/spec/src/data/field.zod.ts`
(`secret` field type, `requiredPermissions`)

### 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:

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

- **Database-per-tenant isolation is not object metadata** — it is an
environment/deployment choice (each environment carries its own database URL).
- Platform/env-global objects declare `tenancy: { enabled: false }` to opt out
of org row-scoping (see the visibility-posture recipe below).

### 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.
- 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
better-auth-managed identity table). It never crosses the Layer 0 wall —
crossing takes a *true platform admin* (the superuser bit **and** a
platform-exclusive capability: `manage_metadata`, `manage_platform_settings`,
`studio.access`, `manage_users`) on one of those same postures. So an org
admin holding the superuser bit stays org-scoped, and on an ordinary tenant
object nobody crosses — the admin sees 0 rows too.

**Recipe — env-global, admin-only object that admins can fully see:**

```typescript
tenancy: { enabled: false }, // not a tenant object → Layer 0 contributes nothing
requiredPermissions: ['manage_platform_settings'], // capability AND-gate → members get 403
```

> ⚠️ **Both keys are load-bearing — neither works alone.**
> `tenancy: { enabled: false }` *by itself* switches the wall off for **every**
> caller, and any permission set carrying a wildcard (`'*'`) read grant then
> reads every row env-wide — the shipped `viewer_readonly` still carries one, as
> may an app-declared default profile or a customer-authored set. (The
> `member_default` baseline is **not** one of them: it is explicit-allow and
> grants only the objects it names.) `requiredPermissions` *by itself* leaves the
> object a tenant object, so the wall keeps denying the untagged rows and even a
> platform admin sees nothing. The pair is the correct combo (admin sees all,
> non-admins 403), and `requiredPermissions` is the half that holds however
> permissive the caller's grants are — it is an AND-gate checked **before** the
> CRUD grant. Posture model: ADR-0066; tenant wall: ADR-0095 D1.

### Cross-skill notes

- **API auth providers** (OIDC, JWT, API key) live in **objectstack-api**.
- **Kernel-level RBAC services** (role inheritance, custom policy engines)
live in **objectstack-platform**.
- **CEL predicate syntax** (`P\`...\``, operators, functions) lives in
**objectstack-formula**.

---

## Metadata Protection (`protection`)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
10 changes: 9 additions & 1 deletion scripts/check-skills-token-ratchet.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,7 +313,11 @@ export const CEILINGS = new Map([
// a transition gate written as a `validations[]` invariant bricks rows that
// were legal when stored, and an invariant written as `requiredWhen` never
// enforces itself at all. +110 tokens, 1 absorbed by headroom, ceiling +109.
['skills/objectstack-data/SKILL.md', 13892],
// 13892 -> 10009: RE-LOCK at the landed count after the `rules/security.md`
// split (#14296 item 1 = A, condition (b)). The raise recorded above is spent
// and its headroom leaves with it; the moved text is priced in its own row
// below, so the package total is unchanged by the re-lock itself.
['skills/objectstack-data/SKILL.md', 10009],
['skills/objectstack-formula/SKILL.md', 6002], // -53 (was 6055)
['skills/objectstack-i18n/SKILL.md', 6338], // -11 (was 6349)
// 12705 -> 12984 (2026-08-31 app-repo-principles raise, see the block above).
Expand DownExpand Up@@ -408,6 +412,10 @@ export const CEILINGS = new Map([
['skills/objectstack-data/rules/lifecycle.md', 1590],
['skills/objectstack-data/rules/naming.md', 773],
['skills/objectstack-data/rules/relationships.md', 3778],
// NEW FILE (#14296 item 1 = A, condition (b)): the entry's Security & Access
// Control block moved here whole. Pinned at its landed count — no headroom,
// because a split that arrives with budget is a raise wearing a new path.
['skills/objectstack-data/rules/security.md', 2480],
// 3024 -> 3109 (2026-08-31 app-repo-principles raise, see the block above).
// Severity Levels listed the three values and left the CHOICE unstated: a
// block rests on a judgement a person made, so a machine-inferred signal — a
Expand Down
2 changes: 1 addition & 1 deletion scripts/role-word-baseline.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,7 +35,7 @@
"content/docs/ui/forms.mdx": 3,
"skills/objectstack-ai/SKILL.md": 1,
"skills/objectstack-automation/SKILL.md": 1,
"skills/objectstack-data/SKILL.md": 2,
"skills/objectstack-data/SKILL.md": 1,
"skills/objectstack-data/rules/relationships.md": 1,
"skills/objectstack-platform/SKILL.md": 2,
"skills/objectstack-query/rules/filters.md": 8,
Expand Down
250 changes: 17 additions & 233 deletions skills/objectstack-data/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,22 @@ metadata:

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects
- **[Security & Access Control](./rules/security.md)** — permission sets, assignment rows, RLS policies, `secret` / `requiredPermissions`, `tenancy`, platform-global posture

---

## Core Concepts

### Object Definition
Expand DownExpand Up@@ -280,21 +296,6 @@ export const Invoice = ObjectSchema.create({

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects

---

## Quick-Start Template

<!-- os:check -->
Expand DownExpand Up@@ -544,67 +545,7 @@ Per-object access control is authored in **permission sets**, not on the object
schema. There is no object-level `permissions` key (and no `hooks` key either) —
`ObjectSchema.create()` **rejects** both as unknown keys.

### Object-level permissions (RBAC)

Grant CRUD access per object with boolean bits on a permission set:

<!-- os:check -->
```typescript
import { definePermissionSet } from '@objectstack/spec';

export const salesUser = definePermissionSet({
name: 'sales_user',
objects: {
account: { allowRead: true, allowCreate: true, allowEdit: true },
contact: { allowRead: true },
},
});

// Register it on the stack root under `permissions` — NOT `permissionSets`:
// defineStack({ permissions: [salesUser], ... })
```

- **Stack key: `permissions`.** The collection is named for the metadata kind,
not for the factory, so `definePermissionSet()` output goes into
`defineStack({ permissions: [...] })`. `permissionSets:` is **refused at
load** — the top level is strict, so the stack fails with an
`Unrecognized key(s) on this stack definition` error naming the key, never a
silent drop. `ObjectStackDefinitionSchema`
(`node_modules/@objectstack/spec/src/stack.zod.ts`) is the enumeration of
record; `objectstack-platform` lists every top-level key.
- Bits: `allowCreate` / `allowRead` / `allowEdit` / `allowDelete`, plus
`allowTransfer` (ownership change), `viewAllRecords` / `modifyAllRecords`
(super-user, bypass sharing).
- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts`
- Combine with `enable.apiMethods` to also restrict the HTTP surface.

### Assigning a permission set to a user

Declaring a set grants nobody anything — an assignment is **data**: one row in
the join object **`sys_user_permission_set`** (`@objectstack/plugin-security`),
carrying `user_id`, `permission_set_id`, and an optional `organization_id`
(`null` = every org context). Optional `valid_from` / `valid_until` bound a
half-open window checked at resolution time; `granted_by` is stamped by the
gate on insert — never author it.

⚠️ **`permission_set_id` takes the `sys_permission_set` RECORD ID, not the set's
`name`.** Grants resolve by loading `sys_permission_set` **by `id`**, so a `name`
in that field matches nothing, raises no error, and silently grants nothing.
Declared sets are upserted by `name` with a **generated** `id` on `kernel:ready`
(ADR-0086 D5) — that id differs per environment, so resolve it first.

Assignment is therefore two calls, both `POST /api/v1/data/{object}`
(`…/query` with a QueryAST body for the read): look up the set's `id` in
`sys_permission_set` by `name`, then insert
`{ user_id, permission_set_id, organization_id }` into
`sys_user_permission_set`. Only a tenant admin — or a delegated `adminScope`
carrying `manageAssignments` for that set and user (ADR-0090 D12) — may write
it; plain CRUD bits on the table are not enough.

**Grant looks inert?** Check in order: a `name` in `permission_set_id`; the set
is `active: false`; the validity window has passed; `organization_id` mismatch.
`GET /api/v1/security/explain?object=&operation=&userId=` answers from the
enforcing code path (explaining another user needs `manage_users`).
Full rules: **[Security & Access Control](./rules/security.md)**.

### Access depth (scope-depth) — the ERP "see my unit / my unit and below" axis

Expand All@@ -618,163 +559,6 @@ paid `@objectstack/security-enterprise` plugin, **fail closed to `own`** without
it, and `defineStack` errors unless the grant declares
`requires: ['hierarchy-security']`. Schema: `PermissionSetSchema.objects.*`.

### Row-Level Security (RLS)

The **enforced** RLS surface is a list of `rowLevelSecurity` policies on a
**permission set / profile** (`PermissionSetSchema.rowLevelSecurity`), *not* a
CEL predicate on the object. Each policy carries a `using` (read filter) and/or
`check` (write filter) **string** predicate. The compiler ANDs `using` into
every read for users carrying that set; `check` gates writes. (`@objectstack/plugin-security`
re-reads the target row through the write filter before single-id `update`/`delete`.)

```typescript
// in a permission set (definePermissionSet)
rowLevelSecurity: [
{
name: 'own_records',
object: 'account', // REQUIRED per policy
operation: 'all', // singular: select|insert|update|delete|all
using: 'owner_id == current_user.id', // read scope
check: 'owner_id == current_user.id', // write scope
},
{
name: 'org_isolation',
object: 'contact',
operation: 'select',
using: 'organization_id == current_user.organization_id',
},
]
```

Predicates are **canonical CEL** (ADR-0058): `field == current_user.<prop>`,
`field == 'literal'`, `field in current_user.<array>`, comparisons (`>`/`<`/`>=`/`<=`),
`&&`/`||`/`!`, and `== null` checks all lower to a pushdown filter. **No** cross-object
traversal or subqueries — those are a compile error (ADR-0055), never silently dropped.
A legacy SQL-style `=` / `IN (...)` predicate still compiles via a **deprecated** bridge
(emits a warning) but should be authored in CEL. The compiler resolves these
`current_user.*` placeholders:

| Placeholder | Resolves to |
|:--|:--|
| `current_user.id` | the caller's user id (ownership) |
| `current_user.email` | the caller's email (ADR-0056) |
| `current_user.organization_id` | the caller's tenant |
| `current_user.org_user_ids` | ids of users in the same org (for `IN`) |
| `current_user.positions` | the caller's positions (for `IN`; ADR-0090 D3) |

- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts` (policy shape),
`node_modules/@objectstack/spec/src/security/rls.zod.ts` (predicate grammar).
- Owner-scoping shortcut: the built-in `member_default` set already owner-scopes
writes via `owner_only_writes` / `owner_only_deletes`, and an object's
`sharingModel` (`private` / `public_read` / `public_read_write` / `controlled_by_parent`, ADR-0056 D1)
is the declarative way to set the org-wide default — prefer those over
hand-written policies for the common cases.

### Sensitive fields — `secret` type + `requiredPermissions`

`maskingRule` is **live** (plugin-security's FieldMasker enforces it). The real
channels are:

**Encrypted-at-rest values — `type: 'secret'` (ADR-0100).** For reversible
machine credentials (DB passwords, API keys, tokens): the engine encrypts the
value on write via the registered `ICryptoProvider`, stores the ciphertext
handle in `sys_secret`, persists only an opaque ref on the row, and masks the
value on read. **Fail-closed:** with no crypto provider registered, writes
throw rather than persist cleartext.

```typescript
fields: {
api_key: { type: 'secret', label: 'API Key' },
}
```

**Per-field access gating — `requiredPermissions` (ADR-0066 D3).** Capabilities
required to READ/EDIT the field. A field declaring `requiredPermissions` is
**masked on read and denied on write** unless the caller holds ALL listed
capabilities — an AND-gate that is strictest-wins over permission-set field
grants. Enforced by plugin-security's FieldMasker.

```typescript
fields: {
ssn: {
type: 'text',
requiredPermissions: ['view_pii'], // mask on read / deny on write without it
},
}
```

- Source: `node_modules/@objectstack/spec/src/data/field.zod.ts`
(`secret` field type, `requiredPermissions`)

### 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:

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

- **Database-per-tenant isolation is not object metadata** — it is an
environment/deployment choice (each environment carries its own database URL).
- Platform/env-global objects declare `tenancy: { enabled: false }` to opt out
of org row-scoping (see the visibility-posture recipe below).

### 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.
- 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
better-auth-managed identity table). It never crosses the Layer 0 wall —
crossing takes a *true platform admin* (the superuser bit **and** a
platform-exclusive capability: `manage_metadata`, `manage_platform_settings`,
`studio.access`, `manage_users`) on one of those same postures. So an org
admin holding the superuser bit stays org-scoped, and on an ordinary tenant
object nobody crosses — the admin sees 0 rows too.

**Recipe — env-global, admin-only object that admins can fully see:**

```typescript
tenancy: { enabled: false }, // not a tenant object → Layer 0 contributes nothing
requiredPermissions: ['manage_platform_settings'], // capability AND-gate → members get 403
```

> ⚠️ **Both keys are load-bearing — neither works alone.**
> `tenancy: { enabled: false }` *by itself* switches the wall off for **every**
> caller, and any permission set carrying a wildcard (`'*'`) read grant then
> reads every row env-wide — the shipped `viewer_readonly` still carries one, as
> may an app-declared default profile or a customer-authored set. (The
> `member_default` baseline is **not** one of them: it is explicit-allow and
> grants only the objects it names.) `requiredPermissions` *by itself* leaves the
> object a tenant object, so the wall keeps denying the untagged rows and even a
> platform admin sees nothing. The pair is the correct combo (admin sees all,
> non-admins 403), and `requiredPermissions` is the half that holds however
> permissive the caller's grants are — it is an AND-gate checked **before** the
> CRUD grant. Posture model: ADR-0066; tenant wall: ADR-0095 D1.

### Cross-skill notes

- **API auth providers** (OIDC, JWT, API key) live in **objectstack-api**.
- **Kernel-level RBAC services** (role inheritance, custom policy engines)
live in **objectstack-platform**.
- **CEL predicate syntax** (`P\`...\``, operators, functions) lives in
**objectstack-formula**.

---

## Metadata Protection (`protection`)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
10 changes: 9 additions & 1 deletion scripts/check-skills-token-ratchet.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,7 +313,11 @@ export const CEILINGS = new Map([
// a transition gate written as a `validations[]` invariant bricks rows that
// were legal when stored, and an invariant written as `requiredWhen` never
// enforces itself at all. +110 tokens, 1 absorbed by headroom, ceiling +109.
['skills/objectstack-data/SKILL.md', 13892],
// 13892 -> 10009: RE-LOCK at the landed count after the `rules/security.md`
// split (#14296 item 1 = A, condition (b)). The raise recorded above is spent
// and its headroom leaves with it; the moved text is priced in its own row
// below, so the package total is unchanged by the re-lock itself.
['skills/objectstack-data/SKILL.md', 10009],
['skills/objectstack-formula/SKILL.md', 6002], // -53 (was 6055)
['skills/objectstack-i18n/SKILL.md', 6338], // -11 (was 6349)
// 12705 -> 12984 (2026-08-31 app-repo-principles raise, see the block above).
Expand DownExpand Up@@ -408,6 +412,10 @@ export const CEILINGS = new Map([
['skills/objectstack-data/rules/lifecycle.md', 1590],
['skills/objectstack-data/rules/naming.md', 773],
['skills/objectstack-data/rules/relationships.md', 3778],
// NEW FILE (#14296 item 1 = A, condition (b)): the entry's Security & Access
// Control block moved here whole. Pinned at its landed count — no headroom,
// because a split that arrives with budget is a raise wearing a new path.
['skills/objectstack-data/rules/security.md', 2480],
// 3024 -> 3109 (2026-08-31 app-repo-principles raise, see the block above).
// Severity Levels listed the three values and left the CHOICE unstated: a
// block rests on a judgement a person made, so a machine-inferred signal — a
Expand Down
2 changes: 1 addition & 1 deletion scripts/role-word-baseline.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,7 +35,7 @@
"content/docs/ui/forms.mdx": 3,
"skills/objectstack-ai/SKILL.md": 1,
"skills/objectstack-automation/SKILL.md": 1,
"skills/objectstack-data/SKILL.md": 2,
"skills/objectstack-data/SKILL.md": 1,
"skills/objectstack-data/rules/relationships.md": 1,
"skills/objectstack-platform/SKILL.md": 2,
"skills/objectstack-query/rules/filters.md": 8,
Expand Down
250 changes: 17 additions & 233 deletions skills/objectstack-data/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,22 @@ metadata:

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects
- **[Security & Access Control](./rules/security.md)** — permission sets, assignment rows, RLS policies, `secret` / `requiredPermissions`, `tenancy`, platform-global posture

---

## Core Concepts

### Object Definition
Expand DownExpand Up@@ -280,21 +296,6 @@ export const Invoice = ObjectSchema.create({

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects

---

## Quick-Start Template

<!-- os:check -->
Expand DownExpand Up@@ -544,67 +545,7 @@ Per-object access control is authored in **permission sets**, not on the object
schema. There is no object-level `permissions` key (and no `hooks` key either) —
`ObjectSchema.create()` **rejects** both as unknown keys.

### Object-level permissions (RBAC)

Grant CRUD access per object with boolean bits on a permission set:

<!-- os:check -->
```typescript
import { definePermissionSet } from '@objectstack/spec';

export const salesUser = definePermissionSet({
name: 'sales_user',
objects: {
account: { allowRead: true, allowCreate: true, allowEdit: true },
contact: { allowRead: true },
},
});

// Register it on the stack root under `permissions` — NOT `permissionSets`:
// defineStack({ permissions: [salesUser], ... })
```

- **Stack key: `permissions`.** The collection is named for the metadata kind,
not for the factory, so `definePermissionSet()` output goes into
`defineStack({ permissions: [...] })`. `permissionSets:` is **refused at
load** — the top level is strict, so the stack fails with an
`Unrecognized key(s) on this stack definition` error naming the key, never a
silent drop. `ObjectStackDefinitionSchema`
(`node_modules/@objectstack/spec/src/stack.zod.ts`) is the enumeration of
record; `objectstack-platform` lists every top-level key.
- Bits: `allowCreate` / `allowRead` / `allowEdit` / `allowDelete`, plus
`allowTransfer` (ownership change), `viewAllRecords` / `modifyAllRecords`
(super-user, bypass sharing).
- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts`
- Combine with `enable.apiMethods` to also restrict the HTTP surface.

### Assigning a permission set to a user

Declaring a set grants nobody anything — an assignment is **data**: one row in
the join object **`sys_user_permission_set`** (`@objectstack/plugin-security`),
carrying `user_id`, `permission_set_id`, and an optional `organization_id`
(`null` = every org context). Optional `valid_from` / `valid_until` bound a
half-open window checked at resolution time; `granted_by` is stamped by the
gate on insert — never author it.

⚠️ **`permission_set_id` takes the `sys_permission_set` RECORD ID, not the set's
`name`.** Grants resolve by loading `sys_permission_set` **by `id`**, so a `name`
in that field matches nothing, raises no error, and silently grants nothing.
Declared sets are upserted by `name` with a **generated** `id` on `kernel:ready`
(ADR-0086 D5) — that id differs per environment, so resolve it first.

Assignment is therefore two calls, both `POST /api/v1/data/{object}`
(`…/query` with a QueryAST body for the read): look up the set's `id` in
`sys_permission_set` by `name`, then insert
`{ user_id, permission_set_id, organization_id }` into
`sys_user_permission_set`. Only a tenant admin — or a delegated `adminScope`
carrying `manageAssignments` for that set and user (ADR-0090 D12) — may write
it; plain CRUD bits on the table are not enough.

**Grant looks inert?** Check in order: a `name` in `permission_set_id`; the set
is `active: false`; the validity window has passed; `organization_id` mismatch.
`GET /api/v1/security/explain?object=&operation=&userId=` answers from the
enforcing code path (explaining another user needs `manage_users`).
Full rules: **[Security & Access Control](./rules/security.md)**.

### Access depth (scope-depth) — the ERP "see my unit / my unit and below" axis

Expand All@@ -618,163 +559,6 @@ paid `@objectstack/security-enterprise` plugin, **fail closed to `own`** without
it, and `defineStack` errors unless the grant declares
`requires: ['hierarchy-security']`. Schema: `PermissionSetSchema.objects.*`.

### Row-Level Security (RLS)

The **enforced** RLS surface is a list of `rowLevelSecurity` policies on a
**permission set / profile** (`PermissionSetSchema.rowLevelSecurity`), *not* a
CEL predicate on the object. Each policy carries a `using` (read filter) and/or
`check` (write filter) **string** predicate. The compiler ANDs `using` into
every read for users carrying that set; `check` gates writes. (`@objectstack/plugin-security`
re-reads the target row through the write filter before single-id `update`/`delete`.)

```typescript
// in a permission set (definePermissionSet)
rowLevelSecurity: [
{
name: 'own_records',
object: 'account', // REQUIRED per policy
operation: 'all', // singular: select|insert|update|delete|all
using: 'owner_id == current_user.id', // read scope
check: 'owner_id == current_user.id', // write scope
},
{
name: 'org_isolation',
object: 'contact',
operation: 'select',
using: 'organization_id == current_user.organization_id',
},
]
```

Predicates are **canonical CEL** (ADR-0058): `field == current_user.<prop>`,
`field == 'literal'`, `field in current_user.<array>`, comparisons (`>`/`<`/`>=`/`<=`),
`&&`/`||`/`!`, and `== null` checks all lower to a pushdown filter. **No** cross-object
traversal or subqueries — those are a compile error (ADR-0055), never silently dropped.
A legacy SQL-style `=` / `IN (...)` predicate still compiles via a **deprecated** bridge
(emits a warning) but should be authored in CEL. The compiler resolves these
`current_user.*` placeholders:

| Placeholder | Resolves to |
|:--|:--|
| `current_user.id` | the caller's user id (ownership) |
| `current_user.email` | the caller's email (ADR-0056) |
| `current_user.organization_id` | the caller's tenant |
| `current_user.org_user_ids` | ids of users in the same org (for `IN`) |
| `current_user.positions` | the caller's positions (for `IN`; ADR-0090 D3) |

- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts` (policy shape),
`node_modules/@objectstack/spec/src/security/rls.zod.ts` (predicate grammar).
- Owner-scoping shortcut: the built-in `member_default` set already owner-scopes
writes via `owner_only_writes` / `owner_only_deletes`, and an object's
`sharingModel` (`private` / `public_read` / `public_read_write` / `controlled_by_parent`, ADR-0056 D1)
is the declarative way to set the org-wide default — prefer those over
hand-written policies for the common cases.

### Sensitive fields — `secret` type + `requiredPermissions`

`maskingRule` is **live** (plugin-security's FieldMasker enforces it). The real
channels are:

**Encrypted-at-rest values — `type: 'secret'` (ADR-0100).** For reversible
machine credentials (DB passwords, API keys, tokens): the engine encrypts the
value on write via the registered `ICryptoProvider`, stores the ciphertext
handle in `sys_secret`, persists only an opaque ref on the row, and masks the
value on read. **Fail-closed:** with no crypto provider registered, writes
throw rather than persist cleartext.

```typescript
fields: {
api_key: { type: 'secret', label: 'API Key' },
}
```

**Per-field access gating — `requiredPermissions` (ADR-0066 D3).** Capabilities
required to READ/EDIT the field. A field declaring `requiredPermissions` is
**masked on read and denied on write** unless the caller holds ALL listed
capabilities — an AND-gate that is strictest-wins over permission-set field
grants. Enforced by plugin-security's FieldMasker.

```typescript
fields: {
ssn: {
type: 'text',
requiredPermissions: ['view_pii'], // mask on read / deny on write without it
},
}
```

- Source: `node_modules/@objectstack/spec/src/data/field.zod.ts`
(`secret` field type, `requiredPermissions`)

### 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:

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

- **Database-per-tenant isolation is not object metadata** — it is an
environment/deployment choice (each environment carries its own database URL).
- Platform/env-global objects declare `tenancy: { enabled: false }` to opt out
of org row-scoping (see the visibility-posture recipe below).

### 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.
- 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
better-auth-managed identity table). It never crosses the Layer 0 wall —
crossing takes a *true platform admin* (the superuser bit **and** a
platform-exclusive capability: `manage_metadata`, `manage_platform_settings`,
`studio.access`, `manage_users`) on one of those same postures. So an org
admin holding the superuser bit stays org-scoped, and on an ordinary tenant
object nobody crosses — the admin sees 0 rows too.

**Recipe — env-global, admin-only object that admins can fully see:**

```typescript
tenancy: { enabled: false }, // not a tenant object → Layer 0 contributes nothing
requiredPermissions: ['manage_platform_settings'], // capability AND-gate → members get 403
```

> ⚠️ **Both keys are load-bearing — neither works alone.**
> `tenancy: { enabled: false }` *by itself* switches the wall off for **every**
> caller, and any permission set carrying a wildcard (`'*'`) read grant then
> reads every row env-wide — the shipped `viewer_readonly` still carries one, as
> may an app-declared default profile or a customer-authored set. (The
> `member_default` baseline is **not** one of them: it is explicit-allow and
> grants only the objects it names.) `requiredPermissions` *by itself* leaves the
> object a tenant object, so the wall keeps denying the untagged rows and even a
> platform admin sees nothing. The pair is the correct combo (admin sees all,
> non-admins 403), and `requiredPermissions` is the half that holds however
> permissive the caller's grants are — it is an AND-gate checked **before** the
> CRUD grant. Posture model: ADR-0066; tenant wall: ADR-0095 D1.

### Cross-skill notes

- **API auth providers** (OIDC, JWT, API key) live in **objectstack-api**.
- **Kernel-level RBAC services** (role inheritance, custom policy engines)
live in **objectstack-platform**.
- **CEL predicate syntax** (`P\`...\``, operators, functions) lives in
**objectstack-formula**.

---

## Metadata Protection (`protection`)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
10 changes: 9 additions & 1 deletion scripts/check-skills-token-ratchet.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,7 +313,11 @@ export const CEILINGS = new Map([
// a transition gate written as a `validations[]` invariant bricks rows that
// were legal when stored, and an invariant written as `requiredWhen` never
// enforces itself at all. +110 tokens, 1 absorbed by headroom, ceiling +109.
['skills/objectstack-data/SKILL.md', 13892],
// 13892 -> 10009: RE-LOCK at the landed count after the `rules/security.md`
// split (#14296 item 1 = A, condition (b)). The raise recorded above is spent
// and its headroom leaves with it; the moved text is priced in its own row
// below, so the package total is unchanged by the re-lock itself.
['skills/objectstack-data/SKILL.md', 10009],
['skills/objectstack-formula/SKILL.md', 6002], // -53 (was 6055)
['skills/objectstack-i18n/SKILL.md', 6338], // -11 (was 6349)
// 12705 -> 12984 (2026-08-31 app-repo-principles raise, see the block above).
Expand DownExpand Up@@ -408,6 +412,10 @@ export const CEILINGS = new Map([
['skills/objectstack-data/rules/lifecycle.md', 1590],
['skills/objectstack-data/rules/naming.md', 773],
['skills/objectstack-data/rules/relationships.md', 3778],
// NEW FILE (#14296 item 1 = A, condition (b)): the entry's Security & Access
// Control block moved here whole. Pinned at its landed count — no headroom,
// because a split that arrives with budget is a raise wearing a new path.
['skills/objectstack-data/rules/security.md', 2480],
// 3024 -> 3109 (2026-08-31 app-repo-principles raise, see the block above).
// Severity Levels listed the three values and left the CHOICE unstated: a
// block rests on a judgement a person made, so a machine-inferred signal — a
Expand Down
2 changes: 1 addition & 1 deletion scripts/role-word-baseline.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,7 +35,7 @@
"content/docs/ui/forms.mdx": 3,
"skills/objectstack-ai/SKILL.md": 1,
"skills/objectstack-automation/SKILL.md": 1,
"skills/objectstack-data/SKILL.md": 2,
"skills/objectstack-data/SKILL.md": 1,
"skills/objectstack-data/rules/relationships.md": 1,
"skills/objectstack-platform/SKILL.md": 2,
"skills/objectstack-query/rules/filters.md": 8,
Expand Down
250 changes: 17 additions & 233 deletions skills/objectstack-data/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,22 @@ metadata:

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects
- **[Security & Access Control](./rules/security.md)** — permission sets, assignment rows, RLS policies, `secret` / `requiredPermissions`, `tenancy`, platform-global posture

---

## Core Concepts

### Object Definition
Expand DownExpand Up@@ -280,21 +296,6 @@ export const Invoice = ObjectSchema.create({

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects

---

## Quick-Start Template

<!-- os:check -->
Expand DownExpand Up@@ -544,67 +545,7 @@ Per-object access control is authored in **permission sets**, not on the object
schema. There is no object-level `permissions` key (and no `hooks` key either) —
`ObjectSchema.create()` **rejects** both as unknown keys.

### Object-level permissions (RBAC)

Grant CRUD access per object with boolean bits on a permission set:

<!-- os:check -->
```typescript
import { definePermissionSet } from '@objectstack/spec';

export const salesUser = definePermissionSet({
name: 'sales_user',
objects: {
account: { allowRead: true, allowCreate: true, allowEdit: true },
contact: { allowRead: true },
},
});

// Register it on the stack root under `permissions` — NOT `permissionSets`:
// defineStack({ permissions: [salesUser], ... })
```

- **Stack key: `permissions`.** The collection is named for the metadata kind,
not for the factory, so `definePermissionSet()` output goes into
`defineStack({ permissions: [...] })`. `permissionSets:` is **refused at
load** — the top level is strict, so the stack fails with an
`Unrecognized key(s) on this stack definition` error naming the key, never a
silent drop. `ObjectStackDefinitionSchema`
(`node_modules/@objectstack/spec/src/stack.zod.ts`) is the enumeration of
record; `objectstack-platform` lists every top-level key.
- Bits: `allowCreate` / `allowRead` / `allowEdit` / `allowDelete`, plus
`allowTransfer` (ownership change), `viewAllRecords` / `modifyAllRecords`
(super-user, bypass sharing).
- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts`
- Combine with `enable.apiMethods` to also restrict the HTTP surface.

### Assigning a permission set to a user

Declaring a set grants nobody anything — an assignment is **data**: one row in
the join object **`sys_user_permission_set`** (`@objectstack/plugin-security`),
carrying `user_id`, `permission_set_id`, and an optional `organization_id`
(`null` = every org context). Optional `valid_from` / `valid_until` bound a
half-open window checked at resolution time; `granted_by` is stamped by the
gate on insert — never author it.

⚠️ **`permission_set_id` takes the `sys_permission_set` RECORD ID, not the set's
`name`.** Grants resolve by loading `sys_permission_set` **by `id`**, so a `name`
in that field matches nothing, raises no error, and silently grants nothing.
Declared sets are upserted by `name` with a **generated** `id` on `kernel:ready`
(ADR-0086 D5) — that id differs per environment, so resolve it first.

Assignment is therefore two calls, both `POST /api/v1/data/{object}`
(`…/query` with a QueryAST body for the read): look up the set's `id` in
`sys_permission_set` by `name`, then insert
`{ user_id, permission_set_id, organization_id }` into
`sys_user_permission_set`. Only a tenant admin — or a delegated `adminScope`
carrying `manageAssignments` for that set and user (ADR-0090 D12) — may write
it; plain CRUD bits on the table are not enough.

**Grant looks inert?** Check in order: a `name` in `permission_set_id`; the set
is `active: false`; the validity window has passed; `organization_id` mismatch.
`GET /api/v1/security/explain?object=&operation=&userId=` answers from the
enforcing code path (explaining another user needs `manage_users`).
Full rules: **[Security & Access Control](./rules/security.md)**.

### Access depth (scope-depth) — the ERP "see my unit / my unit and below" axis

Expand All@@ -618,163 +559,6 @@ paid `@objectstack/security-enterprise` plugin, **fail closed to `own`** without
it, and `defineStack` errors unless the grant declares
`requires: ['hierarchy-security']`. Schema: `PermissionSetSchema.objects.*`.

### Row-Level Security (RLS)

The **enforced** RLS surface is a list of `rowLevelSecurity` policies on a
**permission set / profile** (`PermissionSetSchema.rowLevelSecurity`), *not* a
CEL predicate on the object. Each policy carries a `using` (read filter) and/or
`check` (write filter) **string** predicate. The compiler ANDs `using` into
every read for users carrying that set; `check` gates writes. (`@objectstack/plugin-security`
re-reads the target row through the write filter before single-id `update`/`delete`.)

```typescript
// in a permission set (definePermissionSet)
rowLevelSecurity: [
{
name: 'own_records',
object: 'account', // REQUIRED per policy
operation: 'all', // singular: select|insert|update|delete|all
using: 'owner_id == current_user.id', // read scope
check: 'owner_id == current_user.id', // write scope
},
{
name: 'org_isolation',
object: 'contact',
operation: 'select',
using: 'organization_id == current_user.organization_id',
},
]
```

Predicates are **canonical CEL** (ADR-0058): `field == current_user.<prop>`,
`field == 'literal'`, `field in current_user.<array>`, comparisons (`>`/`<`/`>=`/`<=`),
`&&`/`||`/`!`, and `== null` checks all lower to a pushdown filter. **No** cross-object
traversal or subqueries — those are a compile error (ADR-0055), never silently dropped.
A legacy SQL-style `=` / `IN (...)` predicate still compiles via a **deprecated** bridge
(emits a warning) but should be authored in CEL. The compiler resolves these
`current_user.*` placeholders:

| Placeholder | Resolves to |
|:--|:--|
| `current_user.id` | the caller's user id (ownership) |
| `current_user.email` | the caller's email (ADR-0056) |
| `current_user.organization_id` | the caller's tenant |
| `current_user.org_user_ids` | ids of users in the same org (for `IN`) |
| `current_user.positions` | the caller's positions (for `IN`; ADR-0090 D3) |

- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts` (policy shape),
`node_modules/@objectstack/spec/src/security/rls.zod.ts` (predicate grammar).
- Owner-scoping shortcut: the built-in `member_default` set already owner-scopes
writes via `owner_only_writes` / `owner_only_deletes`, and an object's
`sharingModel` (`private` / `public_read` / `public_read_write` / `controlled_by_parent`, ADR-0056 D1)
is the declarative way to set the org-wide default — prefer those over
hand-written policies for the common cases.

### Sensitive fields — `secret` type + `requiredPermissions`

`maskingRule` is **live** (plugin-security's FieldMasker enforces it). The real
channels are:

**Encrypted-at-rest values — `type: 'secret'` (ADR-0100).** For reversible
machine credentials (DB passwords, API keys, tokens): the engine encrypts the
value on write via the registered `ICryptoProvider`, stores the ciphertext
handle in `sys_secret`, persists only an opaque ref on the row, and masks the
value on read. **Fail-closed:** with no crypto provider registered, writes
throw rather than persist cleartext.

```typescript
fields: {
api_key: { type: 'secret', label: 'API Key' },
}
```

**Per-field access gating — `requiredPermissions` (ADR-0066 D3).** Capabilities
required to READ/EDIT the field. A field declaring `requiredPermissions` is
**masked on read and denied on write** unless the caller holds ALL listed
capabilities — an AND-gate that is strictest-wins over permission-set field
grants. Enforced by plugin-security's FieldMasker.

```typescript
fields: {
ssn: {
type: 'text',
requiredPermissions: ['view_pii'], // mask on read / deny on write without it
},
}
```

- Source: `node_modules/@objectstack/spec/src/data/field.zod.ts`
(`secret` field type, `requiredPermissions`)

### 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:

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

- **Database-per-tenant isolation is not object metadata** — it is an
environment/deployment choice (each environment carries its own database URL).
- Platform/env-global objects declare `tenancy: { enabled: false }` to opt out
of org row-scoping (see the visibility-posture recipe below).

### 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.
- 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
better-auth-managed identity table). It never crosses the Layer 0 wall —
crossing takes a *true platform admin* (the superuser bit **and** a
platform-exclusive capability: `manage_metadata`, `manage_platform_settings`,
`studio.access`, `manage_users`) on one of those same postures. So an org
admin holding the superuser bit stays org-scoped, and on an ordinary tenant
object nobody crosses — the admin sees 0 rows too.

**Recipe — env-global, admin-only object that admins can fully see:**

```typescript
tenancy: { enabled: false }, // not a tenant object → Layer 0 contributes nothing
requiredPermissions: ['manage_platform_settings'], // capability AND-gate → members get 403
```

> ⚠️ **Both keys are load-bearing — neither works alone.**
> `tenancy: { enabled: false }` *by itself* switches the wall off for **every**
> caller, and any permission set carrying a wildcard (`'*'`) read grant then
> reads every row env-wide — the shipped `viewer_readonly` still carries one, as
> may an app-declared default profile or a customer-authored set. (The
> `member_default` baseline is **not** one of them: it is explicit-allow and
> grants only the objects it names.) `requiredPermissions` *by itself* leaves the
> object a tenant object, so the wall keeps denying the untagged rows and even a
> platform admin sees nothing. The pair is the correct combo (admin sees all,
> non-admins 403), and `requiredPermissions` is the half that holds however
> permissive the caller's grants are — it is an AND-gate checked **before** the
> CRUD grant. Posture model: ADR-0066; tenant wall: ADR-0095 D1.

### Cross-skill notes

- **API auth providers** (OIDC, JWT, API key) live in **objectstack-api**.
- **Kernel-level RBAC services** (role inheritance, custom policy engines)
live in **objectstack-platform**.
- **CEL predicate syntax** (`P\`...\``, operators, functions) lives in
**objectstack-formula**.

---

## Metadata Protection (`protection`)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
10 changes: 9 additions & 1 deletion scripts/check-skills-token-ratchet.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,7 +313,11 @@ export const CEILINGS = new Map([
// a transition gate written as a `validations[]` invariant bricks rows that
// were legal when stored, and an invariant written as `requiredWhen` never
// enforces itself at all. +110 tokens, 1 absorbed by headroom, ceiling +109.
['skills/objectstack-data/SKILL.md', 13892],
// 13892 -> 10009: RE-LOCK at the landed count after the `rules/security.md`
// split (#14296 item 1 = A, condition (b)). The raise recorded above is spent
// and its headroom leaves with it; the moved text is priced in its own row
// below, so the package total is unchanged by the re-lock itself.
['skills/objectstack-data/SKILL.md', 10009],
['skills/objectstack-formula/SKILL.md', 6002], // -53 (was 6055)
['skills/objectstack-i18n/SKILL.md', 6338], // -11 (was 6349)
// 12705 -> 12984 (2026-08-31 app-repo-principles raise, see the block above).
Expand DownExpand Up@@ -408,6 +412,10 @@ export const CEILINGS = new Map([
['skills/objectstack-data/rules/lifecycle.md', 1590],
['skills/objectstack-data/rules/naming.md', 773],
['skills/objectstack-data/rules/relationships.md', 3778],
// NEW FILE (#14296 item 1 = A, condition (b)): the entry's Security & Access
// Control block moved here whole. Pinned at its landed count — no headroom,
// because a split that arrives with budget is a raise wearing a new path.
['skills/objectstack-data/rules/security.md', 2480],
// 3024 -> 3109 (2026-08-31 app-repo-principles raise, see the block above).
// Severity Levels listed the three values and left the CHOICE unstated: a
// block rests on a judgement a person made, so a machine-inferred signal — a
Expand Down
2 changes: 1 addition & 1 deletion scripts/role-word-baseline.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,7 +35,7 @@
"content/docs/ui/forms.mdx": 3,
"skills/objectstack-ai/SKILL.md": 1,
"skills/objectstack-automation/SKILL.md": 1,
"skills/objectstack-data/SKILL.md": 2,
"skills/objectstack-data/SKILL.md": 1,
"skills/objectstack-data/rules/relationships.md": 1,
"skills/objectstack-platform/SKILL.md": 2,
"skills/objectstack-query/rules/filters.md": 8,
Expand Down
250 changes: 17 additions & 233 deletions skills/objectstack-data/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,22 @@ metadata:

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects
- **[Security & Access Control](./rules/security.md)** — permission sets, assignment rows, RLS policies, `secret` / `requiredPermissions`, `tenancy`, platform-global posture

---

## Core Concepts

### Object Definition
Expand DownExpand Up@@ -280,21 +296,6 @@ export const Invoice = ObjectSchema.create({

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects

---

## Quick-Start Template

<!-- os:check -->
Expand DownExpand Up@@ -544,67 +545,7 @@ Per-object access control is authored in **permission sets**, not on the object
schema. There is no object-level `permissions` key (and no `hooks` key either) —
`ObjectSchema.create()` **rejects** both as unknown keys.

### Object-level permissions (RBAC)

Grant CRUD access per object with boolean bits on a permission set:

<!-- os:check -->
```typescript
import { definePermissionSet } from '@objectstack/spec';

export const salesUser = definePermissionSet({
name: 'sales_user',
objects: {
account: { allowRead: true, allowCreate: true, allowEdit: true },
contact: { allowRead: true },
},
});

// Register it on the stack root under `permissions` — NOT `permissionSets`:
// defineStack({ permissions: [salesUser], ... })
```

- **Stack key: `permissions`.** The collection is named for the metadata kind,
not for the factory, so `definePermissionSet()` output goes into
`defineStack({ permissions: [...] })`. `permissionSets:` is **refused at
load** — the top level is strict, so the stack fails with an
`Unrecognized key(s) on this stack definition` error naming the key, never a
silent drop. `ObjectStackDefinitionSchema`
(`node_modules/@objectstack/spec/src/stack.zod.ts`) is the enumeration of
record; `objectstack-platform` lists every top-level key.
- Bits: `allowCreate` / `allowRead` / `allowEdit` / `allowDelete`, plus
`allowTransfer` (ownership change), `viewAllRecords` / `modifyAllRecords`
(super-user, bypass sharing).
- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts`
- Combine with `enable.apiMethods` to also restrict the HTTP surface.

### Assigning a permission set to a user

Declaring a set grants nobody anything — an assignment is **data**: one row in
the join object **`sys_user_permission_set`** (`@objectstack/plugin-security`),
carrying `user_id`, `permission_set_id`, and an optional `organization_id`
(`null` = every org context). Optional `valid_from` / `valid_until` bound a
half-open window checked at resolution time; `granted_by` is stamped by the
gate on insert — never author it.

⚠️ **`permission_set_id` takes the `sys_permission_set` RECORD ID, not the set's
`name`.** Grants resolve by loading `sys_permission_set` **by `id`**, so a `name`
in that field matches nothing, raises no error, and silently grants nothing.
Declared sets are upserted by `name` with a **generated** `id` on `kernel:ready`
(ADR-0086 D5) — that id differs per environment, so resolve it first.

Assignment is therefore two calls, both `POST /api/v1/data/{object}`
(`…/query` with a QueryAST body for the read): look up the set's `id` in
`sys_permission_set` by `name`, then insert
`{ user_id, permission_set_id, organization_id }` into
`sys_user_permission_set`. Only a tenant admin — or a delegated `adminScope`
carrying `manageAssignments` for that set and user (ADR-0090 D12) — may write
it; plain CRUD bits on the table are not enough.

**Grant looks inert?** Check in order: a `name` in `permission_set_id`; the set
is `active: false`; the validity window has passed; `organization_id` mismatch.
`GET /api/v1/security/explain?object=&operation=&userId=` answers from the
enforcing code path (explaining another user needs `manage_users`).
Full rules: **[Security & Access Control](./rules/security.md)**.

### Access depth (scope-depth) — the ERP "see my unit / my unit and below" axis

Expand All@@ -618,163 +559,6 @@ paid `@objectstack/security-enterprise` plugin, **fail closed to `own`** without
it, and `defineStack` errors unless the grant declares
`requires: ['hierarchy-security']`. Schema: `PermissionSetSchema.objects.*`.

### Row-Level Security (RLS)

The **enforced** RLS surface is a list of `rowLevelSecurity` policies on a
**permission set / profile** (`PermissionSetSchema.rowLevelSecurity`), *not* a
CEL predicate on the object. Each policy carries a `using` (read filter) and/or
`check` (write filter) **string** predicate. The compiler ANDs `using` into
every read for users carrying that set; `check` gates writes. (`@objectstack/plugin-security`
re-reads the target row through the write filter before single-id `update`/`delete`.)

```typescript
// in a permission set (definePermissionSet)
rowLevelSecurity: [
{
name: 'own_records',
object: 'account', // REQUIRED per policy
operation: 'all', // singular: select|insert|update|delete|all
using: 'owner_id == current_user.id', // read scope
check: 'owner_id == current_user.id', // write scope
},
{
name: 'org_isolation',
object: 'contact',
operation: 'select',
using: 'organization_id == current_user.organization_id',
},
]
```

Predicates are **canonical CEL** (ADR-0058): `field == current_user.<prop>`,
`field == 'literal'`, `field in current_user.<array>`, comparisons (`>`/`<`/`>=`/`<=`),
`&&`/`||`/`!`, and `== null` checks all lower to a pushdown filter. **No** cross-object
traversal or subqueries — those are a compile error (ADR-0055), never silently dropped.
A legacy SQL-style `=` / `IN (...)` predicate still compiles via a **deprecated** bridge
(emits a warning) but should be authored in CEL. The compiler resolves these
`current_user.*` placeholders:

| Placeholder | Resolves to |
|:--|:--|
| `current_user.id` | the caller's user id (ownership) |
| `current_user.email` | the caller's email (ADR-0056) |
| `current_user.organization_id` | the caller's tenant |
| `current_user.org_user_ids` | ids of users in the same org (for `IN`) |
| `current_user.positions` | the caller's positions (for `IN`; ADR-0090 D3) |

- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts` (policy shape),
`node_modules/@objectstack/spec/src/security/rls.zod.ts` (predicate grammar).
- Owner-scoping shortcut: the built-in `member_default` set already owner-scopes
writes via `owner_only_writes` / `owner_only_deletes`, and an object's
`sharingModel` (`private` / `public_read` / `public_read_write` / `controlled_by_parent`, ADR-0056 D1)
is the declarative way to set the org-wide default — prefer those over
hand-written policies for the common cases.

### Sensitive fields — `secret` type + `requiredPermissions`

`maskingRule` is **live** (plugin-security's FieldMasker enforces it). The real
channels are:

**Encrypted-at-rest values — `type: 'secret'` (ADR-0100).** For reversible
machine credentials (DB passwords, API keys, tokens): the engine encrypts the
value on write via the registered `ICryptoProvider`, stores the ciphertext
handle in `sys_secret`, persists only an opaque ref on the row, and masks the
value on read. **Fail-closed:** with no crypto provider registered, writes
throw rather than persist cleartext.

```typescript
fields: {
api_key: { type: 'secret', label: 'API Key' },
}
```

**Per-field access gating — `requiredPermissions` (ADR-0066 D3).** Capabilities
required to READ/EDIT the field. A field declaring `requiredPermissions` is
**masked on read and denied on write** unless the caller holds ALL listed
capabilities — an AND-gate that is strictest-wins over permission-set field
grants. Enforced by plugin-security's FieldMasker.

```typescript
fields: {
ssn: {
type: 'text',
requiredPermissions: ['view_pii'], // mask on read / deny on write without it
},
}
```

- Source: `node_modules/@objectstack/spec/src/data/field.zod.ts`
(`secret` field type, `requiredPermissions`)

### 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:

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

- **Database-per-tenant isolation is not object metadata** — it is an
environment/deployment choice (each environment carries its own database URL).
- Platform/env-global objects declare `tenancy: { enabled: false }` to opt out
of org row-scoping (see the visibility-posture recipe below).

### 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.
- 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
better-auth-managed identity table). It never crosses the Layer 0 wall —
crossing takes a *true platform admin* (the superuser bit **and** a
platform-exclusive capability: `manage_metadata`, `manage_platform_settings`,
`studio.access`, `manage_users`) on one of those same postures. So an org
admin holding the superuser bit stays org-scoped, and on an ordinary tenant
object nobody crosses — the admin sees 0 rows too.

**Recipe — env-global, admin-only object that admins can fully see:**

```typescript
tenancy: { enabled: false }, // not a tenant object → Layer 0 contributes nothing
requiredPermissions: ['manage_platform_settings'], // capability AND-gate → members get 403
```

> ⚠️ **Both keys are load-bearing — neither works alone.**
> `tenancy: { enabled: false }` *by itself* switches the wall off for **every**
> caller, and any permission set carrying a wildcard (`'*'`) read grant then
> reads every row env-wide — the shipped `viewer_readonly` still carries one, as
> may an app-declared default profile or a customer-authored set. (The
> `member_default` baseline is **not** one of them: it is explicit-allow and
> grants only the objects it names.) `requiredPermissions` *by itself* leaves the
> object a tenant object, so the wall keeps denying the untagged rows and even a
> platform admin sees nothing. The pair is the correct combo (admin sees all,
> non-admins 403), and `requiredPermissions` is the half that holds however
> permissive the caller's grants are — it is an AND-gate checked **before** the
> CRUD grant. Posture model: ADR-0066; tenant wall: ADR-0095 D1.

### Cross-skill notes

- **API auth providers** (OIDC, JWT, API key) live in **objectstack-api**.
- **Kernel-level RBAC services** (role inheritance, custom policy engines)
live in **objectstack-platform**.
- **CEL predicate syntax** (`P\`...\``, operators, functions) lives in
**objectstack-formula**.

---

## Metadata Protection (`protection`)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
10 changes: 9 additions & 1 deletion scripts/check-skills-token-ratchet.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,7 +313,11 @@ export const CEILINGS = new Map([
// a transition gate written as a `validations[]` invariant bricks rows that
// were legal when stored, and an invariant written as `requiredWhen` never
// enforces itself at all. +110 tokens, 1 absorbed by headroom, ceiling +109.
['skills/objectstack-data/SKILL.md', 13892],
// 13892 -> 10009: RE-LOCK at the landed count after the `rules/security.md`
// split (#14296 item 1 = A, condition (b)). The raise recorded above is spent
// and its headroom leaves with it; the moved text is priced in its own row
// below, so the package total is unchanged by the re-lock itself.
['skills/objectstack-data/SKILL.md', 10009],
['skills/objectstack-formula/SKILL.md', 6002], // -53 (was 6055)
['skills/objectstack-i18n/SKILL.md', 6338], // -11 (was 6349)
// 12705 -> 12984 (2026-08-31 app-repo-principles raise, see the block above).
Expand DownExpand Up@@ -408,6 +412,10 @@ export const CEILINGS = new Map([
['skills/objectstack-data/rules/lifecycle.md', 1590],
['skills/objectstack-data/rules/naming.md', 773],
['skills/objectstack-data/rules/relationships.md', 3778],
// NEW FILE (#14296 item 1 = A, condition (b)): the entry's Security & Access
// Control block moved here whole. Pinned at its landed count — no headroom,
// because a split that arrives with budget is a raise wearing a new path.
['skills/objectstack-data/rules/security.md', 2480],
// 3024 -> 3109 (2026-08-31 app-repo-principles raise, see the block above).
// Severity Levels listed the three values and left the CHOICE unstated: a
// block rests on a judgement a person made, so a machine-inferred signal — a
Expand Down
2 changes: 1 addition & 1 deletion scripts/role-word-baseline.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,7 +35,7 @@
"content/docs/ui/forms.mdx": 3,
"skills/objectstack-ai/SKILL.md": 1,
"skills/objectstack-automation/SKILL.md": 1,
"skills/objectstack-data/SKILL.md": 2,
"skills/objectstack-data/SKILL.md": 1,
"skills/objectstack-data/rules/relationships.md": 1,
"skills/objectstack-platform/SKILL.md": 2,
"skills/objectstack-query/rules/filters.md": 8,
Expand Down
250 changes: 17 additions & 233 deletions skills/objectstack-data/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,22 @@ metadata:

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects
- **[Security & Access Control](./rules/security.md)** — permission sets, assignment rows, RLS policies, `secret` / `requiredPermissions`, `tenancy`, platform-global posture

---

## Core Concepts

### Object Definition
Expand DownExpand Up@@ -280,21 +296,6 @@ export const Invoice = ObjectSchema.create({

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects

---

## Quick-Start Template

<!-- os:check -->
Expand DownExpand Up@@ -544,67 +545,7 @@ Per-object access control is authored in **permission sets**, not on the object
schema. There is no object-level `permissions` key (and no `hooks` key either) —
`ObjectSchema.create()` **rejects** both as unknown keys.

### Object-level permissions (RBAC)

Grant CRUD access per object with boolean bits on a permission set:

<!-- os:check -->
```typescript
import { definePermissionSet } from '@objectstack/spec';

export const salesUser = definePermissionSet({
name: 'sales_user',
objects: {
account: { allowRead: true, allowCreate: true, allowEdit: true },
contact: { allowRead: true },
},
});

// Register it on the stack root under `permissions` — NOT `permissionSets`:
// defineStack({ permissions: [salesUser], ... })
```

- **Stack key: `permissions`.** The collection is named for the metadata kind,
not for the factory, so `definePermissionSet()` output goes into
`defineStack({ permissions: [...] })`. `permissionSets:` is **refused at
load** — the top level is strict, so the stack fails with an
`Unrecognized key(s) on this stack definition` error naming the key, never a
silent drop. `ObjectStackDefinitionSchema`
(`node_modules/@objectstack/spec/src/stack.zod.ts`) is the enumeration of
record; `objectstack-platform` lists every top-level key.
- Bits: `allowCreate` / `allowRead` / `allowEdit` / `allowDelete`, plus
`allowTransfer` (ownership change), `viewAllRecords` / `modifyAllRecords`
(super-user, bypass sharing).
- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts`
- Combine with `enable.apiMethods` to also restrict the HTTP surface.

### Assigning a permission set to a user

Declaring a set grants nobody anything — an assignment is **data**: one row in
the join object **`sys_user_permission_set`** (`@objectstack/plugin-security`),
carrying `user_id`, `permission_set_id`, and an optional `organization_id`
(`null` = every org context). Optional `valid_from` / `valid_until` bound a
half-open window checked at resolution time; `granted_by` is stamped by the
gate on insert — never author it.

⚠️ **`permission_set_id` takes the `sys_permission_set` RECORD ID, not the set's
`name`.** Grants resolve by loading `sys_permission_set` **by `id`**, so a `name`
in that field matches nothing, raises no error, and silently grants nothing.
Declared sets are upserted by `name` with a **generated** `id` on `kernel:ready`
(ADR-0086 D5) — that id differs per environment, so resolve it first.

Assignment is therefore two calls, both `POST /api/v1/data/{object}`
(`…/query` with a QueryAST body for the read): look up the set's `id` in
`sys_permission_set` by `name`, then insert
`{ user_id, permission_set_id, organization_id }` into
`sys_user_permission_set`. Only a tenant admin — or a delegated `adminScope`
carrying `manageAssignments` for that set and user (ADR-0090 D12) — may write
it; plain CRUD bits on the table are not enough.

**Grant looks inert?** Check in order: a `name` in `permission_set_id`; the set
is `active: false`; the validity window has passed; `organization_id` mismatch.
`GET /api/v1/security/explain?object=&operation=&userId=` answers from the
enforcing code path (explaining another user needs `manage_users`).
Full rules: **[Security & Access Control](./rules/security.md)**.

### Access depth (scope-depth) — the ERP "see my unit / my unit and below" axis

Expand All@@ -618,163 +559,6 @@ paid `@objectstack/security-enterprise` plugin, **fail closed to `own`** without
it, and `defineStack` errors unless the grant declares
`requires: ['hierarchy-security']`. Schema: `PermissionSetSchema.objects.*`.

### Row-Level Security (RLS)

The **enforced** RLS surface is a list of `rowLevelSecurity` policies on a
**permission set / profile** (`PermissionSetSchema.rowLevelSecurity`), *not* a
CEL predicate on the object. Each policy carries a `using` (read filter) and/or
`check` (write filter) **string** predicate. The compiler ANDs `using` into
every read for users carrying that set; `check` gates writes. (`@objectstack/plugin-security`
re-reads the target row through the write filter before single-id `update`/`delete`.)

```typescript
// in a permission set (definePermissionSet)
rowLevelSecurity: [
{
name: 'own_records',
object: 'account', // REQUIRED per policy
operation: 'all', // singular: select|insert|update|delete|all
using: 'owner_id == current_user.id', // read scope
check: 'owner_id == current_user.id', // write scope
},
{
name: 'org_isolation',
object: 'contact',
operation: 'select',
using: 'organization_id == current_user.organization_id',
},
]
```

Predicates are **canonical CEL** (ADR-0058): `field == current_user.<prop>`,
`field == 'literal'`, `field in current_user.<array>`, comparisons (`>`/`<`/`>=`/`<=`),
`&&`/`||`/`!`, and `== null` checks all lower to a pushdown filter. **No** cross-object
traversal or subqueries — those are a compile error (ADR-0055), never silently dropped.
A legacy SQL-style `=` / `IN (...)` predicate still compiles via a **deprecated** bridge
(emits a warning) but should be authored in CEL. The compiler resolves these
`current_user.*` placeholders:

| Placeholder | Resolves to |
|:--|:--|
| `current_user.id` | the caller's user id (ownership) |
| `current_user.email` | the caller's email (ADR-0056) |
| `current_user.organization_id` | the caller's tenant |
| `current_user.org_user_ids` | ids of users in the same org (for `IN`) |
| `current_user.positions` | the caller's positions (for `IN`; ADR-0090 D3) |

- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts` (policy shape),
`node_modules/@objectstack/spec/src/security/rls.zod.ts` (predicate grammar).
- Owner-scoping shortcut: the built-in `member_default` set already owner-scopes
writes via `owner_only_writes` / `owner_only_deletes`, and an object's
`sharingModel` (`private` / `public_read` / `public_read_write` / `controlled_by_parent`, ADR-0056 D1)
is the declarative way to set the org-wide default — prefer those over
hand-written policies for the common cases.

### Sensitive fields — `secret` type + `requiredPermissions`

`maskingRule` is **live** (plugin-security's FieldMasker enforces it). The real
channels are:

**Encrypted-at-rest values — `type: 'secret'` (ADR-0100).** For reversible
machine credentials (DB passwords, API keys, tokens): the engine encrypts the
value on write via the registered `ICryptoProvider`, stores the ciphertext
handle in `sys_secret`, persists only an opaque ref on the row, and masks the
value on read. **Fail-closed:** with no crypto provider registered, writes
throw rather than persist cleartext.

```typescript
fields: {
api_key: { type: 'secret', label: 'API Key' },
}
```

**Per-field access gating — `requiredPermissions` (ADR-0066 D3).** Capabilities
required to READ/EDIT the field. A field declaring `requiredPermissions` is
**masked on read and denied on write** unless the caller holds ALL listed
capabilities — an AND-gate that is strictest-wins over permission-set field
grants. Enforced by plugin-security's FieldMasker.

```typescript
fields: {
ssn: {
type: 'text',
requiredPermissions: ['view_pii'], // mask on read / deny on write without it
},
}
```

- Source: `node_modules/@objectstack/spec/src/data/field.zod.ts`
(`secret` field type, `requiredPermissions`)

### 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:

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

- **Database-per-tenant isolation is not object metadata** — it is an
environment/deployment choice (each environment carries its own database URL).
- Platform/env-global objects declare `tenancy: { enabled: false }` to opt out
of org row-scoping (see the visibility-posture recipe below).

### 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.
- 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
better-auth-managed identity table). It never crosses the Layer 0 wall —
crossing takes a *true platform admin* (the superuser bit **and** a
platform-exclusive capability: `manage_metadata`, `manage_platform_settings`,
`studio.access`, `manage_users`) on one of those same postures. So an org
admin holding the superuser bit stays org-scoped, and on an ordinary tenant
object nobody crosses — the admin sees 0 rows too.

**Recipe — env-global, admin-only object that admins can fully see:**

```typescript
tenancy: { enabled: false }, // not a tenant object → Layer 0 contributes nothing
requiredPermissions: ['manage_platform_settings'], // capability AND-gate → members get 403
```

> ⚠️ **Both keys are load-bearing — neither works alone.**
> `tenancy: { enabled: false }` *by itself* switches the wall off for **every**
> caller, and any permission set carrying a wildcard (`'*'`) read grant then
> reads every row env-wide — the shipped `viewer_readonly` still carries one, as
> may an app-declared default profile or a customer-authored set. (The
> `member_default` baseline is **not** one of them: it is explicit-allow and
> grants only the objects it names.) `requiredPermissions` *by itself* leaves the
> object a tenant object, so the wall keeps denying the untagged rows and even a
> platform admin sees nothing. The pair is the correct combo (admin sees all,
> non-admins 403), and `requiredPermissions` is the half that holds however
> permissive the caller's grants are — it is an AND-gate checked **before** the
> CRUD grant. Posture model: ADR-0066; tenant wall: ADR-0095 D1.

### Cross-skill notes

- **API auth providers** (OIDC, JWT, API key) live in **objectstack-api**.
- **Kernel-level RBAC services** (role inheritance, custom policy engines)
live in **objectstack-platform**.
- **CEL predicate syntax** (`P\`...\``, operators, functions) lives in
**objectstack-formula**.

---

## Metadata Protection (`protection`)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
10 changes: 9 additions & 1 deletion scripts/check-skills-token-ratchet.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -313,7 +313,11 @@ export const CEILINGS = new Map([
// a transition gate written as a `validations[]` invariant bricks rows that
// were legal when stored, and an invariant written as `requiredWhen` never
// enforces itself at all. +110 tokens, 1 absorbed by headroom, ceiling +109.
['skills/objectstack-data/SKILL.md', 13892],
// 13892 -> 10009: RE-LOCK at the landed count after the `rules/security.md`
// split (#14296 item 1 = A, condition (b)). The raise recorded above is spent
// and its headroom leaves with it; the moved text is priced in its own row
// below, so the package total is unchanged by the re-lock itself.
['skills/objectstack-data/SKILL.md', 10009],
['skills/objectstack-formula/SKILL.md', 6002], // -53 (was 6055)
['skills/objectstack-i18n/SKILL.md', 6338], // -11 (was 6349)
// 12705 -> 12984 (2026-08-31 app-repo-principles raise, see the block above).
Expand DownExpand Up@@ -408,6 +412,10 @@ export const CEILINGS = new Map([
['skills/objectstack-data/rules/lifecycle.md', 1590],
['skills/objectstack-data/rules/naming.md', 773],
['skills/objectstack-data/rules/relationships.md', 3778],
// NEW FILE (#14296 item 1 = A, condition (b)): the entry's Security & Access
// Control block moved here whole. Pinned at its landed count — no headroom,
// because a split that arrives with budget is a raise wearing a new path.
['skills/objectstack-data/rules/security.md', 2480],
// 3024 -> 3109 (2026-08-31 app-repo-principles raise, see the block above).
// Severity Levels listed the three values and left the CHOICE unstated: a
// block rests on a judgement a person made, so a machine-inferred signal — a
Expand Down
2 changes: 1 addition & 1 deletion scripts/role-word-baseline.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,7 +35,7 @@
"content/docs/ui/forms.mdx": 3,
"skills/objectstack-ai/SKILL.md": 1,
"skills/objectstack-automation/SKILL.md": 1,
"skills/objectstack-data/SKILL.md": 2,
"skills/objectstack-data/SKILL.md": 1,
"skills/objectstack-data/rules/relationships.md": 1,
"skills/objectstack-platform/SKILL.md": 2,
"skills/objectstack-query/rules/filters.md": 8,
Expand Down
250 changes: 17 additions & 233 deletions skills/objectstack-data/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,22 @@ metadata:

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects
- **[Security & Access Control](./rules/security.md)** — permission sets, assignment rows, RLS policies, `secret` / `requiredPermissions`, `tenancy`, platform-global posture

---

## Core Concepts

### Object Definition
Expand DownExpand Up@@ -280,21 +296,6 @@ export const Invoice = ObjectSchema.create({

---

## Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

- **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties
- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs
- **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
- **[Lifecycle Hooks](./references/data-hooks.md)** — the 8 lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects

---

## Quick-Start Template

<!-- os:check -->
Expand DownExpand Up@@ -544,67 +545,7 @@ Per-object access control is authored in **permission sets**, not on the object
schema. There is no object-level `permissions` key (and no `hooks` key either) —
`ObjectSchema.create()` **rejects** both as unknown keys.

### Object-level permissions (RBAC)

Grant CRUD access per object with boolean bits on a permission set:

<!-- os:check -->
```typescript
import { definePermissionSet } from '@objectstack/spec';

export const salesUser = definePermissionSet({
name: 'sales_user',
objects: {
account: { allowRead: true, allowCreate: true, allowEdit: true },
contact: { allowRead: true },
},
});

// Register it on the stack root under `permissions` — NOT `permissionSets`:
// defineStack({ permissions: [salesUser], ... })
```

- **Stack key: `permissions`.** The collection is named for the metadata kind,
not for the factory, so `definePermissionSet()` output goes into
`defineStack({ permissions: [...] })`. `permissionSets:` is **refused at
load** — the top level is strict, so the stack fails with an
`Unrecognized key(s) on this stack definition` error naming the key, never a
silent drop. `ObjectStackDefinitionSchema`
(`node_modules/@objectstack/spec/src/stack.zod.ts`) is the enumeration of
record; `objectstack-platform` lists every top-level key.
- Bits: `allowCreate` / `allowRead` / `allowEdit` / `allowDelete`, plus
`allowTransfer` (ownership change), `viewAllRecords` / `modifyAllRecords`
(super-user, bypass sharing).
- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts`
- Combine with `enable.apiMethods` to also restrict the HTTP surface.

### Assigning a permission set to a user

Declaring a set grants nobody anything — an assignment is **data**: one row in
the join object **`sys_user_permission_set`** (`@objectstack/plugin-security`),
carrying `user_id`, `permission_set_id`, and an optional `organization_id`
(`null` = every org context). Optional `valid_from` / `valid_until` bound a
half-open window checked at resolution time; `granted_by` is stamped by the
gate on insert — never author it.

⚠️ **`permission_set_id` takes the `sys_permission_set` RECORD ID, not the set's
`name`.** Grants resolve by loading `sys_permission_set` **by `id`**, so a `name`
in that field matches nothing, raises no error, and silently grants nothing.
Declared sets are upserted by `name` with a **generated** `id` on `kernel:ready`
(ADR-0086 D5) — that id differs per environment, so resolve it first.

Assignment is therefore two calls, both `POST /api/v1/data/{object}`
(`…/query` with a QueryAST body for the read): look up the set's `id` in
`sys_permission_set` by `name`, then insert
`{ user_id, permission_set_id, organization_id }` into
`sys_user_permission_set`. Only a tenant admin — or a delegated `adminScope`
carrying `manageAssignments` for that set and user (ADR-0090 D12) — may write
it; plain CRUD bits on the table are not enough.

**Grant looks inert?** Check in order: a `name` in `permission_set_id`; the set
is `active: false`; the validity window has passed; `organization_id` mismatch.
`GET /api/v1/security/explain?object=&operation=&userId=` answers from the
enforcing code path (explaining another user needs `manage_users`).
Full rules: **[Security & Access Control](./rules/security.md)**.

### Access depth (scope-depth) — the ERP "see my unit / my unit and below" axis

Expand All@@ -618,163 +559,6 @@ paid `@objectstack/security-enterprise` plugin, **fail closed to `own`** without
it, and `defineStack` errors unless the grant declares
`requires: ['hierarchy-security']`. Schema: `PermissionSetSchema.objects.*`.

### Row-Level Security (RLS)

The **enforced** RLS surface is a list of `rowLevelSecurity` policies on a
**permission set / profile** (`PermissionSetSchema.rowLevelSecurity`), *not* a
CEL predicate on the object. Each policy carries a `using` (read filter) and/or
`check` (write filter) **string** predicate. The compiler ANDs `using` into
every read for users carrying that set; `check` gates writes. (`@objectstack/plugin-security`
re-reads the target row through the write filter before single-id `update`/`delete`.)

```typescript
// in a permission set (definePermissionSet)
rowLevelSecurity: [
{
name: 'own_records',
object: 'account', // REQUIRED per policy
operation: 'all', // singular: select|insert|update|delete|all
using: 'owner_id == current_user.id', // read scope
check: 'owner_id == current_user.id', // write scope
},
{
name: 'org_isolation',
object: 'contact',
operation: 'select',
using: 'organization_id == current_user.organization_id',
},
]
```

Predicates are **canonical CEL** (ADR-0058): `field == current_user.<prop>`,
`field == 'literal'`, `field in current_user.<array>`, comparisons (`>`/`<`/`>=`/`<=`),
`&&`/`||`/`!`, and `== null` checks all lower to a pushdown filter. **No** cross-object
traversal or subqueries — those are a compile error (ADR-0055), never silently dropped.
A legacy SQL-style `=` / `IN (...)` predicate still compiles via a **deprecated** bridge
(emits a warning) but should be authored in CEL. The compiler resolves these
`current_user.*` placeholders:

| Placeholder | Resolves to |
|:--|:--|
| `current_user.id` | the caller's user id (ownership) |
| `current_user.email` | the caller's email (ADR-0056) |
| `current_user.organization_id` | the caller's tenant |
| `current_user.org_user_ids` | ids of users in the same org (for `IN`) |
| `current_user.positions` | the caller's positions (for `IN`; ADR-0090 D3) |

- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts` (policy shape),
`node_modules/@objectstack/spec/src/security/rls.zod.ts` (predicate grammar).
- Owner-scoping shortcut: the built-in `member_default` set already owner-scopes
writes via `owner_only_writes` / `owner_only_deletes`, and an object's
`sharingModel` (`private` / `public_read` / `public_read_write` / `controlled_by_parent`, ADR-0056 D1)
is the declarative way to set the org-wide default — prefer those over
hand-written policies for the common cases.

### Sensitive fields — `secret` type + `requiredPermissions`

`maskingRule` is **live** (plugin-security's FieldMasker enforces it). The real
channels are:

**Encrypted-at-rest values — `type: 'secret'` (ADR-0100).** For reversible
machine credentials (DB passwords, API keys, tokens): the engine encrypts the
value on write via the registered `ICryptoProvider`, stores the ciphertext
handle in `sys_secret`, persists only an opaque ref on the row, and masks the
value on read. **Fail-closed:** with no crypto provider registered, writes
throw rather than persist cleartext.

```typescript
fields: {
api_key: { type: 'secret', label: 'API Key' },
}
```

**Per-field access gating — `requiredPermissions` (ADR-0066 D3).** Capabilities
required to READ/EDIT the field. A field declaring `requiredPermissions` is
**masked on read and denied on write** unless the caller holds ALL listed
capabilities — an AND-gate that is strictest-wins over permission-set field
grants. Enforced by plugin-security's FieldMasker.

```typescript
fields: {
ssn: {
type: 'text',
requiredPermissions: ['view_pii'], // mask on read / deny on write without it
},
}
```

- Source: `node_modules/@objectstack/spec/src/data/field.zod.ts`
(`secret` field type, `requiredPermissions`)

### 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:

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

- **Database-per-tenant isolation is not object metadata** — it is an
environment/deployment choice (each environment carries its own database URL).
- Platform/env-global objects declare `tenancy: { enabled: false }` to opt out
of org row-scoping (see the visibility-posture recipe below).

### 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.
- 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
better-auth-managed identity table). It never crosses the Layer 0 wall —
crossing takes a *true platform admin* (the superuser bit **and** a
platform-exclusive capability: `manage_metadata`, `manage_platform_settings`,
`studio.access`, `manage_users`) on one of those same postures. So an org
admin holding the superuser bit stays org-scoped, and on an ordinary tenant
object nobody crosses — the admin sees 0 rows too.

**Recipe — env-global, admin-only object that admins can fully see:**

```typescript
tenancy: { enabled: false }, // not a tenant object → Layer 0 contributes nothing
requiredPermissions: ['manage_platform_settings'], // capability AND-gate → members get 403
```

> ⚠️ **Both keys are load-bearing — neither works alone.**
> `tenancy: { enabled: false }` *by itself* switches the wall off for **every**
> caller, and any permission set carrying a wildcard (`'*'`) read grant then
> reads every row env-wide — the shipped `viewer_readonly` still carries one, as
> may an app-declared default profile or a customer-authored set. (The
> `member_default` baseline is **not** one of them: it is explicit-allow and
> grants only the objects it names.) `requiredPermissions` *by itself* leaves the
> object a tenant object, so the wall keeps denying the untagged rows and even a
> platform admin sees nothing. The pair is the correct combo (admin sees all,
> non-admins 403), and `requiredPermissions` is the half that holds however
> permissive the caller's grants are — it is an AND-gate checked **before** the
> CRUD grant. Posture model: ADR-0066; tenant wall: ADR-0095 D1.

### Cross-skill notes

- **API auth providers** (OIDC, JWT, API key) live in **objectstack-api**.
- **Kernel-level RBAC services** (role inheritance, custom policy engines)
live in **objectstack-platform**.
- **CEL predicate syntax** (`P\`...\``, operators, functions) lives in
**objectstack-formula**.

---

## Metadata Protection (`protection`)
Expand Down
Loading
Loading