diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index 4e3fdc98fe4..0b73fb6e9c0 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -541,7 +541,14 @@ export const CEILINGS = new Map([ // 3778 -> 3676: RE-LOCK at the landed count (#19715, card item 4). A 5-line // table of contents (+154 tokens) landed inside the row's 256 tokens of // headroom, so no raise; pinned AT the measurement — zero headroom. - ['skills/objectstack-data/rules/relationships.md', 3676], + // 3676 -> 3873 (#26297): `sharing.controlledByParent.ownerMayEdit` — the + // owner of a controlled_by_parent detail row may update it by id with read + // access to the master; what it does not widen; refused off a detail and + // one level up — which the file did not teach beside `controlled_by_parent`. + // +201 tokens on a landed 3672, 4 absorbed by headroom, ceiling +197. No + // repeated passage to cut first (measured: no sentence repeats). Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天 + // 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」. + ['skills/objectstack-data/rules/relationships.md', 3873], // 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. @@ -571,7 +578,18 @@ export const CEILINGS = new Map([ // first (measured: no sentence repeats in the file). Maintainer ruling // 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天 // 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」. - ['skills/objectstack-data/rules/security.md', 2956], + // 2956 -> 3693 (#26297): membership keys (`membershipKeys` on the roster + // object — the three members, the refusals, the once-per-request system + // read that fails closed, and the choice rule against owner scoping, a + // position and a sharing rule), the position → `permissionSets` binding + // (a namesake set is reported, never granted) and the `external` + // membership tier for portal users (no baseline, explicit grants only) — + // none of which the file taught, so an author wrote per-row policies and + // expected a position-named set to grant itself. +737 tokens on a landed + // 2956 at headroom 0, ceiling +737. No repeated passage in the file to + // cut first (measured: no sentence repeats). Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天 + // 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」. + ['skills/objectstack-data/rules/security.md', 3693], // 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 @@ -726,8 +744,22 @@ export const CEILINGS = new Map([ // ceiling +201; nothing duplicated in the file to cut first. Same ruling as // the `rules/security.md` row above (2026-10-27, tap A: // 「A:先削重复,余额按实测提高」). - ['skills/objectstack-data/evals/hooks-security-seeds-datasources.json', 1095], - ['skills/objectstack-data/evals/objects-fields-relationships.json', 946], + // 1095 -> 1404 (#26297): one regression case for the portal membership key + // (a portal user reads only the rows of the accounts they belong to — + // expected: `membershipKeys` on the roster object, `current_user.KEY` in the + // policy, the set bound through a position's `permissionSets`, the + // `external` tier), and the seed case gains the job-owned column declared + // `applyOn: 'create'`. +309 tokens at headroom 0, ceiling +309; nothing + // duplicated in the file to cut first. Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天 + // 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」. + ['skills/objectstack-data/evals/hooks-security-seeds-datasources.json', 1404], + // 946 -> 1050 (#26297): the invoice-line case gains the owner opt-in + // (`ownerMayEdit` on the controlled_by_parent line) and pins `added_by` as + // `type: 'user'`, never a lookup to a `user` object. +104 tokens at + // headroom 0, ceiling +104; nothing duplicated in the file to cut first. + // Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天 + // 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」. + ['skills/objectstack-data/evals/objects-fields-relationships.json', 1050], ['skills/objectstack-formula/evals/cel-predicates-formulas.json', 1046], // 1004 -> 1451 (2026-10-27, the objectstack-i18n fix card of the skills gap // audit): two eval prompts for the teaching that card adds — the `reports`, @@ -769,9 +801,21 @@ export const CEILINGS = new Map([ // absorbed by headroom, ceiling +18. Same maintainer ruling as the SKILL.md // row above (2026-10-27, verbatim and untranslated): 「A:先削重复,余额按实测提高」. ['skills/objectstack-automation/references/state-machines-and-approvals.md', 5587], - ['skills/objectstack-data/references/examples-objects.md', 1779], + // 1779 -> 1769 (#26297): RE-LOCK at the landed count after a net cut — two + // stale `{ type: 'lookup', reference: 'user' }` fields (an object that does + // not exist) became `type: 'user'`; -10 tokens, the row follows the + // measurement down, zero headroom. + ['skills/objectstack-data/references/examples-objects.md', 1769], ['skills/objectstack-data/references/lint-rules.md', 970], - ['skills/objectstack-data/references/seeds.md', 1299], + // 1299 -> 1566 (#26297): the `columns` dataset key and `applyOn: 'create'` + // — a column a job or an admin owns after the first load is written on + // create and left alone on every `upsert` replay; which modes it touches, + // the refused key, the cross-link to the import door's `applyOn` — which + // the option table lacked, so a replay reverted the app's own writes. +267 + // tokens at headroom 0, ceiling +267. No repeated passage to cut first + // (measured: no sentence repeats). Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天 + // 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」. + ['skills/objectstack-data/references/seeds.md', 1566], ['skills/objectstack-platform/references/bootstrap.md', 1351], ['skills/objectstack-platform/references/operations.md', 3125], ['skills/objectstack-platform/references/plugin-development.md', 3137], diff --git a/skills/objectstack-data/evals/hooks-security-seeds-datasources.json b/skills/objectstack-data/evals/hooks-security-seeds-datasources.json index 42a526f0eae..3b1381b32e6 100644 --- a/skills/objectstack-data/evals/hooks-security-seeds-datasources.json +++ b/skills/objectstack-data/evals/hooks-security-seeds-datasources.json @@ -33,11 +33,11 @@ }, { "id": 4, - "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.", - "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.", + "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.", + "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.", "files": [], "assertions": { - "must_contain": ["defineSeed", "externalId", "upsert", "env:", "daysFromNow"], + "must_contain": ["defineSeed", "externalId", "upsert", "env:", "daysFromNow", "applyOn: 'create'"], "must_not_contain": ["new Date(", "defineDataset", "externalId: 'id'"] } }, @@ -50,6 +50,16 @@ "must_contain": ["when:", "editable: false", "readable: true", "clm_contract.governing_law"], "must_not_contain": ["validations:", "readonlyWhen"] } + }, + { + "id": 6, + "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.", + "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.", + "files": [], + "assertions": { + "must_contain": ["membershipKeys", "current_user.portal_account_ids", "userField", "valueField", "permissionSets", "external"], + "must_not_contain": ["permissions: {"] + } } ] } diff --git a/skills/objectstack-data/evals/objects-fields-relationships.json b/skills/objectstack-data/evals/objects-fields-relationships.json index 1fcdaaf6e22..35e22691b77 100644 --- a/skills/objectstack-data/evals/objects-fields-relationships.json +++ b/skills/objectstack-data/evals/objects-fields-relationships.json @@ -13,12 +13,12 @@ }, { "id": 2, - "prompt": "Model invoices with line items, and let invoices be tagged with many tags where each tagging records who added it and when.", - "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.", + "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.", + "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.", "files": [], "assertions": { - "must_contain": ["master_detail", "deleteBehavior", "inlineEdit", "summary", "lookup"], - "must_not_contain": ["many_to_many", "deleteBehavior: 'set_null'"] + "must_contain": ["master_detail", "deleteBehavior", "inlineEdit", "summary", "lookup", "ownerMayEdit", "type: 'user'"], + "must_not_contain": ["many_to_many", "deleteBehavior: 'set_null'", "reference: 'user'"] } }, { diff --git a/skills/objectstack-data/references/examples-objects.md b/skills/objectstack-data/references/examples-objects.md index 01e553201a8..a5e21cf0fd4 100644 --- a/skills/objectstack-data/references/examples-objects.md +++ b/skills/objectstack-data/references/examples-objects.md @@ -39,7 +39,7 @@ export default ObjectSchema.create({ vat_id: { type: 'text', group: 'billing' }, billing_address: { type: 'address', group: 'billing' }, created_at: { type: 'datetime', readonly: true, group: 'system' }, - created_by: { type: 'lookup', reference: 'user', readonly: true, group: 'system' }, + created_by: { type: 'user', readonly: true, group: 'system' }, }, }); ``` @@ -85,7 +85,7 @@ export default ObjectSchema.create({ ]}, account: { type: 'lookup', reference: 'account', required: true }, contact: { type: 'lookup', reference: 'contact' }, - assigned_to: { type: 'lookup', reference: 'user' }, + assigned_to: { type: 'user' }, due_date: { type: 'datetime' }, }, validations: [ diff --git a/skills/objectstack-data/references/seeds.md b/skills/objectstack-data/references/seeds.md index a742d868819..57b592d18aa 100644 --- a/skills/objectstack-data/references/seeds.md +++ b/skills/objectstack-data/references/seeds.md @@ -50,6 +50,7 @@ export const SeedData = [statusSeed, categorySeed]; // parents first | `object` | derived | Auto-set from `objectDef.name` — never write manually | | `externalId` | `'name'` | Stable business key used for upsert / update lookup | | `mode` | `'upsert'` | Import strategy (see below) | +| `columns` | — | Per-column write policy, keyed by field: `{ FIELD: { applyOn: 'create' } }` (below) | | `env` | `['prod','dev','test']` | Environments where the seed loads | | `records` | — | `Partial>[]` | @@ -65,6 +66,26 @@ Full Zod shape: `node_modules/@objectstack/spec/src/data/seed.zod.ts`. | `ignore` | Insert; silently skip duplicates. | Additive bootstrap | | `replace` ⚠️ | Delete everything, then insert. **Data loss.** | Cache / lookup tables only — never user data | +### Columns the app owns after the first load — `columns.FIELD.applyOn` + +A seed is replayed (every dev-server boot, every re-publish) and an `upsert` +replay re-asserts every column on the row it matched — right for master data, +wrong for a column a job or an admin advances after the first load. Declare it +`applyOn: 'create'`: written when the seed creates the row, left as the row +holds it on every replay (an `update` dataset never writes it; `insert` / +`ignore` / `replace` never update a matched row, so nothing changes there). +Each key must be a field of the object or an injected system column — +`defineSeed` refuses one that is not. Same values and meaning as an import +mapping entry's `applyOn` (objectstack-api). + +```typescript +defineSeed(Invoice, { + externalId: 'number', + columns: { status: { applyOn: 'create' } }, // the payment job owns it after the first seed + records: [{ number: 'INV-001', status: 'draft', amount: 1200 }], +}); +``` + ### `externalId` selection Pick a stable natural key. **Never use `id`** — UUIDs differ across diff --git a/skills/objectstack-data/rules/relationships.md b/skills/objectstack-data/rules/relationships.md index 232e7c6a082..fffc0ae37b1 100644 --- a/skills/objectstack-data/rules/relationships.md +++ b/skills/objectstack-data/rules/relationships.md @@ -13,7 +13,7 @@ Guide for modeling relationships between objects using `lookup`, `master_detail` | Type | Lifecycle | Required | Sharing | Roll-ups | Use Case | |:-----|:----------|:---------|:--------|:---------|:---------| | `lookup` | Independent | Optional by default | Independent | Supported via `summary` | "Related to" | -| `master_detail` | Coupled (cascade delete) | Forced only under `controlled_by_parent`; else lint-warned | Inherits parent | Supported via `summary` | "Owned by" | +| `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" | | `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 | ## When to Use lookup vs master_detail @@ -92,6 +92,23 @@ export default ObjectSchema.create({ }); ``` +#### Owner edits under `controlled_by_parent` — `ownerMayEdit` + +A `controlled_by_parent` detail inherits the master's access: reads are scoped +to accessible masters and **every write needs edit access on the master**. To +let a detail row's owner (`owner_id`) update that row with only *read* access +to the master, opt in on the detail: + +```typescript +sharingModel: 'controlled_by_parent', +sharing: { controlledByParent: { ownerMayEdit: true } }, // by-id update only +``` + +Nothing else widens — insert, delete, predicate (`multi: true`) updates and +on-behalf-of writes still need master edit; the detail's own row policies +still AND in. Refused at parse under any other `sharingModel` (the owner +already writes there) and one level up (`sharing.ownerMayEdit`). + ### Many-to-Many: Junction Object ```typescript diff --git a/skills/objectstack-data/rules/security.md b/skills/objectstack-data/rules/security.md index 1e4ca33b44c..5f5834ddd39 100644 --- a/skills/objectstack-data/rules/security.md +++ b/skills/objectstack-data/rules/security.md @@ -116,6 +116,69 @@ bridge (warns). The compiler resolves these `owner_only_deletes`) and the object's `sharingModel` (ADR-0056 D1) over hand-written policies for the common cases. +## Membership keys — a `current_user.KEY` set the roster object declares + +Rows keyed on a **named record** (an account, a supplier) rather than on a +person or a position: declare the set on the **roster** object whose rows +define it — not a stamp on every data row, not one policy per record. + + +```typescript +import { ObjectSchema } from '@objectstack/spec/data'; + +// Roster: one row per (portal user, account) +export default ObjectSchema.create({ + name: 'account_member', + sharingModel: 'private', + fields: { + user: { type: 'user', required: true }, + account: { type: 'lookup', reference: 'account', required: true }, + active: { type: 'boolean', defaultValue: true }, + }, + membershipKeys: { + portal_account_ids: { userField: 'user', valueField: 'account', filter: 'active == true' }, + }, +}); +``` + +```typescript +// in the portal 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' }, +] +``` + +- `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); `filter`: optional canonical CEL over the roster's own + columns, no `current_user.*`. +- One name per stack: a key on two objects, a kernel name (`id`, + `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, + `email`) 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; + one record, once → a sharing rule or a manual share. + +## Positions grant only what they name; portal users are `external` + +- A position distributes **only** the sets its `permissionSets` names + (ADR-0131 D3/D4): `{ name: 'sales_rep', label: 'Sales Rep', permissionSets: + ['sales_user'] }`. A set that merely shares the position's name is reported, + never granted; a name matching no set confers nothing. +- A portal or partner user is an organization member on the `external` + membership tier (closed list `owner`, `admin`, `delegated_admin`, `member`, + `external`, one per member — + https://objectstack.ai/docs/permissions/authentication#membership-tiers-are-a-closed-list). + The `everyone` baseline (`isDefault` sets) does not reach it, it sees no + member directory and holds no business unit, and every org-wide default is + owner-only for it: rows reach it **only** through explicit grants — a + position or permission set whose row policies bound them (the key above), + a sharing rule or a manual share. + ## Field permissions — a row-conditioned edit lock (`when`) A set's `fields` entry (`'OBJECT.FIELD': { readable, editable }`) may carry