Skip to content

Commit bb60b86

Browse files
docs(skills): objectstack-data teaches membership keys, ownerMayEdit, create-only seed columns, the position-to-set binding and the external tier (#26416)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ82udicoCE8dxKwR8vrMoM9W)_ Fixes #26297 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:`skills/objectstack-data` 教五件 18.x 已发布、技能没教的事:① `rules/security.md` 新增「成员键」一节(`membershipKeys` 声明在名册对象上,`userField` / `valueField` / `filter` 三个成员、拒收规则、每请求一次的系统读且失败即零行、与 owner 作用域 / 岗位 / 共享规则的选择规则)和「岗位只授它点名的集合;门户用户是 `external`」一节;② `rules/relationships.md` 在 `controlled_by_parent` 旁教 `sharing.controlledByParent.ownerMayEdit`;③ `references/seeds.md` 补 `columns` 选项行和 `applyOn: 'create'` 小节;④ `references/examples-objects.md` 两处过时的 `{ type: 'lookup', reference: 'user' }` 改为 `type: 'user'`;⑤ eval 加门户成员题一道,种子题与发票行题各扩一句。六行 token 上限按实测调整,裁决原文写在行旁。 - **为什么改**:AI 作者建门户或按名册限行的应用时,没有这些规则就会逐行手写策略、以为岗位同名集合会自动授予、种子重放覆盖掉管理员的修改;`reference: 'user'` 的例子会被原样抄进一个不存在的对象。 - **风险与回滚**:只改发布技能包的文字、两个 eval 夹具和棘轮脚本的六行上限,不改运行时;回滚即 revert 本 PR(上限行随之回落)。 - **先看**:① `skills/objectstack-data/rules/security.md` 新两节(成员键示例块与四条要点、岗位/`external` 两条);② `scripts/check-skills-token-ratchet.mjs` 六行上限及其注释;③ `evals/hooks-security-seeds-datasources.json` 第 6 题。 - **两分钟验证**:`node scripts/check-skills-token-ratchet.mjs` → 末行 `81 authored bundle file(s) within their ceilings`,其中 `rules/security.md is 3693 tokens (ceiling 3693; headroom 0)`;`pnpm --filter @objectstack/spec run check:skill-examples` → 新标记的名册对象块随 265 个示例通过类型检查。 - **需要你做的**:Tier H(`skills/**`),请 approve 或直接 Merge when ready。 ## What changes Card #26297, the six Measured rows (D2–D7) and nothing wider: 1. **`rules/security.md` — "Membership keys"** (after the RLS section, whose placeholder table already named `current_user.KEY` without saying where it comes from): the choice statement first (rows keyed on a *named record* → a key on the **roster** object, never a stamp on every row or a policy per record), a type-checked roster example (`account_member` with `membershipKeys.portal_account_ids: { userField: 'user', valueField: 'account', filter: 'active == true' }`) and the policy fragment that reads it with `in`; then four bullets — the three members and what each may name; the refusals (one name per stack, the kernel's own names, no contribution through an object extension); the once-per-request system read (no read on the roster needed, 1,000-value cap, a larger set / failed read / non-compiling `filter` matches no rows, whoever writes roster rows grants the key); and the choice rule against owner scoping, a position's `positions` and a sharing rule. 2. **`rules/security.md` — "Positions grant only what they name; portal users are `external`"**: a position distributes only the sets its `permissionSets` names (ADR-0131 D3/D4; a namesake set is reported, never granted; a name matching no set confers nothing), with the one-line position shape; the `external` membership tier for portal and partner users (closed list, one tier per member, docs anchor), which the `everyone` baseline does not reach, sees no member directory, holds no business unit, and for which every org-wide default is owner-only — rows reach it only through explicit grants (a position or set whose row policies bound them, a sharing rule, a manual share). `check:role-word` (ADR-0090 D3) refuses the reserved word in this tree, so the prose says "membership tier" where the spec's constant file says otherwise. 3. **`rules/relationships.md` — "Owner edits under `controlled_by_parent` — `ownerMayEdit`"** under the master_detail pattern, plus "owner opt-in `ownerMayEdit` (below)" in the type table's Sharing cell (the card's row at L16): what a detail inherits, the two-line opt-in, what it does not widen (insert, delete, predicate updates, on-behalf-of writes; the detail's own row policies still AND in), refused at parse off a `controlled_by_parent` detail and one level up. 4. **`references/seeds.md`**: a `columns` row in the `Seed` fields table and a section "Columns the app owns after the first load — `columns.FIELD.applyOn`" after the import modes: why a replay reverts app-owned columns, `applyOn: 'create'` semantics per mode, the refused key, and the cross-link to the import mapping entry's `applyOn` (taught in objectstack-api; not restated). 5. **`references/examples-objects.md`** L42 and L88: `{ type: 'lookup', reference: 'user', … }` → `{ type: 'user', … }` (the 18.x form; a `user` field's target is fixed to `sys_user` and a foreign `reference` is refused). Both blocks are `os:check`-marked and still type-check. This is the one stale text the card names, and the only removal. 6. **Evals**, in each file's existing inline-array shape: `hooks-security-seeds-datasources.json` eval 6 (the card's acceptance prompt: portal staff read only the orders of the accounts they belong to — expected `membershipKeys` on the roster, `current_user.portal_account_ids` in the policy, `userField` / `valueField`, the set bound through a position's `permissionSets`, the `external` tier; `must_not_contain` pins the object-level `permissions:` key), and eval 4 gains the job-owned column (`applyOn: 'create'`); `objects-fields-relationships.json` eval 2 gains the owner opt-in (`ownerMayEdit`) and pins `added_by` as `type: 'user'` with `must_not_contain: "reference: 'user'"`. `SKILL.md` is untouched: its `controlled_by_parent` row (L76) and the security pointer line (L48) sit at 3 tokens of headroom, and the card's Wanted names `rules/relationships.md` for `ownerMayEdit`; `references/_index.md` is generator-owned and unchanged (the spec files involved are already listed). **Token-ratchet rows** (`scripts/check-skills-token-ratchet.mjs`), per the maintainer's ruling on the seat's decision card 「skills 修复卡的教学和 eval 都撞 token 天花板,怎么付?」, tap **A** (2026-10-27 01:21Z), verbatim and untranslated: 「A:先削重复,余额按实测提高」. Step one, cut duplicates first: measured on `origin/main` `c51aed4f`, none of the four `.md` files carries a repeated sentence (a sentence-level scan of each file: 0 repeats in `security.md`, `relationships.md`, `seeds.md`; the only repeat in `examples-objects.md` is the `os:check` marker + import line that opens each type-checked block), and neither eval file repeats a case — so there was nothing to cut and no surviving-home list to give. Step two, each touched row follows its measurement (`ceil(utf8 bytes / 4)`): `rules/security.md` 2956 → 3693 (+737 at headroom 0); `rules/relationships.md` 3676 → 3873 (+201 landed on 3672, 4 absorbed by headroom, ceiling +197); `references/seeds.md` 1299 → 1566 (+267 at headroom 0); `references/examples-objects.md` 1779 → **1769** (the two stale fields are a net cut of 10 tokens; the row follows the measurement down); `evals/hooks-security-seeds-datasources.json` 1095 → 1404 (+309); `evals/objects-fields-relationships.json` 946 → 1050 (+104). Every row re-pinned at zero headroom; each comment quotes the tap. Only `objectstack-data` rows are touched. **Two readings** (lines, and tokens as the ratchet counts them): | File | Lines before → after | Tokens before → after | |:--|:--|:--| | `rules/security.md` | 249 → 312 (+63) | 2956 → 3693 (+737) | | `rules/relationships.md` | 401 → 418 (+17) | 3672 → 3873 (+201) | | `references/seeds.md` | 142 → 163 (+21) | 1299 → 1566 (+267) | | `references/examples-objects.md` | 197 → 197 (0) | 1779 → 1769 (−10) | | `evals/hooks-security-seeds-datasources.json` (not .md, reported for the raise) | 55 → 65 (+10) | 1095 → 1404 (+309) | | `evals/objects-fields-relationships.json` (not .md, reported for the raise) | 45 → 45 (0) | 946 → 1050 (+104) | | **Whole package** (`SKILL.md` + `references/**/*.md` + `rules/**/*.md`, 14 files, raw file bytes) | **3736 → 3837 (+101)** | **39497 → 40692 (+1195)** | Removed: the two stale `reference: 'user'` spellings only. Sequencing (zero verbs beside these numbers): #26393 landed the `when` section this PR's membership text sits next to, and is not restated; #25640 owns line 1 of `SKILL.md`, which this PR does not touch; #26392 taught the import door's `applyOn` in objectstack-api, which `seeds.md` cross-links. `skip-changeset`: the published skills packages carry no `package.json`; nothing is released from any package. ## Why An AI author building a portal or a roster-scoped app from this skill had no rule for a set keyed on a named record, so it wrote per-row policies by hand or stamped an owner on every row; it expected a position-named set to grant itself; it let a seed replay revert a status an admin or a job had advanced; and it copied `reference: 'user'` into a lookup to an object that does not exist. Every key here is already carried by `@objectstack/spec` 18.x (`Metadata-protocol: none`); the gap was the teaching. ## Tests - `node scripts/check-skills-token-ratchet.mjs` — exit 0: `81 authored bundle file(s) within their ceilings`, the six touched rows at headroom 0. - `node scripts/check-skills-token-ratchet.mjs --self-test` — exit 0, `79 cases pass`. - `pnpm --filter @objectstack/spec run check:skill-docs`, `check:skill-refs`, `check:skill-examples` — see Verification; the new roster-object block in `security.md` is `os:check`-marked and type-checks (the policy fragment beside it is unmarked, like the file's RLS block). - Evals are fixtures (the repo has no eval runner): every `must_contain` string of the new and extended cases occurs in its expected output; the `must_not_contain` strings were checked against the expected outputs by hand. ## Acceptance notes - `SKILL.md` L48 (the security pointer line) does not name membership keys and L76 does not point at `ownerMayEdit`; both would need a raise of the SKILL.md row for a few tokens. Not filed: observation only. - `evals/README.md` (headroom 1) was not updated to name the new topics. Not filed: observation only. - The dispatch order's "`skills/**` inside the package's ceilings" cannot hold at the measured headroom (0 on five of the six files); the seat's claim widened the surface to the touched ratchet rows under the ruling above. - The PR body's first two lines are the harness-required attribution block (the os-dev exception); `Fixes #26297` follows immediately. ## Verification Gate list derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the final tree (base `c51aed4f`, head `e63f6ec2`), each run locally with the exit code captured before any pipe; `--ran` reconciliation below. No commit followed the gate runs, so every reading is of `e63f6ec2`. **Reconciliation** (`node scripts/pm/dispatch-gates.mjs --ran ran.list --repo objectstack-ai/objectstack`, exit 0): `52 derived, 51 run, 1 NOT-MEASURED, 0 UNRUN`. The one NOT MEASURED is `pnpm check:dual-build-cjs-loads` (exit 3: its prerequisite is a whole-repo build; this diff touches no package source, and CI's `Lint & Repo Gates` runs it — a declared narrowing). Two readings were taken twice: `pnpm check:dts-closure` exited 1 on a first pass that raced the still-running closure build (plugin-auth mid-emit, "1 built package missing declaration files") and exits 0 re-run after the build (`38 built package(s) swept - 160/160 declared declaration file(s) present`); `node scripts/check-comment-mask-corpus.mjs` was run alone after `check:skill-examples` (exit 0, `18297 files, 0 disagree`). The closures for `check:skill-examples` and `check:doc-formula-expressions` (`@objectstack/client-react...` and `@objectstack/lint...`) were built under `scripts/pm/os-verify-lock.sh` (slot `issue-26297`, VERDICT command-exit 0, 251 s held). `check:pm-dispatch-gates` ran detached on the final head: fast tier, 1901 cases pass, 299 s, exit 0. <details><summary>Every derived command with its exit code on e63f6ec (53 lines: the 52 derived, plus `check:skill-docs` and `check:skill-examples` the dispatch order asked for)</summary> - `node scripts/check-ci-filter-parity.mjs` — exit 0 - `node scripts/check-closing-keyword-parity.mjs` — exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` — exit 0 - `node scripts/check-declaration-mirrors.mjs` — exit 0 - `node scripts/check-declaration-mirrors.mjs --self-test` — exit 0 - `node scripts/check-doc-route-spelling.mjs --advisory` — exit 0 - `node scripts/check-doc-route-spelling.mjs --self-test` — exit 0 - `node scripts/check-dts-emitted.mjs --self-test` — exit 0 - `node scripts/check-scripts-symbol-anchors.mjs` — exit 0 - `node scripts/check-scripts-symbol-anchors.mjs --self-test` — exit 0 - `node scripts/check-self-test-wired.mjs` — exit 0 - `node scripts/check-self-test-wired.mjs --self-test` — exit 0 - `node scripts/check-self-test-workflow-commands.mjs` — exit 0 - `node scripts/check-self-test-workflow-commands.mjs --self-test` — exit 0 - `node scripts/check-skills-token-ratchet.mjs` — exit 0 - `node scripts/check-skills-token-ratchet.mjs --self-test` — exit 0 - `node scripts/check-whole-set-label-write.mjs` — exit 0 - `node scripts/check-whole-set-label-write.mjs --self-test` — exit 0 - `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` — exit 0 - `node scripts/ci/plan-test-core-shards.mjs --self-test` — exit 0 - `node scripts/ci/prune-turbo-cache.mjs --self-test` — exit 0 - `node scripts/pm/bare-root-worklist.mjs --self-test` — exit 0 - `pnpm --filter @objectstack/spec run check:skill-refs` — exit 0 - `pnpm check:agent-test-spelling` — exit 0 - `pnpm check:bash32-floor` — exit 0 - `pnpm check:cli-command-ids` — exit 0 - `pnpm check:corpus-claim-drift` — exit 0 - `pnpm check:cross-package-test-inputs` — exit 0 - `pnpm check:doc-authoring` — exit 0 - `pnpm check:driver-memory-census` — exit 0 - `pnpm check:dts-closure` — exit 0 - `pnpm check:entry-guard` — exit 0 - `pnpm check:gitlink-declared` — exit 0 - `pnpm check:nul-bytes` — exit 0 - `pnpm check:parse-guard` — exit 0 - `pnpm check:pm-governed-merges` — exit 0 - `pnpm check:pm-skill-id-lint` — exit 0 - `pnpm check:pnpm-filter-targets` — exit 0 - `pnpm check:pnpm-flag-spellings` — exit 0 - `pnpm check:ratchet-remedy-authority` — exit 0 - `pnpm check:refd-timer-probe` — exit 0 - `pnpm check:role-word` — exit 0 - `pnpm check:skill-compatibility` — exit 0 - `pnpm check:skill-frame-sync` — exit 0 - `pnpm check:skill-identifier-liveness` — exit 0 - `pnpm check:sourcemap-no-sources-content` — exit 0 - `pnpm check:watch-hint-literal` — exit 0 - `pnpm --filter @objectstack/spec run check:skill-docs` — exit 0 - `pnpm --filter @objectstack/spec run check:skill-examples` — exit 0 - `pnpm --filter @objectstack/lint run check:doc-formula-expressions` — exit 0 - `pnpm check:dual-build-cjs-loads` — NOT MEASURED (exit 3, PREREQUISITE NOT MET: whole-repo build) - `pnpm check:lean-entry-closure` — exit 0 - `node scripts/check-comment-mask-corpus.mjs` — exit 0 - `pnpm check:pm-dispatch-gates` — exit 0 </details> <details><summary>Token ratchet on e63f6ec</summary> - `skills/objectstack-data/rules/security.md is 3693 tokens (ceiling 3693; headroom 0)` - `skills/objectstack-data/rules/relationships.md is 3873 tokens (ceiling 3873; headroom 0)` - `skills/objectstack-data/references/seeds.md is 1566 tokens (ceiling 1566; headroom 0)` - `skills/objectstack-data/references/examples-objects.md is 1769 tokens (ceiling 1769; headroom 0)` - `skills/objectstack-data/evals/hooks-security-seeds-datasources.json is 1404 tokens (ceiling 1404; headroom 0)` - `skills/objectstack-data/evals/objects-fields-relationships.json is 1050 tokens (ceiling 1050; headroom 0)` - `81 authored bundle file(s) within their ceilings; 10 generator-owned file(s) measured, not ratcheted.` - `--self-test`: `79 cases pass.` </details> <details><summary>check:skill-examples on e63f6ec</summary> `265 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them` (264 on `main`; the 265th is the new `os:check`-marked roster object in `security.md`). </details> ## Human Minimum Each item is one rule from [CONTRIBUTING.md › Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum). - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No schema change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's run (repo-wide scan). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. Nothing publishes (`skip-changeset`); both declaration lines are filled in above. - [x] 6. `packages/spec` not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block and the quoted ruling are the required exceptions). --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbXTWvHG6MXFD7Gywf2)_ Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 26e7c75 commit bb60b86

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
@@ -561,7 +561,14 @@ export const CEILINGS = new Map([
561561
// 3778 -> 3676: RE-LOCK at the landed count (#19715, card item 4). A 5-line
562562
// table of contents (+154 tokens) landed inside the row's 256 tokens of
563563
// headroom, so no raise; pinned AT the measurement — zero headroom.
564-
['skills/objectstack-data/rules/relationships.md', 3676],
564+
// 3676 -> 3873 (#26297): `sharing.controlledByParent.ownerMayEdit` — the
565+
// owner of a controlled_by_parent detail row may update it by id with read
566+
// access to the master; what it does not widen; refused off a detail and
567+
// one level up — which the file did not teach beside `controlled_by_parent`.
568+
// +201 tokens on a landed 3672, 4 absorbed by headroom, ceiling +197. No
569+
// repeated passage to cut first (measured: no sentence repeats). Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天
570+
// 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」.
571+
['skills/objectstack-data/rules/relationships.md', 3873],
565572
// NEW FILE (#14296 item 1 = A, condition (b)): the entry's Security & Access
566573
// Control block moved here whole. Pinned at its landed count — no headroom,
567574
// because a split that arrives with budget is a raise wearing a new path.
@@ -591,7 +598,18 @@ export const CEILINGS = new Map([
591598
// first (measured: no sentence repeats in the file). Maintainer ruling
592599
// 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天
593600
// 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」.
594-
['skills/objectstack-data/rules/security.md', 2956],
601+
// 2956 -> 3693 (#26297): membership keys (`membershipKeys` on the roster
602+
// object — the three members, the refusals, the once-per-request system
603+
// read that fails closed, and the choice rule against owner scoping, a
604+
// position and a sharing rule), the position → `permissionSets` binding
605+
// (a namesake set is reported, never granted) and the `external`
606+
// membership tier for portal users (no baseline, explicit grants only) —
607+
// none of which the file taught, so an author wrote per-row policies and
608+
// expected a position-named set to grant itself. +737 tokens on a landed
609+
// 2956 at headroom 0, ceiling +737. No repeated passage in the file to
610+
// cut first (measured: no sentence repeats). Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天
611+
// 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」.
612+
['skills/objectstack-data/rules/security.md', 3693],
595613
// 3024 -> 3109 (2026-08-31 app-repo-principles raise, see the block above).
596614
// Severity Levels listed the three values and left the CHOICE unstated: a
597615
// block rests on a judgement a person made, so a machine-inferred signal — a
@@ -746,8 +764,22 @@ export const CEILINGS = new Map([
746764
// ceiling +201; nothing duplicated in the file to cut first. Same ruling as
747765
// the `rules/security.md` row above (2026-10-27, tap A:
748766
// 「A:先削重复,余额按实测提高」).
749-
['skills/objectstack-data/evals/hooks-security-seeds-datasources.json', 1095],
750-
['skills/objectstack-data/evals/objects-fields-relationships.json', 946],
767+
// 1095 -> 1404 (#26297): one regression case for the portal membership key
768+
// (a portal user reads only the rows of the accounts they belong to —
769+
// expected: `membershipKeys` on the roster object, `current_user.KEY` in the
770+
// policy, the set bound through a position's `permissionSets`, the
771+
// `external` tier), and the seed case gains the job-owned column declared
772+
// `applyOn: 'create'`. +309 tokens at headroom 0, ceiling +309; nothing
773+
// duplicated in the file to cut first. Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天
774+
// 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」.
775+
['skills/objectstack-data/evals/hooks-security-seeds-datasources.json', 1404],
776+
// 946 -> 1050 (#26297): the invoice-line case gains the owner opt-in
777+
// (`ownerMayEdit` on the controlled_by_parent line) and pins `added_by` as
778+
// `type: 'user'`, never a lookup to a `user` object. +104 tokens at
779+
// headroom 0, ceiling +104; nothing duplicated in the file to cut first.
780+
// Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天
781+
// 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」.
782+
['skills/objectstack-data/evals/objects-fields-relationships.json', 1050],
751783
['skills/objectstack-formula/evals/cel-predicates-formulas.json', 1046],
752784
// 1004 -> 1451 (2026-10-27, the objectstack-i18n fix card of the skills gap
753785
// audit): two eval prompts for the teaching that card adds — the `reports`,
@@ -799,9 +831,21 @@ export const CEILINGS = new Map([
799831
// absorbed by headroom, ceiling +18. Same maintainer ruling as the SKILL.md
800832
// row above (2026-10-27, verbatim and untranslated): 「A:先削重复,余额按实测提高」.
801833
['skills/objectstack-automation/references/state-machines-and-approvals.md', 5587],
802-
['skills/objectstack-data/references/examples-objects.md', 1779],
834+
// 1779 -> 1769 (#26297): RE-LOCK at the landed count after a net cut — two
835+
// stale `{ type: 'lookup', reference: 'user' }` fields (an object that does
836+
// not exist) became `type: 'user'`; -10 tokens, the row follows the
837+
// measurement down, zero headroom.
838+
['skills/objectstack-data/references/examples-objects.md', 1769],
803839
['skills/objectstack-data/references/lint-rules.md', 970],
804-
['skills/objectstack-data/references/seeds.md', 1299],
840+
// 1299 -> 1566 (#26297): the `columns` dataset key and `applyOn: 'create'`
841+
// — a column a job or an admin owns after the first load is written on
842+
// create and left alone on every `upsert` replay; which modes it touches,
843+
// the refused key, the cross-link to the import door's `applyOn` — which
844+
// the option table lacked, so a replay reverted the app's own writes. +267
845+
// tokens at headroom 0, ceiling +267. No repeated passage to cut first
846+
// (measured: no sentence repeats). Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天
847+
// 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」.
848+
['skills/objectstack-data/references/seeds.md', 1566],
805849
['skills/objectstack-platform/references/bootstrap.md', 1351],
806850
['skills/objectstack-platform/references/operations.md', 3125],
807851
['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)