Skip to content

Commit e63f6ec

Browse files
docs(skills): objectstack-data teaches membership keys, ownerMayEdit, create-only seed columns, the position-to-set binding and the external tier
rules/security.md: a membership-keys section (declare `membershipKeys` on the roster object; the three members; the refusals; the once-per-request fail-closed read; the choice rule against owner scoping, a position and a sharing rule), then the position -> `permissionSets` binding (a namesake set is reported, never granted) and the `external` membership tier for portal users (no baseline, explicit grants only). rules/relationships.md: `sharing.controlledByParent.ownerMayEdit` beside the controlled_by_parent row. references/seeds.md: the `columns` dataset key and `applyOn: 'create'`. references/examples-objects.md: the two `{ type: 'lookup', reference: 'user' }` fields become `type: 'user'`. evals: one portal membership case; the seed and invoice-line cases gain the new keys. scripts/check-skills-token-ratchet.mjs: the six touched objectstack-data rows follow the measurement (five raised under the maintainer's ruling, one lowered by the net cut). Claude-Session: https://claude.ai/code/session_01JQwxbXTWvHG6MXFD7Gywf2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent c51aed4 commit e63f6ec

7 files changed

Lines changed: 171 additions & 16 deletions

File tree

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

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,14 @@ export const CEILINGS = new Map([
531531
// 3778 -> 3676: RE-LOCK at the landed count (#19715, card item 4). A 5-line
532532
// table of contents (+154 tokens) landed inside the row's 256 tokens of
533533
// headroom, so no raise; pinned AT the measurement — zero headroom.
534-
['skills/objectstack-data/rules/relationships.md', 3676],
534+
// 3676 -> 3873 (#26297): `sharing.controlledByParent.ownerMayEdit` — the
535+
// owner of a controlled_by_parent detail row may update it by id with read
536+
// access to the master; what it does not widen; refused off a detail and
537+
// one level up — which the file did not teach beside `controlled_by_parent`.
538+
// +201 tokens on a landed 3672, 4 absorbed by headroom, ceiling +197. No
539+
// repeated passage to cut first (measured: no sentence repeats). Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天
540+
// 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」.
541+
['skills/objectstack-data/rules/relationships.md', 3873],
535542
// NEW FILE (#14296 item 1 = A, condition (b)): the entry's Security & Access
536543
// Control block moved here whole. Pinned at its landed count — no headroom,
537544
// because a split that arrives with budget is a raise wearing a new path.
@@ -561,7 +568,18 @@ export const CEILINGS = new Map([
561568
// first (measured: no sentence repeats in the file). Maintainer ruling
562569
// 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天
563570
// 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」.
564-
['skills/objectstack-data/rules/security.md', 2956],
571+
// 2956 -> 3693 (#26297): membership keys (`membershipKeys` on the roster
572+
// object — the three members, the refusals, the once-per-request system
573+
// read that fails closed, and the choice rule against owner scoping, a
574+
// position and a sharing rule), the position → `permissionSets` binding
575+
// (a namesake set is reported, never granted) and the `external`
576+
// membership tier for portal users (no baseline, explicit grants only) —
577+
// none of which the file taught, so an author wrote per-row policies and
578+
// expected a position-named set to grant itself. +737 tokens on a landed
579+
// 2956 at headroom 0, ceiling +737. No repeated passage in the file to
580+
// cut first (measured: no sentence repeats). Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天
581+
// 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」.
582+
['skills/objectstack-data/rules/security.md', 3693],
565583
// 3024 -> 3109 (2026-08-31 app-repo-principles raise, see the block above).
566584
// Severity Levels listed the three values and left the CHOICE unstated: a
567585
// block rests on a judgement a person made, so a machine-inferred signal — a
@@ -685,8 +703,22 @@ export const CEILINGS = new Map([
685703
// ceiling +201; nothing duplicated in the file to cut first. Same ruling as
686704
// the `rules/security.md` row above (2026-10-27, tap A:
687705
// 「A:先削重复,余额按实测提高」).
688-
['skills/objectstack-data/evals/hooks-security-seeds-datasources.json', 1095],
689-
['skills/objectstack-data/evals/objects-fields-relationships.json', 946],
706+
// 1095 -> 1404 (#26297): one regression case for the portal membership key
707+
// (a portal user reads only the rows of the accounts they belong to —
708+
// expected: `membershipKeys` on the roster object, `current_user.KEY` in the
709+
// policy, the set bound through a position's `permissionSets`, the
710+
// `external` tier), and the seed case gains the job-owned column declared
711+
// `applyOn: 'create'`. +309 tokens at headroom 0, ceiling +309; nothing
712+
// duplicated in the file to cut first. Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天
713+
// 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」.
714+
['skills/objectstack-data/evals/hooks-security-seeds-datasources.json', 1404],
715+
// 946 -> 1050 (#26297): the invoice-line case gains the owner opt-in
716+
// (`ownerMayEdit` on the controlled_by_parent line) and pins `added_by` as
717+
// `type: 'user'`, never a lookup to a `user` object. +104 tokens at
718+
// headroom 0, ceiling +104; nothing duplicated in the file to cut first.
719+
// Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天
720+
// 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」.
721+
['skills/objectstack-data/evals/objects-fields-relationships.json', 1050],
690722
['skills/objectstack-formula/evals/cel-predicates-formulas.json', 1046],
691723
['skills/objectstack-i18n/evals/bundles-locales-coverage.json', 1004],
692724
['skills/objectstack-platform/evals/config-plugins-ops.json', 1211],
@@ -707,9 +739,21 @@ export const CEILINGS = new Map([
707739
// The package deltas are the pointer lines and the four new file titles only.
708740
['skills/objectstack-automation/references/examples-flows.md', 1436],
709741
['skills/objectstack-automation/references/state-machines-and-approvals.md', 5569],
710-
['skills/objectstack-data/references/examples-objects.md', 1779],
742+
// 1779 -> 1769 (#26297): RE-LOCK at the landed count after a net cut — two
743+
// stale `{ type: 'lookup', reference: 'user' }` fields (an object that does
744+
// not exist) became `type: 'user'`; -10 tokens, the row follows the
745+
// measurement down, zero headroom.
746+
['skills/objectstack-data/references/examples-objects.md', 1769],
711747
['skills/objectstack-data/references/lint-rules.md', 970],
712-
['skills/objectstack-data/references/seeds.md', 1299],
748+
// 1299 -> 1566 (#26297): the `columns` dataset key and `applyOn: 'create'`
749+
// — a column a job or an admin owns after the first load is written on
750+
// create and left alone on every `upsert` replay; which modes it touches,
751+
// the refused key, the cross-link to the import door's `applyOn` — which
752+
// the option table lacked, so a replay reverted the app's own writes. +267
753+
// tokens at headroom 0, ceiling +267. No repeated passage to cut first
754+
// (measured: no sentence repeats). Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天
755+
// 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」.
756+
['skills/objectstack-data/references/seeds.md', 1566],
713757
['skills/objectstack-platform/references/bootstrap.md', 1351],
714758
['skills/objectstack-platform/references/operations.md', 3125],
715759
['skills/objectstack-platform/references/plugin-development.md', 3137],

‎skills/objectstack-data/evals/hooks-security-seeds-datasources.json‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,11 @@
3333
},
3434
{
3535
"id": 4,
36-
"prompt": "Seed the `country` reference table for every environment, and add demo `opportunity` rows (dev/test only) that close 45 days after install and are owned by whoever installs the package.",
37-
"expected_output": "Uses `defineSeed(Country, { externalId: 'code', mode: 'upsert', records })` for reference data and `defineSeed(Opportunity, { env: ['dev', 'test'], ... })` for demo rows, with lookups referenced by natural key and dynamic values as CEL: `close_date: cel`daysFromNow(45)``, `owner_id: cel`os.user.id``. Never `externalId: 'id'`, never `new Date()` / `Date.now()` (breaks build determinism), never `defineDataset` (the analytics layer), parents before children in the exported array.",
36+
"prompt": "Seed the `country` reference table for every environment, and add demo `opportunity` rows (dev/test only) that close 45 days after install and are owned by whoever installs the package. A nightly job advances each opportunity's `stage`; replaying the seed must not reset it.",
37+
"expected_output": "Uses `defineSeed(Country, { externalId: 'code', mode: 'upsert', records })` for reference data and `defineSeed(Opportunity, { env: ['dev', 'test'], ... })` for demo rows, with lookups referenced by natural key and dynamic values as CEL: `close_date: cel`daysFromNow(45)``, `owner_id: cel`os.user.id``. Never `externalId: 'id'`, never `new Date()` / `Date.now()` (breaks build determinism), never `defineDataset` (the analytics layer), parents before children in the exported array. The job-owned column is declared `columns: { stage: { applyOn: 'create' } }`, so an `upsert` replay leaves it as the row holds it.",
3838
"files": [],
3939
"assertions": {
40-
"must_contain": ["defineSeed", "externalId", "upsert", "env:", "daysFromNow"],
40+
"must_contain": ["defineSeed", "externalId", "upsert", "env:", "daysFromNow", "applyOn: 'create'"],
4141
"must_not_contain": ["new Date(", "defineDataset", "externalId: 'id'"]
4242
}
4343
},
@@ -50,6 +50,16 @@
5050
"must_contain": ["when:", "editable: false", "readable: true", "clm_contract.governing_law"],
5151
"must_not_contain": ["validations:", "readonlyWhen"]
5252
}
53+
},
54+
{
55+
"id": 6,
56+
"prompt": "Customers' staff sign in to our portal and may read only the orders of the accounts they belong to; staff of one account must never see another account's orders.",
57+
"expected_output": "Declares the set on the roster object that defines it: `account_member` (`user` field + `account` lookup) with `membershipKeys: { portal_account_ids: { userField: 'user', valueField: 'account' } }`; a portal permission set whose `rowLevelSecurity` reads it (`using: 'account in current_user.portal_account_ids'`), distributed by a position naming it in `permissionSets`; portal staff are invited on the `external` membership tier, outside the `everyone` baseline. Never a policy per account, never an owner stamp on every order.",
58+
"files": [],
59+
"assertions": {
60+
"must_contain": ["membershipKeys", "current_user.portal_account_ids", "userField", "valueField", "permissionSets", "external"],
61+
"must_not_contain": ["permissions: {"]
62+
}
5363
}
5464
]
5565
}

‎skills/objectstack-data/evals/objects-fields-relationships.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313
},
1414
{
1515
"id": 2,
16-
"prompt": "Model invoices with line items, and let invoices be tagged with many tags where each tagging records who added it and when.",
17-
"expected_output": "Line items: `invoice_line` with a `master_detail` field to `invoice`, `required: true`, `deleteBehavior: 'cascade'` (or `'restrict'` — `set_null` is refused on master_detail), `inlineEdit` on that relationship, and a `summary` roll-up field on the parent. Tagging carries attributes, so it is a junction object (`invoice_tag`) with two `lookup` fields plus `added_by` / `added_at` — not a `multiple: true` lookup (an array of ids) and not a `many_to_many` type, which does not exist.",
16+
"prompt": "Model invoices with line items, and let invoices be tagged with many tags where each tagging records who added it and when. A rep who owns a line item but can only read the invoice may still correct that line.",
17+
"expected_output": "Line items: `invoice_line` with a `master_detail` field to `invoice`, `required: true`, `deleteBehavior: 'cascade'` (or `'restrict'` — `set_null` is refused on master_detail), `inlineEdit` on that relationship, and a `summary` roll-up field on the parent. Tagging carries attributes, so it is a junction object (`invoice_tag`) with two `lookup` fields plus `added_by` / `added_at` — not a `multiple: true` lookup (an array of ids) and not a `many_to_many` type, which does not exist. The line object is `sharingModel: 'controlled_by_parent'` with `sharing: { controlledByParent: { ownerMayEdit: true } }` (by-id update only; every other write still needs master edit). `added_by` is `type: 'user'` — never a `lookup` to a `user` object, which does not exist.",
1818
"files": [],
1919
"assertions": {
20-
"must_contain": ["master_detail", "deleteBehavior", "inlineEdit", "summary", "lookup"],
21-
"must_not_contain": ["many_to_many", "deleteBehavior: 'set_null'"]
20+
"must_contain": ["master_detail", "deleteBehavior", "inlineEdit", "summary", "lookup", "ownerMayEdit", "type: 'user'"],
21+
"must_not_contain": ["many_to_many", "deleteBehavior: 'set_null'", "reference: 'user'"]
2222
}
2323
},
2424
{

‎skills/objectstack-data/references/examples-objects.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export default ObjectSchema.create({
3939
vat_id: { type: 'text', group: 'billing' },
4040
billing_address: { type: 'address', group: 'billing' },
4141
created_at: { type: 'datetime', readonly: true, group: 'system' },
42-
created_by: { type: 'lookup', reference: 'user', readonly: true, group: 'system' },
42+
created_by: { type: 'user', readonly: true, group: 'system' },
4343
},
4444
});
4545
```
@@ -85,7 +85,7 @@ export default ObjectSchema.create({
8585
]},
8686
account: { type: 'lookup', reference: 'account', required: true },
8787
contact: { type: 'lookup', reference: 'contact' },
88-
assigned_to: { type: 'lookup', reference: 'user' },
88+
assigned_to: { type: 'user' },
8989
due_date: { type: 'datetime' },
9090
},
9191
validations: [

‎skills/objectstack-data/references/seeds.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const SeedData = [statusSeed, categorySeed]; // parents first
5050
| `object` | derived | Auto-set from `objectDef.name` — never write manually |
5151
| `externalId` | `'name'` | Stable business key used for upsert / update lookup |
5252
| `mode` | `'upsert'` | Import strategy (see below) |
53+
| `columns` | — | Per-column write policy, keyed by field: `{ FIELD: { applyOn: 'create' } }` (below) |
5354
| `env` | `['prod','dev','test']` | Environments where the seed loads |
5455
| `records` | — | `Partial<Record<keyof object.fields, unknown>>[]` |
5556

@@ -65,6 +66,26 @@ Full Zod shape: `node_modules/@objectstack/spec/src/data/seed.zod.ts`.
6566
| `ignore` | Insert; silently skip duplicates. | Additive bootstrap |
6667
| `replace` ⚠️ | Delete everything, then insert. **Data loss.** | Cache / lookup tables only — never user data |
6768

69+
### Columns the app owns after the first load — `columns.FIELD.applyOn`
70+
71+
A seed is replayed (every dev-server boot, every re-publish) and an `upsert`
72+
replay re-asserts every column on the row it matched — right for master data,
73+
wrong for a column a job or an admin advances after the first load. Declare it
74+
`applyOn: 'create'`: written when the seed creates the row, left as the row
75+
holds it on every replay (an `update` dataset never writes it; `insert` /
76+
`ignore` / `replace` never update a matched row, so nothing changes there).
77+
Each key must be a field of the object or an injected system column —
78+
`defineSeed` refuses one that is not. Same values and meaning as an import
79+
mapping entry's `applyOn` (objectstack-api).
80+
81+
```typescript
82+
defineSeed(Invoice, {
83+
externalId: 'number',
84+
columns: { status: { applyOn: 'create' } }, // the payment job owns it after the first seed
85+
records: [{ number: 'INV-001', status: 'draft', amount: 1200 }],
86+
});
87+
```
88+
6889
### `externalId` selection
6990

7091
Pick a stable natural key. **Never use `id`** — UUIDs differ across

‎skills/objectstack-data/rules/relationships.md‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Guide for modeling relationships between objects using `lookup`, `master_detail`
1313
| Type | Lifecycle | Required | Sharing | Roll-ups | Use Case |
1414
|:-----|:----------|:---------|:--------|:---------|:---------|
1515
| `lookup` | Independent | Optional by default | Independent | Supported via `summary` | "Related to" |
16-
| `master_detail` | Coupled (cascade delete) | Forced only under `controlled_by_parent`; else lint-warned | Inherits parent | Supported via `summary` | "Owned by" |
16+
| `master_detail` | Coupled (cascade delete) | Forced only under `controlled_by_parent`; else lint-warned | Inherits parent; owner opt-in `ownerMayEdit` (below) | Supported via `summary` | "Owned by" |
1717
| `tree` | Self-reference | Optional — and so is `reference`, which when written must name this object itself; any other object is refused at parse, not lint-warned | N/A | Not available | Hierarchical |
1818

1919
## When to Use lookup vs master_detail
@@ -92,6 +92,23 @@ export default ObjectSchema.create({
9292
});
9393
```
9494

95+
#### Owner edits under `controlled_by_parent` — `ownerMayEdit`
96+
97+
A `controlled_by_parent` detail inherits the master's access: reads are scoped
98+
to accessible masters and **every write needs edit access on the master**. To
99+
let a detail row's owner (`owner_id`) update that row with only *read* access
100+
to the master, opt in on the detail:
101+
102+
```typescript
103+
sharingModel: 'controlled_by_parent',
104+
sharing: { controlledByParent: { ownerMayEdit: true } }, // by-id update only
105+
```
106+
107+
Nothing else widens — insert, delete, predicate (`multi: true`) updates and
108+
on-behalf-of writes still need master edit; the detail's own row policies
109+
still AND in. Refused at parse under any other `sharingModel` (the owner
110+
already writes there) and one level up (`sharing.ownerMayEdit`).
111+
95112
### Many-to-Many: Junction Object
96113

97114
```typescript

‎skills/objectstack-data/rules/security.md‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,69 @@ bridge (warns). The compiler resolves these
116116
`owner_only_deletes`) and the object's `sharingModel` (ADR-0056 D1) over
117117
hand-written policies for the common cases.
118118

119+
## Membership keys — a `current_user.KEY` set the roster object declares
120+
121+
Rows keyed on a **named record** (an account, a supplier) rather than on a
122+
person or a position: declare the set on the **roster** object whose rows
123+
define it — not a stamp on every data row, not one policy per record.
124+
125+
<!-- os:check -->
126+
```typescript
127+
import { ObjectSchema } from '@objectstack/spec/data';
128+
129+
// Roster: one row per (portal user, account)
130+
export default ObjectSchema.create({
131+
name: 'account_member',
132+
sharingModel: 'private',
133+
fields: {
134+
user: { type: 'user', required: true },
135+
account: { type: 'lookup', reference: 'account', required: true },
136+
active: { type: 'boolean', defaultValue: true },
137+
},
138+
membershipKeys: {
139+
portal_account_ids: { userField: 'user', valueField: 'account', filter: 'active == true' },
140+
},
141+
});
142+
```
143+
144+
```typescript
145+
// in the portal permission set — a policy reads the set with `in`
146+
rowLevelSecurity: [
147+
{ name: 'portal_orders', object: 'order', operation: 'select', using: 'account in current_user.portal_account_ids' },
148+
]
149+
```
150+
151+
- `userField`: the roster's user column (`user`, or a lookup to `sys_user`);
152+
`valueField`: the reference column whose values form the set (never the
153+
same column); `filter`: optional canonical CEL over the roster's own
154+
columns, no `current_user.*`.
155+
- One name per stack: a key on two objects, a kernel name (`id`,
156+
`organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`,
157+
`email`) and a key on an object extension are refused.
158+
- Read once per request as a system read in the caller's organization (no
159+
read on the roster needed), at most 1,000 values; more, a failed read or a
160+
`filter` that does not compile matches **no rows**. Whoever writes roster
161+
rows grants the key.
162+
- **Choose by what the rows are keyed on:** the person → owner scoping; a
163+
position → `positions` on the policy; a named record → a membership key;
164+
one record, once → a sharing rule or a manual share.
165+
166+
## Positions grant only what they name; portal users are `external`
167+
168+
- A position distributes **only** the sets its `permissionSets` names
169+
(ADR-0131 D3/D4): `{ name: 'sales_rep', label: 'Sales Rep', permissionSets:
170+
['sales_user'] }`. A set that merely shares the position's name is reported,
171+
never granted; a name matching no set confers nothing.
172+
- A portal or partner user is an organization member on the `external`
173+
membership tier (closed list `owner`, `admin`, `delegated_admin`, `member`,
174+
`external`, one per member —
175+
https://objectstack.ai/docs/permissions/authentication#membership-tiers-are-a-closed-list).
176+
The `everyone` baseline (`isDefault` sets) does not reach it, it sees no
177+
member directory and holds no business unit, and every org-wide default is
178+
owner-only for it: rows reach it **only** through explicit grants — a
179+
position or permission set whose row policies bound them (the key above),
180+
a sharing rule or a manual share.
181+
119182
## Field permissions — a row-conditioned edit lock (`when`)
120183

121184
A set's `fields` entry (`'OBJECT.FIELD': { readable, editable }`) may carry

0 commit comments

Comments
 (0)