@@ -37,6 +37,22 @@ metadata:
3737
3838---
3939
40+ ## Quick Reference — Detailed Rules
41+
42+ For comprehensive documentation with incorrect/correct examples:
43+
44+ - ** [ Naming Conventions] ( ./rules/naming.md ) ** — snake_case rules, option values, config properties
45+ - ** [ Field Types] ( ./rules/field-types.md ) ** — All 49 field types with decision tree and configs
46+ - ** [ Relationships] ( ./rules/relationships.md ) ** — lookup vs master_detail, junction patterns, delete behaviors
47+ - ** [ Validation Rules] ( ./rules/validation.md ) ** — All validation types, script inversion, severity levels
48+ - ** [ Index Strategy] ( ./rules/indexing.md ) ** — btree/gin/gist/fulltext, composite indexes, partial indexes
49+ - ** [ 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)
50+ - ** [ Lifecycle Hooks] ( ./references/data-hooks.md ) ** — the 8 lifecycle events, ` handler ` vs sandboxed ` body ` (ctx + capability contract), registration, canonical patterns
51+ - ** [ Datasources & Federation] ( ./rules/datasources.md ) ** — ` defineDatasource ` , external/federated objects (` remoteName ` /` columnMap ` ), auto-connect gating, credentials; ❌ no ` field.columnName ` on external objects
52+ - ** [ Security & Access Control] ( ./rules/security.md ) ** — permission sets, assignment rows, RLS policies, ` secret ` / ` requiredPermissions ` , ` tenancy ` , platform-global posture
53+
54+ ---
55+
4056## Core Concepts
4157
4258### Object Definition
@@ -280,21 +296,6 @@ export const Invoice = ObjectSchema.create({
280296
281297---
282298
283- ## Quick Reference — Detailed Rules
284-
285- For comprehensive documentation with incorrect/correct examples:
286-
287- - ** [ Naming Conventions] ( ./rules/naming.md ) ** — snake_case rules, option values, config properties
288- - ** [ Field Types] ( ./rules/field-types.md ) ** — All 49 field types with decision tree and configs
289- - ** [ Relationships] ( ./rules/relationships.md ) ** — lookup vs master_detail, junction patterns, delete behaviors
290- - ** [ Validation Rules] ( ./rules/validation.md ) ** — All validation types, script inversion, severity levels
291- - ** [ Index Strategy] ( ./rules/indexing.md ) ** — btree/gin/gist/fulltext, composite indexes, partial indexes
292- - ** [ 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)
293- - ** [ Lifecycle Hooks] ( ./references/data-hooks.md ) ** — the 8 lifecycle events, ` handler ` vs sandboxed ` body ` (ctx + capability contract), registration, canonical patterns
294- - ** [ Datasources & Federation] ( ./rules/datasources.md ) ** — ` defineDatasource ` , external/federated objects (` remoteName ` /` columnMap ` ), auto-connect gating, credentials; ❌ no ` field.columnName ` on external objects
295-
296- ---
297-
298299## Quick-Start Template
299300
300301<!-- os:check -->
@@ -544,67 +545,7 @@ Per-object access control is authored in **permission sets**, not on the object
544545schema. There is no object-level ` permissions ` key (and no ` hooks ` key either) —
545546` ObjectSchema.create() ` ** rejects** both as unknown keys.
546547
547- ### Object-level permissions (RBAC)
548-
549- Grant CRUD access per object with boolean bits on a permission set:
550-
551- <!-- os:check -->
552- ``` typescript
553- import { definePermissionSet } from ' @objectstack/spec' ;
554-
555- export const salesUser = definePermissionSet ({
556- name: ' sales_user' ,
557- objects: {
558- account: { allowRead: true , allowCreate: true , allowEdit: true },
559- contact: { allowRead: true },
560- },
561- });
562-
563- // Register it on the stack root under `permissions` — NOT `permissionSets`:
564- // defineStack({ permissions: [salesUser], ... })
565- ```
566-
567- - ** Stack key: ` permissions ` .** The collection is named for the metadata kind,
568- not for the factory, so ` definePermissionSet() ` output goes into
569- ` defineStack({ permissions: [...] }) ` . ` permissionSets: ` is ** refused at
570- load** — the top level is strict, so the stack fails with an
571- ` Unrecognized key(s) on this stack definition ` error naming the key, never a
572- silent drop. ` ObjectStackDefinitionSchema `
573- (` node_modules/@objectstack/spec/src/stack.zod.ts ` ) is the enumeration of
574- record; ` objectstack-platform ` lists every top-level key.
575- - Bits: ` allowCreate ` / ` allowRead ` / ` allowEdit ` / ` allowDelete ` , plus
576- ` allowTransfer ` (ownership change), ` viewAllRecords ` / ` modifyAllRecords `
577- (super-user, bypass sharing).
578- - Source: ` node_modules/@objectstack/spec/src/security/permission.zod.ts `
579- - Combine with ` enable.apiMethods ` to also restrict the HTTP surface.
580-
581- ### Assigning a permission set to a user
582-
583- Declaring a set grants nobody anything — an assignment is ** data** : one row in
584- the join object ** ` sys_user_permission_set ` ** (` @objectstack/plugin-security ` ),
585- carrying ` user_id ` , ` permission_set_id ` , and an optional ` organization_id `
586- (` null ` = every org context). Optional ` valid_from ` / ` valid_until ` bound a
587- half-open window checked at resolution time; ` granted_by ` is stamped by the
588- gate on insert — never author it.
589-
590- ⚠️ ** ` permission_set_id ` takes the ` sys_permission_set ` RECORD ID, not the set's
591- ` name ` .** Grants resolve by loading ` sys_permission_set ` ** by ` id ` ** , so a ` name `
592- in that field matches nothing, raises no error, and silently grants nothing.
593- Declared sets are upserted by ` name ` with a ** generated** ` id ` on ` kernel:ready `
594- (ADR-0086 D5) — that id differs per environment, so resolve it first.
595-
596- Assignment is therefore two calls, both ` POST /api/v1/data/{object} `
597- (` …/query ` with a QueryAST body for the read): look up the set's ` id ` in
598- ` sys_permission_set ` by ` name ` , then insert
599- ` { user_id, permission_set_id, organization_id } ` into
600- ` sys_user_permission_set ` . Only a tenant admin — or a delegated ` adminScope `
601- carrying ` manageAssignments ` for that set and user (ADR-0090 D12) — may write
602- it; plain CRUD bits on the table are not enough.
603-
604- ** Grant looks inert?** Check in order: a ` name ` in ` permission_set_id ` ; the set
605- is ` active: false ` ; the validity window has passed; ` organization_id ` mismatch.
606- ` GET /api/v1/security/explain?object=&operation=&userId= ` answers from the
607- enforcing code path (explaining another user needs ` manage_users ` ).
548+ Full rules: ** [ Security & Access Control] ( ./rules/security.md ) ** .
608549
609550### Access depth (scope-depth) — the ERP "see my unit / my unit and below" axis
610551
@@ -618,163 +559,6 @@ paid `@objectstack/security-enterprise` plugin, **fail closed to `own`** without
618559it, and ` defineStack ` errors unless the grant declares
619560` requires: ['hierarchy-security'] ` . Schema: ` PermissionSetSchema.objects.* ` .
620561
621- ### Row-Level Security (RLS)
622-
623- The ** enforced** RLS surface is a list of ` rowLevelSecurity ` policies on a
624- ** permission set / profile** (` PermissionSetSchema.rowLevelSecurity ` ), * not* a
625- CEL predicate on the object. Each policy carries a ` using ` (read filter) and/or
626- ` check ` (write filter) ** string** predicate. The compiler ANDs ` using ` into
627- every read for users carrying that set; ` check ` gates writes. (` @objectstack/plugin-security `
628- re-reads the target row through the write filter before single-id ` update ` /` delete ` .)
629-
630- ``` typescript
631- // in a permission set (definePermissionSet)
632- rowLevelSecurity : [
633- {
634- name: ' own_records' ,
635- object: ' account' , // REQUIRED per policy
636- operation: ' all' , // singular: select|insert|update|delete|all
637- using: ' owner_id == current_user.id' , // read scope
638- check: ' owner_id == current_user.id' , // write scope
639- },
640- {
641- name: ' org_isolation' ,
642- object: ' contact' ,
643- operation: ' select' ,
644- using: ' organization_id == current_user.organization_id' ,
645- },
646- ]
647- ```
648-
649- Predicates are ** canonical CEL** (ADR-0058): ` field == current_user.<prop> ` ,
650- ` field == 'literal' ` , ` field in current_user.<array> ` , comparisons (` > ` /` < ` /` >= ` /` <= ` ),
651- ` && ` /` || ` /` ! ` , and ` == null ` checks all lower to a pushdown filter. ** No** cross-object
652- traversal or subqueries — those are a compile error (ADR-0055), never silently dropped.
653- A legacy SQL-style ` = ` / ` IN (...) ` predicate still compiles via a ** deprecated** bridge
654- (emits a warning) but should be authored in CEL. The compiler resolves these
655- ` current_user.* ` placeholders:
656-
657- | Placeholder | Resolves to |
658- | :--| :--|
659- | ` current_user.id ` | the caller's user id (ownership) |
660- | ` current_user.email ` | the caller's email (ADR-0056) |
661- | ` current_user.organization_id ` | the caller's tenant |
662- | ` current_user.org_user_ids ` | ids of users in the same org (for ` IN ` ) |
663- | ` current_user.positions ` | the caller's positions (for ` IN ` ; ADR-0090 D3) |
664-
665- - Source: ` node_modules/@objectstack/spec/src/security/permission.zod.ts ` (policy shape),
666- ` node_modules/@objectstack/spec/src/security/rls.zod.ts ` (predicate grammar).
667- - Owner-scoping shortcut: the built-in ` member_default ` set already owner-scopes
668- writes via ` owner_only_writes ` / ` owner_only_deletes ` , and an object's
669- ` sharingModel ` (` private ` / ` public_read ` / ` public_read_write ` / ` controlled_by_parent ` , ADR-0056 D1)
670- is the declarative way to set the org-wide default — prefer those over
671- hand-written policies for the common cases.
672-
673- ### Sensitive fields — ` secret ` type + ` requiredPermissions `
674-
675- ` maskingRule ` is ** live** (plugin-security's FieldMasker enforces it). The real
676- channels are:
677-
678- ** Encrypted-at-rest values — ` type: 'secret' ` (ADR-0100).** For reversible
679- machine credentials (DB passwords, API keys, tokens): the engine encrypts the
680- value on write via the registered ` ICryptoProvider ` , stores the ciphertext
681- handle in ` sys_secret ` , persists only an opaque ref on the row, and masks the
682- value on read. ** Fail-closed:** with no crypto provider registered, writes
683- throw rather than persist cleartext.
684-
685- ``` typescript
686- fields : {
687- api_key : { type : ' secret' , label : ' API Key' },
688- }
689- ```
690-
691- ** Per-field access gating — ` requiredPermissions ` (ADR-0066 D3).** Capabilities
692- required to READ/EDIT the field. A field declaring ` requiredPermissions ` is
693- ** masked on read and denied on write** unless the caller holds ALL listed
694- capabilities — an AND-gate that is strictest-wins over permission-set field
695- grants. Enforced by plugin-security's FieldMasker.
696-
697- ``` typescript
698- fields : {
699- ssn : {
700- type : ' text' ,
701- requiredPermissions : [' view_pii' ], // mask on read / deny on write without it
702- },
703- }
704- ```
705-
706- - Source: ` node_modules/@objectstack/spec/src/data/field.zod.ts `
707- (` secret ` field type, ` requiredPermissions ` )
708-
709- ### Multi-tenancy
710-
711- For SaaS, set ` tenancy ` on the object schema for row-level tenant isolation
712- (the tenant field is injected on write and enforced on read). The block is
713- ** strict** — exactly two keys:
714-
715- ``` typescript
716- tenancy : {
717- enabled : true , // enable row-level tenant isolation
718- // tenantField — NO default; omit it and the driver uses `organization_id`
719- }
720- ```
721-
722- - ** Database-per-tenant isolation is not object metadata** — it is an
723- environment/deployment choice (each environment carries its own database URL).
724- - Platform/env-global objects declare ` tenancy: { enabled: false } ` to opt out
725- of org row-scoping (see the visibility-posture recipe below).
726-
727- ### Platform-global / admin-only objects (visibility posture)
728-
729- Some system/config objects are ** env-global** (not partitioned per org) and
730- should be visible to a ** platform admin env-wide** but hidden from members —
731- e.g. identity tables a plugin writes via its own adapter (` sys_sso_provider ` ,
732- OAuth clients). These hit a non-obvious interaction:
733-
734- - Reads of a tenant object pass the ** Layer 0 tenant wall** (ADR-0095 D1): an
735- ` organization_id == <the caller's organization> ` filter AND-composed ahead of
736- every business RLS policy. Any row whose ` organization_id ` is ** null or
737- absent** (common for adapter-written rows that never get the tenant stamp) is
738- ** denied** — the list renders empty. Single-tenant deployments never hit this;
739- the wall is inert there.
740- - The ` viewAllRecords ` superuser bit is ** posture-gated and wall-blind** : it
741- short-circuits ** business RLS only** , and only on objects whose posture allows
742- it (` access.default: 'private' ` , ` tenancy: { enabled: false } ` , or a
743- better-auth-managed identity table). It never crosses the Layer 0 wall —
744- crossing takes a * true platform admin* (the superuser bit ** and** a
745- platform-exclusive capability: ` manage_metadata ` , ` manage_platform_settings ` ,
746- ` studio.access ` , ` manage_users ` ) on one of those same postures. So an org
747- admin holding the superuser bit stays org-scoped, and on an ordinary tenant
748- object nobody crosses — the admin sees 0 rows too.
749-
750- ** Recipe — env-global, admin-only object that admins can fully see:**
751-
752- ``` typescript
753- tenancy : { enabled : false }, // not a tenant object → Layer 0 contributes nothing
754- requiredPermissions : [' manage_platform_settings' ], // capability AND-gate → members get 403
755- ```
756-
757- > ⚠️ ** Both keys are load-bearing — neither works alone.**
758- > ` tenancy: { enabled: false } ` * by itself* switches the wall off for ** every**
759- > caller, and any permission set carrying a wildcard (` '*' ` ) read grant then
760- > reads every row env-wide — the shipped ` viewer_readonly ` still carries one, as
761- > may an app-declared default profile or a customer-authored set. (The
762- > ` member_default ` baseline is ** not** one of them: it is explicit-allow and
763- > grants only the objects it names.) ` requiredPermissions ` * by itself* leaves the
764- > object a tenant object, so the wall keeps denying the untagged rows and even a
765- > platform admin sees nothing. The pair is the correct combo (admin sees all,
766- > non-admins 403), and ` requiredPermissions ` is the half that holds however
767- > permissive the caller's grants are — it is an AND-gate checked ** before** the
768- > CRUD grant. Posture model: ADR-0066; tenant wall: ADR-0095 D1.
769-
770- ### Cross-skill notes
771-
772- - ** API auth providers** (OIDC, JWT, API key) live in ** objectstack-api** .
773- - ** Kernel-level RBAC services** (role inheritance, custom policy engines)
774- live in ** objectstack-platform** .
775- - ** CEL predicate syntax** (` P\ ` ...\` `, operators, functions) lives in
776- ** objectstack-formula** .
777-
778562---
779563
780564## Metadata Protection (` protection ` )
0 commit comments