diff --git a/.changeset/27660-skills-credential-presence-only.md b/.changeset/27660-skills-credential-presence-only.md new file mode 100644 index 00000000000..802eb17bded --- /dev/null +++ b/.changeset/27660-skills-credential-presence-only.md @@ -0,0 +1,12 @@ +--- +'@objectstack/skills': patch +--- + +docs(skills): objectstack-data teaches that a `secret` / `password` column is queried only for presence, with one eval + +Clause-②: no +Metadata-protocol: none + +- **Before.** The engine refuses a credential column (`type: 'secret'` / `type: 'password'`) as a filter key, a sort key, a group-by or min/max key and an import `matchFields` key with `400 INVALID_FIELD`, and its search scan never reads one. `objectstack-data`'s `rules/security.md` taught the credential types but said nothing about querying them, so an agent following it could still write a view filter, sort, report or import mapping that the runtime refuses. +- **Now.** `rules/security.md` states the rule next to the `secret` example: the only condition allowed on a credential column asks whether it has a value (`null`, `$null`, `$exists`, `$empty`); everything else is refused; search never scans it, so it does not belong in `searchableFields`. One eval asks for a list view filtered on a `secret` field and expects the redirect to the view's `is_not_null` rule. +- The new text in `rules/security.md` is paid for by folding sentences the package already holds elsewhere (the `secret` type's purpose and encryption in `rules/field-types.md`, the queue parking rule in `SKILL.md`), and the remaining balance on it and on the eval file is raised by measurement, at zero headroom. The example shows the has-a-value check in both spellings: `where` / hook `query.filter`, and a list view's `filter` rule (`operator: 'is_not_null'`). No schema, key or runtime behaviour changes. diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index be6cade155b..c223c18ebf8 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -712,7 +712,24 @@ export const CEILINGS = new Map([ // 3693 -> 3687 (#27016): RE-LOCK at the landed count. `queues` joins the // kernel names a membership key may not take (+3 tokens), paid by cutting // "not a stamp on every data row," from the membership-keys intro; net -6. - ['skills/objectstack-data/rules/security.md', 3687], + // 3687 -> 3698 (card #27660, the same Skills-docs follow-up): one rule + // beside the `secret` example — a `secret` / `password` column is never a + // filter, sort, group-by or import `matchFields` key (`400 INVALID_FIELD`), + // search never scans it, and the only condition is has-a-value, shown in + // both surfaces' spellings (`where` / hook `query.filter` and a list view's + // `filter` rule). Same standing ruling, 2026-10-27, decision card 「skills + // 修复卡的教学和 eval 都撞 token 天花板,怎么付?」, tap A, verbatim and + // untranslated: 「A:先削重复,余额按实测提高」. PAID DOWN FIRST: four passages + // the package holds elsewhere — the `secret` type's purpose, encryption and + // masking (`rules/field-types.md`, net 150 bytes), the queue-parking aside + // (`SKILL.md` § Ownership queues, 91), the `external` tier's mechanism list + // (the choice list above it, net 107) and `— NOT permissionSets` (the + // bullet below it, 25): 373 bytes = 93 units; the rule adds 442 bytes = 111 + // units. On `origin/main` the file was 14722 bytes (3681 tokens, headroom + // 6); the LANDED file is 14791 bytes -> 3698 tokens, ceiling +11, pinned AT + // the measurement, zero headroom. + // ceil($(wc -c < skills/objectstack-data/rules/security.md) / 4) == ceil(14791 / 4) + ['skills/objectstack-data/rules/security.md', 3698], // 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 @@ -984,7 +1001,25 @@ export const CEILINGS = new Map([ // -> 1573 tokens, ceiling +169, pinned AT the measurement, zero headroom. // The raising PR's body carries the same arithmetic. // ceil($(wc -c < skills/objectstack-data/evals/hooks-security-seeds-datasources.json) / 4) == ceil(6292 / 4) - ['skills/objectstack-data/evals/hooks-security-seeds-datasources.json', 1573], + // 1573 -> 1674 (card #27660, the Skills-docs follow-up owed by the + // objectql PR that refuses a credential column as a filter or sort key): + // one case for a list view filtered on a `secret` field by prefix — + // expected the redirect to the view dialect's presence rule + // (`operator: 'is_not_null'`) and the `INVALID_FIELD` refusal named; NO + // `starts_with`. Seat decision on the card's open question, applying the + // standing maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的 + // 教学和 eval 都撞 token 天花板,怎么付?」, tap A, verbatim and untranslated: + // 「A:先削重复,余额按实测提高」. PAID DOWN FIRST: two clauses that restated + // their own case's `must_not_contain` — case 2's "Permission sets, not the + // object: " (restating `permissions: {`, 33 bytes) and case 8's " — no + // picklist, counter or insert hook" (restating `assigned_to` / + // `beforeInsert`, 40 bytes), 73 bytes = 18 units; the case adds 479 bytes = + // 120 units. On `origin/main` the file was 6289 bytes (1573 tokens, headroom + // 0); the LANDED file is 6695 bytes -> 1674 tokens, ceiling +101, pinned AT + // the measurement, zero headroom. The raising PR's thread carries the same + // arithmetic. + // ceil($(wc -c < skills/objectstack-data/evals/hooks-security-seeds-datasources.json) / 4) == ceil(6695 / 4) + ['skills/objectstack-data/evals/hooks-security-seeds-datasources.json', 1674], // 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 diff --git a/skills/objectstack-data/evals/hooks-security-seeds-datasources.json b/skills/objectstack-data/evals/hooks-security-seeds-datasources.json index 58f183b45d5..3bb4ee10e03 100644 --- a/skills/objectstack-data/evals/hooks-security-seeds-datasources.json +++ b/skills/objectstack-data/evals/hooks-security-seeds-datasources.json @@ -14,7 +14,7 @@ { "id": 2, "prompt": "Sales reps see only their own leads, sales managers their unit and everything below it; every signed-in user reads the `product` catalog.", - "expected_output": "Permission sets, not the object: `lead` is `sharingModel: 'private'`; the rep grant carries `readScope: 'own'`, the manager grant `readScope: 'unit_and_below'` (`@objectstack/security-enterprise` + `requires: ['hierarchy-security']`, else fails closed to `own`); `product` is `sharingModel: 'public_read'`.", + "expected_output": "`lead` is `sharingModel: 'private'`; the rep grant carries `readScope: 'own'`, the manager grant `readScope: 'unit_and_below'` (`@objectstack/security-enterprise` + `requires: ['hierarchy-security']`, else fails closed to `own`); `product` is `sharingModel: 'public_read'`.", "files": [], "assertions": { "must_contain": ["sharingModel: 'private'", "readScope", "unit_and_below", "public_read", "hierarchy-security"], @@ -74,12 +74,22 @@ { "id": 8, "prompt": "Web-form leads arrive ownerless; inside sales holds them until a rep takes one, and two reps must never own the same lead.", - "expected_output": "`queues: [defineQueue({ name: 'inside_sales', label: 'Inside Sales', members: [{ type: 'position', value: 'inside_sales_rep' }] })]` and an `assignmentRules` entry `assignTo: { type: 'queue', value: 'inside_sales' }` with no `strategy` park each lead in the injected `queue` column — no picklist, counter or insert hook; a rep claims by an ordinary by-id `{ owner_id: }` update, the loser gets `409 CONCURRENT_UPDATE`.", + "expected_output": "`queues: [defineQueue({ name: 'inside_sales', label: 'Inside Sales', members: [{ type: 'position', value: 'inside_sales_rep' }] })]` and an `assignmentRules` entry `assignTo: { type: 'queue', value: 'inside_sales' }` with no `strategy` park each lead in the injected `queue` column; a rep claims by an ordinary by-id `{ owner_id: }` update, the loser gets `409 CONCURRENT_UPDATE`.", "files": [], "assertions": { "must_contain": ["defineQueue", "type: 'position'", "type: 'queue'", "CONCURRENT_UPDATE"], "must_not_contain": ["assigned_to", "beforeInsert"] } + }, + { + "id": 9, + "prompt": "A `vendor` list view of the rows whose `api_key` (a `secret` field) starts with `sk_live`.", + "expected_output": "Redirects: a credential is filtered only by presence, `filter: [{ field: 'api_key', operator: 'is_not_null' }]`; any other filter or sort is `400 INVALID_FIELD`.", + "files": [], + "assertions": { + "must_contain": ["INVALID_FIELD", "is_not_null"], + "must_not_contain": ["starts_with"] + } } ] } diff --git a/skills/objectstack-data/rules/security.md b/skills/objectstack-data/rules/security.md index fcfd4a7a83b..dc6ade1c63d 100644 --- a/skills/objectstack-data/rules/security.md +++ b/skills/objectstack-data/rules/security.md @@ -16,7 +16,7 @@ export const salesUser = definePermissionSet({ }, }); -// Register it on the stack root under `permissions` — NOT `permissionSets`: +// Register it on the stack root under `permissions`: // defineStack({ permissions: [salesUser], ... }) ``` @@ -162,8 +162,7 @@ rowLevelSecurity: [ position → `positions` on the policy; a named record → a membership key; one record, once → a sharing rule or a manual share; a team's records → a sharing rule whose `sharedWith: { type: 'queue', value: '' }` - reaches an ownership queue's members (it shares the rule's rows, it parks - none — only the record's `queue` column does that; SKILL.md § Ownership queues). + reaches an ownership queue's members (SKILL.md § Ownership queues). ## Positions grant only what they name; portal users are `external` @@ -177,9 +176,7 @@ rowLevelSecurity: [ 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. + owner-only for it: rows reach it **only** through the explicit grants above. ## Field permissions — a row-conditioned edit lock (`when`) @@ -214,16 +211,22 @@ fields: { `maskingRule` is **live** (FieldMasker). Two more channels: -**Encrypted-at-rest values — `type: 'secret'` (ADR-0100).** For reversible -machine credentials (DB passwords, API keys, tokens): encrypted on write via the -registered `ICryptoProvider`, the ciphertext handle in `sys_secret` and only an -opaque ref on the row, masked on read. **Fail-closed:** with no crypto provider, -writes throw rather than persist cleartext. +**Encrypted-at-rest values — `type: 'secret'` (ADR-0100).** Ciphertext lives in +`sys_secret`, only an opaque ref on the row. **Fail-closed:** with no crypto +provider, writes throw rather than persist cleartext. + +**Query a credential only for presence.** A `secret` / `password` column is +never a filter, sort, group-by or import `matchFields` key (`400 INVALID_FIELD`; +an import row naming it fails). Search never scans it: keep it out of +`searchableFields`. ```typescript fields: { api_key: { type: 'secret', label: 'API Key' }, } +// also `null`, `$exists`, `$empty` / `is_not_empty` +where: { api_key: { $null: false } } // ObjectQL, hook `query.filter` +filter: [{ field: 'api_key', operator: 'is_not_null' }] // list view ``` **Per-field access gating — `requiredPermissions` (ADR-0066 D3).** A field