Skip to content

Commit 446117f

Browse files
os-litantclaude
andauthored
skills(data): split security into rules/security.md under #14296 item 1 (package net −64 tokens) + DATA-B-06 (#14673)
* skills(data): split Security & Access Control into rules/security.md DATA-I-04. The entry file carried a 239-line Security & Access Control block; six of its seven subsections move verbatim into a new `skills/objectstack-data/rules/security.md`. No sentence is added: the new file's only new bytes are its H1, and the entry keeps a one-line pointer plus one row in the routing table. `### Access depth (scope-depth)` stays in SKILL.md. It is a registered exhaustive section in `check:skill-identifier-liveness` Leg 2 (`access-scope-depth` -> `ObjectAccessScopeSchema`), keyed by file path plus heading, and a bound heading never leaves its file. Three declared deletions inside the moved text, each a duplicate that the split would otherwise carry across a file boundary: - `### Cross-skill notes` (3 bullets) — objectstack-api and objectstack-platform are routed by the entry's Skill Boundaries table, objectstack-formula by the frontmatter and by three other entry sites. - the `sharingModel` four-value re-enumeration in the RLS bullet — the exhaustive table lives in the entry's Required properties block. - Multi-tenancy's forward-pointer bullet to the visibility-posture recipe directly beneath it. Ceilings, per the ruling's condition (b): one row for the new file pinned at its landed count (2480), the entry row re-locked at its landed count (13892 -> 10009). No other row changes. Package (every file under skills/objectstack-data/, generated included): 38,219 -> 38,155 tokens, net -64. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LraLgQVGq8egUwfYZpbYt1 * skills(data): move the rules routing table above Core Concepts DATA-B-06 (MOVE-TO). The 9-row table telling an agent which `rules/` file answers its question sat at line 283 of 1,067, behind Core Concepts, the `searchableFields` section and Field Groups; an agent that stops reading early never learns `rules/` exists. It now sits directly after the Skill Boundaries table, which is the other routing construct in the file. Byte-neutral: the same 16 lines, moved. SKILL.md is 40,034 bytes before and after, so the ceiling re-locked in the previous commit still holds exactly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LraLgQVGq8egUwfYZpbYt1 * chore(gates): ratchet-DOWN the role-word baseline for objectstack-data `check:role-word` went red as a ratchet-DOWN: the entry file's count improved 2 -> 1 because the deleted `### Cross-skill notes` bullet carried "role inheritance". The other occurrence is untouched, and the new `rules/security.md` carries none, so no entry is added. The remedy is the gate's own author-side one (`--update`), one shrinking line. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LraLgQVGq8egUwfYZpbYt1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ba1806d commit 446117f

4 files changed

Lines changed: 237 additions & 235 deletions

File tree

‎scripts/check-skills-token-ratchet.mjs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,11 @@ export const CEILINGS = new Map([
313313
// a transition gate written as a `validations[]` invariant bricks rows that
314314
// were legal when stored, and an invariant written as `requiredWhen` never
315315
// enforces itself at all. +110 tokens, 1 absorbed by headroom, ceiling +109.
316-
['skills/objectstack-data/SKILL.md', 13892],
316+
// 13892 -> 10009: RE-LOCK at the landed count after the `rules/security.md`
317+
// split (#14296 item 1 = A, condition (b)). The raise recorded above is spent
318+
// and its headroom leaves with it; the moved text is priced in its own row
319+
// below, so the package total is unchanged by the re-lock itself.
320+
['skills/objectstack-data/SKILL.md', 10009],
317321
['skills/objectstack-formula/SKILL.md', 6002], // -53 (was 6055)
318322
['skills/objectstack-i18n/SKILL.md', 6338], // -11 (was 6349)
319323
// 12705 -> 12984 (2026-08-31 app-repo-principles raise, see the block above).
@@ -418,6 +422,10 @@ export const CEILINGS = new Map([
418422
['skills/objectstack-data/rules/lifecycle.md', 1590],
419423
['skills/objectstack-data/rules/naming.md', 773],
420424
['skills/objectstack-data/rules/relationships.md', 3778],
425+
// NEW FILE (#14296 item 1 = A, condition (b)): the entry's Security & Access
426+
// Control block moved here whole. Pinned at its landed count — no headroom,
427+
// because a split that arrives with budget is a raise wearing a new path.
428+
['skills/objectstack-data/rules/security.md', 2480],
421429
// 3024 -> 3109 (2026-08-31 app-repo-principles raise, see the block above).
422430
// Severity Levels listed the three values and left the CHOICE unstated: a
423431
// block rests on a judgement a person made, so a machine-inferred signal — a

‎scripts/role-word-baseline.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"content/docs/ui/forms.mdx": 3,
3636
"skills/objectstack-ai/SKILL.md": 1,
3737
"skills/objectstack-automation/SKILL.md": 1,
38-
"skills/objectstack-data/SKILL.md": 2,
38+
"skills/objectstack-data/SKILL.md": 1,
3939
"skills/objectstack-data/rules/relationships.md": 1,
4040
"skills/objectstack-platform/SKILL.md": 2,
4141
"skills/objectstack-query/rules/filters.md": 8,

‎skills/objectstack-data/SKILL.md‎

Lines changed: 17 additions & 233 deletions
Original file line numberDiff line numberDiff line change
@@ -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
544545
schema. 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
618559
it, 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

Comments
 (0)