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
12 changes: 12 additions & 0 deletions .changeset/27660-skills-credential-presence-only.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 37 additions & 2 deletions scripts/check-skills-token-ratchet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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: <self> }` 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: <self> }` 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"]
}
}
]
}
25 changes: 14 additions & 11 deletions skills/objectstack-data/rules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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], ... })
```

Expand Down Expand Up @@ -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: '<queue name>' }`
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`

Expand All @@ -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`)

Expand Down Expand Up @@ -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
Expand Down
Loading