diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index 2811c044162..d8148a2e965 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -552,7 +552,16 @@ export const CEILINGS = new Map([ // 2026-10-24 (project decision list item 9), verbatim and untranslated, the // line for item 8 of the same message elided: // 「9. 技能文档 security.md 的 token 上限 2543 → 2563 … 同意」. - ['skills/objectstack-data/rules/security.md', 2563], + // 2563 -> 2956 (#26179): the field permission row condition `when` — the + // choice rule (a lock for everyone is a validation; a lock that depends on + // who edits is `when` on that set's field entries), one example and the + // four boundaries — which the file did not teach, so an author locked the + // fields for every user. +395 tokens on a landed 2561, 2 absorbed by + // headroom, ceiling +393. The file carries no duplicated passage to cut + // 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], // 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 @@ -651,7 +660,15 @@ export const CEILINGS = new Map([ ['skills/objectstack-ai/evals/skills-tools-knowledge.json', 1074], ['skills/objectstack-api/evals/endpoints-auth-routes.json', 1042], ['skills/objectstack-automation/evals/flows-triggers-approvals.json', 1255], - ['skills/objectstack-data/evals/hooks-security-seeds-datasources.json', 894], + // 894 -> 1095 (#26179): one regression case for the field permission `when` + // (the contract prompt: lock the legal terms for finance from approval on, + // legal keeps the edit) — expected: `when` on the finance set's entries with + // `readable: true`, and NO validation rule or `readonlyWhen` locking the + // fields for every user. +201 tokens, zero headroom to absorb any of it, + // 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], ['skills/objectstack-formula/evals/cel-predicates-formulas.json', 1046], ['skills/objectstack-i18n/evals/bundles-locales-coverage.json', 1004], diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 468a5b3fd22..917b77131f0 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -218,7 +218,8 @@ export const Invoice = ObjectSchema.create({ - Use `visibleWhen` to hide irrelevant fields in ObjectUI forms. - Use `readonlyWhen` for state-locked fields; the ObjectQL write path ignores - incoming changes when the predicate is `TRUE`. + incoming changes when the predicate is `TRUE` (for everyone; per permission + set → field permission `when`, rules/security.md). - **`readonly: true` governs the end-user surface, not trusted system writers.** A non-system write (REST/UI, and any `runAs:'user'` flow — the default) has the field **stripped** from any non-system write; the write reports diff --git a/skills/objectstack-data/evals/hooks-security-seeds-datasources.json b/skills/objectstack-data/evals/hooks-security-seeds-datasources.json index 6f4648942de..42a526f0eae 100644 --- a/skills/objectstack-data/evals/hooks-security-seeds-datasources.json +++ b/skills/objectstack-data/evals/hooks-security-seeds-datasources.json @@ -40,6 +40,16 @@ "must_contain": ["defineSeed", "externalId", "upsert", "env:", "daysFromNow"], "must_not_contain": ["new Date(", "defineDataset", "externalId: 'id'"] } + }, + { + "id": 5, + "prompt": "Finance may edit a contract's legal terms (`governing_law`, `liability_cap`) while it is a draft, but not once it is approved, signing or active. Legal still edits them.", + "expected_output": "Field permission entries with a row condition on the finance set: `'clm_contract.governing_law': { readable: true, editable: false, when: \"status in ['approved', 'signing', 'active']\" }` and the same for `liability_cap`; the legal set keeps its edit grant. No validation rule and no `readonlyWhen`, which would lock the fields for every user.", + "files": [], + "assertions": { + "must_contain": ["when:", "editable: false", "readable: true", "clm_contract.governing_law"], + "must_not_contain": ["validations:", "readonlyWhen"] + } } ] } diff --git a/skills/objectstack-data/rules/security.md b/skills/objectstack-data/rules/security.md index a6fed439cf4..1e4ca33b44c 100644 --- a/skills/objectstack-data/rules/security.md +++ b/skills/objectstack-data/rules/security.md @@ -116,6 +116,35 @@ 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. +## Field permissions — a row-conditioned edit lock (`when`) + +A set's `fields` entry (`'OBJECT.FIELD': { readable, editable }`) may carry +`when`, a row condition in the same canonical CEL as `rowLevelSecurity[].using`. +**Choose by who loses the edit — one mechanism per need, never both:** + +- Locked for **everyone** at that stage → a [validation rule](./validation.md) + on the object (`readonlyWhen` likewise strips the write for everyone). +- Locked for **some permission sets** only → `when` on the field entries of + each set that loses the edit; the other sets keep their grant. + +```typescript +// finance may not change the contract's legal terms after approval +fields: { + 'clm_contract.governing_law': { readable: true, editable: false, when: "status in ['approved', 'signing', 'active']" }, +} +``` + +- **Edit vote only.** Reads are row-blind; `readable: false` beside `when` is + refused at parse time. +- **Same object.** Fields of the entry's object vs a literal or `current_user.*`; + no relation reach (refused at `os validate` / publish). +- **Pre-image.** An update is judged on the stored row, so a write cannot unlock + itself by moving `status` back; an insert on the row as sent. +- **Locks, never grants.** On unmatched rows the entry is absent; another held + set's `editable: true` still wins (most-permissive merge). +- Docs: https://objectstack.ai/docs/permissions/field-level-security#row-conditioned-edit-locks-when + and the recipe https://objectstack.ai/docs/permissions/access-recipes#data-lock-fields-for-a-position-once-a-record-reaches-a-stage + ## Sensitive fields — `secret` type + `requiredPermissions` `maskingRule` is **live** (FieldMasker). Two more channels: