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
56 changes: 50 additions & 6 deletions scripts/check-skills-token-ratchet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'"]
}
},
Expand All @@ -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: {"]
}
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'"]
}
},
{
Expand Down
4 changes: 2 additions & 2 deletions skills/objectstack-data/references/examples-objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
},
});
```
Expand Down Expand Up @@ -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: [
Expand Down
21 changes: 21 additions & 0 deletions skills/objectstack-data/references/seeds.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<keyof object.fields, unknown>>[]` |

Expand All @@ -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
Expand Down
19 changes: 18 additions & 1 deletion skills/objectstack-data/rules/relationships.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions skills/objectstack-data/rules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- os:check -->
```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
Expand Down
Loading