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
2 changes: 1 addition & 1 deletion .changeset/data-skill-optional-master.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/skills': patch
---
Expand All @@ -7,4 +7,4 @@
Clause-②: no
Metadata-protocol: none

`objectstack-data` now teaches "a rep sees the contacts of the accounts they own; a contact need not have an account" as a `controlled_by_parent` detail whose only `master_detail` is declared `required: false` (an optional master), not as an owner field copied by hooks. A row with no master is its owner's: the owner or View All on the detail reads it, the owner or Modify All writes it, an insert naming no master is admitted, setting a master asks edit on it, emptying one asks edit on the stored master (`ownerMayEdit` does not cover that), and a chain hop with no master ends there under the same rule. The `relationship/master-detail-required` lint row names the optional master, and the membership-key notes say a `valueField` cannot be the roster's own `id` (`membership-key-value-field-invalid`). The platform behaviour shipped earlier in `@objectstack/spec`, `@objectstack/lint` and `@objectstack/plugin-security`; this release only teaches it.
`objectstack-data` now teaches "a rep sees the contacts of the accounts they own; a contact need not have an account" as a `controlled_by_parent` detail whose only `master_detail` is declared `required: false` (an optional master), not as an owner field copied by hooks. A row with no master is its owner's: the owner or View All on the detail reads it, the owner or Modify All writes it, an insert naming no master is admitted, setting a master asks edit on it, emptying one asks edit on the stored master (`ownerMayEdit` does not cover that), and a chain hop with no master ends there under the same rule. The `relationship/master-detail-required` lint row names the optional master. The platform behaviour shipped earlier in `@objectstack/spec`, `@objectstack/lint` and `@objectstack/plugin-security`; this release only teaches it.
10 changes: 10 additions & 0 deletions .changeset/data-skill-person-is-a-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/skills': patch
---

docs(skills): the data skill teaches "the person is a record", a membership key with `valueField: 'id'`

Clause-②: no
Metadata-protocol: none

`objectstack-data`'s security rule now teaches the membership key whose `valueField` is `id`, the roster's own primary key: on an `employee` roster, `my_employee_ids: { userField: 'user', valueField: 'id' }` is the ids of the caller's own employee rows, and a `leave_request` policy reads `employee in current_user.my_employee_ids`. It grants nothing by itself, and a policy that tests it against a column not referencing the roster, or another object's `id`, is refused (`rls-predicate-membership-key-target`). The rule no longer says `valueField` can never be `id`; that was true before the platform change and is corrected here, including the earlier note in this release. A new eval case exercises it. The platform behaviour is the `@objectstack/spec`, `@objectstack/lint` and `@objectstack/plugin-security` change in this same release; this note covers only the teaching.
57 changes: 55 additions & 2 deletions content/docs/permissions/rls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,8 @@ rows. Match on `id` or `email`, which are.
Any other `current_user.KEY` is a **membership set**: a pre-resolved array that
lets a policy express set membership without the subqueries the grammar doesn't
allow. Declare it first — a `membershipKeys` entry on the **roster** object whose
rows define the set names the user column (`userField`), the reference column
whose values form the set (`valueField`) and an optional `filter` over the
rows define the set names the user column (`userField`), the column whose
values form the set (`valueField`: a reference column, or `id`) and an optional `filter` over the
roster's own columns, and every policy reads the result as `current_user.KEY`:

{/* os:check */}
Expand Down Expand Up @@ -218,6 +218,59 @@ installed plugin registers a membership resolver that supplies the key. A key
both sources claim resolves from neither, and a key nothing supplies, or one
over 1,000 values, matches no rows (fail closed).

**When the person is a record.** HR, service-desk and portal apps often model
the person as a row (an `employee`, an `agent`, a `customer`) with a user column
pointing at the sign-in. Name `id`, the roster's own primary key, as
`valueField`, and the set holds the ids of the caller's own rows of that object,
"the records I am":

{/* os:check */}
```typescript
import { ObjectSchema } from '@objectstack/spec/data';
import { definePermissionSet } from '@objectstack/spec';

// Roster: the person is a record, linked to the user who signs in
export const Employee = ObjectSchema.create({
name: 'employee',
sharingModel: 'private',
fields: {
user: { type: 'user', required: true },
},
membershipKeys: {
my_employee_ids: { userField: 'user', valueField: 'id' },
},
});

export const LeaveRequest = ObjectSchema.create({
name: 'leave_request',
sharingModel: 'public_read',
fields: {
employee: { type: 'lookup', reference: 'employee', required: true },
},
});

export const EmployeeSelfService = definePermissionSet({
name: 'employee_self_service',
label: 'Employee Self-Service',
objects: { leave_request: { allowRead: true } },
rowLevelSecurity: [
{ name: 'own_leave', object: 'leave_request', operation: 'select', using: 'employee in current_user.my_employee_ids' },
],
});
```

Such a key holds ids of the roster object itself, so a policy compares it with a
column that references the roster (the leave request's `employee`) or with the
roster's own `id`. Tested against a column that references another object, a
column that is not a reference, or another object's `id`, the set could never
match, and the policy is refused (`rls-predicate-membership-key-target`). The
key grants nothing by itself: it yields only the rows whose `userField` is the
caller, and what a policy lets through with it is the author's choice.
`leave_request` is `public_read` so that the policy decides which rows the
holder reads: RLS only narrows what the org-wide default already allowed, so
under `private` it would narrow the employee's own rows, and a request someone
else filed for them would stay hidden.

## How policies compose

```
Expand Down
19 changes: 17 additions & 2 deletions scripts/check-skills-token-ratchet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -859,8 +859,23 @@ export const CEILINGS = new Map([
// units. On `origin/main` the file was 14722 bytes (3681 tokens, headroom
// 6); the LANDED file is 14791 bytes -> 3698 tokens, ceiling +11, pinned AT
// the measurement, zero headroom.
// ceil($(wc -c < skills/objectstack-data/rules/security.md) / 4) == ceil(14791 / 4)
['skills/objectstack-data/rules/security.md', 3698],
// 3698 -> 3768 (#31176, the docs-and-skill half of ruling A on #31129): the
// membership key with `valueField: 'id'` ("the person is a record": an
// `employee` roster's `my_employee_ids`, read by a `leave_request` policy as
// `employee in current_user.my_employee_ids`), what it does not do (grants
// nothing by itself; tested against a column not referencing the roster, or
// another object's `id`: `rls-predicate-membership-key-target`), and the
// correction of the false "nor `id`" clause on `valueField`. Same standing
// ruling, 2026-10-27, decision card 「skills 修复卡的教学和 eval 都撞 token
// 天花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」
// (record 6513306068). PAID DOWN FIRST: the three `- Source:` pointer lines
// (`security/permission.zod.ts`, `security/rls.zod.ts`, `data/field.zod.ts`),
// each path kept in the generated `references/_index.md`; 208 bytes. On
// `origin/main` the file was 14783 bytes (3696 tokens, headroom 2); the
// LANDED file is 15069 bytes -> 3768 tokens, ceiling +70, pinned AT the
// measurement, zero headroom.
// ceil($(wc -c < skills/objectstack-data/rules/security.md) / 4) == ceil(15069 / 4)
['skills/objectstack-data/rules/security.md', 3768],
// 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
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@
"must_contain": ["sharingModel: 'private'", "sharingModel: 'public_read'"],
"must_not_contain": ["sharingModel: 'public_read_write'", "permissions:"]
}
},
{
"id": 17,
"prompt": "In our HR app each `employee` record has a `user` lookup to the person who signs in. An employee may read only their own `leave_request` rows (each has an `employee` lookup), and we don't want an owner stamp or a junction table for it. Call the set `my_employee_ids`.",
"expected_output": "On the `employee` roster: `membershipKeys: { my_employee_ids: { userField: 'user', valueField: 'id' } }`, so the set is the ids of the caller's own employee rows; a permission set reads it (`using: 'employee in current_user.my_employee_ids'`) on `leave_request`, whose `employee` references the roster. The key grants nothing by itself, and tested against a column that does not reference `employee` (or another object's `id`) the policy is refused (`rls-predicate-membership-key-target`). No `owner_id` scoping, no junction object.",
"files": [],
"assertions": {
"must_contain": ["membershipKeys", "my_employee_ids", "valueField: 'id'", "employee in current_user.my_employee_ids"],
"must_not_contain": ["using: 'owner_id"]
}
}
]
}
21 changes: 12 additions & 9 deletions skills/objectstack-data/rules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export const salesUser = definePermissionSet({
- 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`
- **`isDefault: true` = the `everyone` baseline (ADR-0090 D5).** A published package
declares **exactly one**, anchor-safe: `describeAnchorForbiddenBits(set, 'everyone')`
(`@objectstack/spec/security`) returns `null`, else lint
Expand Down Expand Up @@ -109,7 +108,6 @@ bridge (warns). The compiler resolves these
| `current_user.queues` | the ownership queues the caller sits in (for `IN`; ADR-0142 D3) |
| `current_user.KEY` | the set an object's `membershipKeys.KEY` declares (for `IN`) |

- Source: `node_modules/@objectstack/spec/src/security/rls.zod.ts`.
- Prefer the built-in `member_default` owner scoping (`owner_only_writes` /
`owner_only_deletes`) and the object's `sharingModel` (ADR-0056 D1) over
hand-written policies for the common cases.
Expand Down Expand Up @@ -140,26 +138,33 @@ export default ObjectSchema.create({
```

```typescript
// in the portal permission set — a policy reads the set with `in`
// in a permission set — a policy reads the set with `in`
rowLevelSecurity: [
{ name: 'portal_orders', object: 'order', operation: 'select', using: 'account in current_user.portal_account_ids' },
// the person is a record: roster `employee` declares
// my_employee_ids: { userField: 'user', valueField: 'id' }
{ name: 'own_leave', object: 'leave_request', operation: 'select', using: 'employee in current_user.my_employee_ids' },
]
```

- `userField`: the roster's user column (`user`, or a lookup to `sys_user`);
`valueField`: the reference column whose values form the set (never the
same column, nor `id`: `membership-key-value-field-invalid`);
`valueField`: the reference column whose values form the set, or `id`
(`membership-key-value-field-invalid`; never the same column);
`filter`: optional canonical CEL over the roster's own
columns, no `current_user.*`.
- With `id` the set is the ids of the caller's own roster rows; it grants
nothing by itself. Tested against a column not referencing the roster, or another
object's `id`, it is refused (`rls-predicate-membership-key-target`).
- One name per stack: a key on two objects, a kernel name (`id`,
`organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`,
`email`, `queues`) and a key on an object extension are refused.
- Read once per request as a system read in the caller's organization (no
read on the roster needed), at most 1,000 values; more, a failed read or a
`filter` that does not compile matches **no rows**. Whoever writes roster
rows grants the key.
- **Choose by what the rows are keyed on:** the person → owner scoping; a
position → `positions` on the policy; a named record → a membership key;
- **Choose by what the rows are keyed on:** the user → owner scoping; a
position → `positions` on the policy; a named record (the caller's own
roster row too) → a membership key;
a parent's owner, parent optional → an optional master (relationships.md);
one record, once → a sharing rule or a manual share; a team's records → a
sharing rule whose `sharedWith: { type: 'queue', value: '<queue name>' }`
Expand Down Expand Up @@ -244,8 +249,6 @@ fields: {
}
```

- Source: `node_modules/@objectstack/spec/src/data/field.zod.ts`

## Multi-tenancy

Organization count is a **deployment posture** fact, never object metadata.
Expand Down
Loading