Skip to content

Commit dc532e8

Browse files
feat(spec): a roster object declares its membership keys, ObjectSchema.membershipKeys (#25513)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoJLr1A7TYy1k6cGE23eo9VQ)_ Part of #25234 Clause-②: yes (widening) Metadata-protocol: added PR 1 of 3 of ruling A on #25234 (ruling comment 6355640929, on design comment 6344270438): the spec half. `ObjectSchema` gains one optional key, `membershipKeys`, on the ROSTER object: each entry makes `current_user.KEY` the distinct values of `valueField` on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds. Nothing reads the key yet. Its reader is PR 2 (plugin-security's `stageRlsMembership`, beside the registered code resolver), and the authoring rules are PR 3. This PR leaves the card open; the `skills/objectstack-data/rules/security.md` row (Tier H) lands separately, by the seat. No object that exists today changes behaviour: at the base `ab510f42e7` the word `membershipKeys` occurs nowhere in this tree (`git grep` exit 1, against a `defaultRowScope` control that hits under `packages/plugins`). ## The shape ```ts // packages/spec/src/security/rls.zod.ts, beside DefaultRowScopeSchema MembershipKeySchema = strictObject({ surface: 'this membership key', … }, { userField: SnakeCaseIdentifierSchema, // U: the roster row's user column valueField: SnakeCaseIdentifierSchema, // F: the column whose values form the set filter: z.string().optional(), // CEL over this object's own columns label: z.string().optional(), description: z.string().optional(), }).superRefine(/* valueField !== userField */); MembershipKeysSchema = z.record(z.string(), MembershipKeySchema).superRefine(/* each name: snake_case, not reserved */); // ObjectSchema, after defaultRowScope: membershipKeys: MembershipKeysSchema.optional() MEMBERSHIP_KEY_MAX_VALUES = 1000 // the ruled platform constant; not a member ``` - **Home: `security/rls.zod.ts`, not a new module.** The design sketched a new `membership-key.zod.ts`; the order says a new module only if the precedent made one, and #24398 put `DefaultRowScopeSchema` in `rls.zod.ts`. Measured on the first push: a new module under `security/` is picked up by `gen:skill-refs`, which rewrites `skills/objectstack-data/references/_index.md` and `skills/objectstack-ui/references/_index.md`, and this PR's claim excludes `skills/**`. In `rls.zod.ts` the generated skill references do not move. - **The key name is judged in a record-level `superRefine`, not as the record's key schema.** A refused record key reaches the author only as zod's generic `Invalid key in record` (measured), which hides which rule the name broke. The record-level check raises one `custom` issue at `membershipKeys.KEY` naming the rule: snake_case, or the reserved list, read from `RESERVED_RLS_MEMBERSHIP_KEYS` in `contracts/rls-membership-resolver.ts` and never copied. - **`label` and `description` are `z.string()`**, as on the RLS policy and on the object itself. The design's sketch named `I18nLabelSchema`, a `ui/` render-time union nothing in the security tree imports. Its consumer is PR 2's explain `detail`, which takes plain text. - **Aliases and guidance.** Near-miss aliases from the design (`user`, `value`, `field`, `where`, `condition`) plus `userColumn`, `ownerField`, `valueColumn` and `using`. `object` gets the "implied, declare it on the roster object" prescription; `limit` / `maxValues` get "no authorable size: 1,000 per request, a platform constant". `ObjectExtensionSchema` gains a `membershipKeys` guidance line, so the refusal names the owning-package rule. ## The 13 ruled refusals: which the shape carries, which PR 3 carries | # | Refusal | Here | Where | |:--|:--|:--|:--| | 1 | unknown member; non-snake_case key name | yes | strict shape; record-level name check | | 2 | a key named like a kernel context value | yes | record-level name check over `RESERVED_RLS_MEMBERSHIP_KEYS` | | 3 | one key declared on two objects | yes (stack doors) | `defineStack` and `composeStacks` cross-reference, below. The runtime gate against the registry (other installed packages) is PR 3 | | 4 | a key the registered code resolver also owns | no | runtime gate (it holds the resolver's `keys`): PR 3 | | 5 | U missing on the merged object, not a single-valued user reference | no | authoring rule on the merged object: PR 3 | | 6 | F missing or not a reference; F equal to U | the U = F half | `superRefine` at `valueField`; the field-type half is PR 3 | | 7 | a `filter` that does not lower, names a missing column, or reads `current_user.*` | no | the `rls-predicate-*` judge with X as target: PR 3 | | 8 | a cycle | unspellable | no member names another key | | 9 | an unbounded set | unspellable | no size member; U is the required equality anchor; the bound is the constant | | 10 | cross-package reach | yes | `ObjectExtensionSchema` refuses the key, now with a prescription. See the acceptance note on the registry's `extend` door | | 11 | a policy comparing the key with a column of another target | no | `rls-predicate-*` extension: PR 3 | | 12 | a policy reading an unowned key | no | runtime gate: PR 3 | | 13 | a declared key in a scalar position | no | already refused by the predicate judge; made exact in PR 3 | ## What else moves, each by its own tool - **`defineStack` / `composeStacks`, refusal 3.** `duplicateMembershipKeyFindings` (in `stack.zod.ts`) counts each key by distinct declaring-object name. The one-stack door runs beside the duplicate default-record-page check, before the object-count early return. The composition door runs in the artifact cross-reference pass over the composed objects, after the conflict strategy, so an object `'override'` replaced no longer counts and one `'merge'` combined counts once. It runs under every manifest strategy, because the key namespace is the deployment's. Both doors raise the `STACK_CROSS_REFERENCE_INVALID` / 422 envelope. - **`composeStacks` with `objectConflict: 'merge'`**: `membershipKeys` is record-typed, so the composer's derived collection set picks it up with no `stack.zod.ts` edit. Two stacks declaring a different `membershipKeys` on one object are refused like `listViews`. The literal lists in `compose-stacks-merge-collection-refusal.test.ts` and `compose-stacks-collection-pipe-arm.test.ts` gained the key in shape order, plus one fixture row. - **`rls.zod.ts` example fix (the design's carrier note).** Use cases 4 and 5, the two `@example` comments and the `using` TSDoc said the runtime stages `territory_account_ids` / pre-resolves `team_member_ids`. Nothing in this tree stages either. They now say these are app-supplied keys. A new "Where a membership key comes from" section names the two sources: a registered resolver, and the declared key, marked not applied yet. A sixth use case shows the portal key. No published describe string changed meaning. - **Liveness ledger** (`packages/spec/liveness/object.json`): `membershipKeys` is `planned` with `authorWarn` and an `authorHint`. It is drilled into 5 `planned` children, because `check:liveness` refuses an undeclared container inheritance (measured: it named `userField, valueField, filter, label, description`). Only the container warns. Probed with `lintLivenessPropertiesFromLedgerDir` from `packages/lint`: one `liveness-planned-property` finding on an object declaring the key, zero on the control. The note gives the flip condition: `live` on the commit that lands PR 2, citing `stageRlsMembership`, with `authorWarn` / `authorHint` dropped in the same edit. - **Studio form**: not offered. The root-ledger `omit` row in `metadata-form-zod-reconciliation.test.ts` records why: no server stages the key yet, and the ruling makes the form row an objectui card. - **Field-level-security projection** (`@objectstack/metadata-core`): `OBJECT_REFERENCE_POSITIONS` classifies `membershipKeys` as `recordOf(ruleEntry)`. A key whose `userField`, `valueField` or `filter` names a field the caller cannot read is dropped whole, the map goes when nothing is left, and the key NAME is never read as a field. The classification pin in `object-schema-fls-references.test.ts` otherwise reds, as it did on #24398. - **Served `/meta/types` count** (`@objectstack/metadata-protocol` test only): `object` moves 46 to 47, with its history sentence. - **Generated artefacts**, by `pnpm --filter @objectstack/spec check:generated --fix` after a build: the reference pages under `content/docs/references/**`, `authorable-surface/{data,security}.json`, `json-schema.manifest/security.json`, `api-surface/security.json`, `export-origins/security.json`, `declaration-map/security.json`, `liveness/state-counts/object.md`, and the strictness-ledger counts (one more strict site in `rls.zod.ts`). - **`dropped-refinements.baseline.json`** (hand-edited by design): three new entries, `security/MembershipKeyName`, `security/MembershipKey` and `security/MembershipKeys`. The two new sites `membershipKeys` and `membershipKeys.valueType` are added to the nine entries that embed `ObjectSchema`, as the build printed them. `measured` moves 242/813 to 245/835. Neither rule has a projectable arm: `bannedKeys` would publish the reserved list, but only as a refinement on the record root, which loses the per-key message above. - **ADR-0122 type aliases**: `MembershipKey` / `MembershipKeyParsed`, `MembershipKeys` / `MembershipKeysParsed`, `MembershipKeyName` / `MembershipKeyNameParsed` (`check:spec-parsed-alias` green). - **Changeset** `.changeset/25234-object-membership-keys.md`: `minor` on `@objectstack/spec`, `patch` on `@objectstack/metadata-core`, with the `Clause-②` and `Metadata-protocol` lines. ## Tests `packages/spec/src/data/object-membership-keys.test.ts`, 25 tests: 1. The design's worked example (hotsrm's `srm_supplier_contact` with `portal_supplier_ids`) parses unchanged, through `ObjectSchema` and through the `object` metadata write door; the minimal two-member declaration parses; control: an object without the key parses and its output has no key. 2. Refused, each asserting `code` and `path`: every name in `RESERVED_RLS_MEMBERSHIP_KEYS` (6, read from the contract), three non-snake_case names, `valueField` equal to `userField` (at `valueField`), `object` and `limit` / `maxValues` (`unrecognized_keys` with their prescriptions), an unknown member, a missing `userField` / `valueField`, and the key on an `ObjectExtensionSchema`. 3. The size bound is the constant 1,000 and no member; the entry's member list is exactly the five. 4. `defineStack`: one key on two objects is refused (envelope `STACK_CROSS_REFERENCE_INVALID` / 422, one issue naming both objects and their `stack.objects[i]` positions); control: two different keys are accepted. 5. `composeStacks`: one key on two objects across two stacks is refused (envelope, naming both objects and the stacks that define them), also under `manifest: 'preserve'`. Controls: two different keys compose, and one object declared identically by both stacks under `'merge'` counts once. A different declaration of one key on one object under `'merge'` is the collection refusal, not this one. Also `object-schema-fls-references.test.ts` gained a three-leg pin: dropped on `valueField`, dropped on `filter`, kept otherwise; the map removed when every key goes; a key named like a denied field, and a denied field named like a member (`filter`), cost nothing. **Ablation, one, on the behaviour this diff adds:** the one-stack door's `errors.push(...collectDuplicateMembershipKeyErrors(config))` replaced by a comment, through `node scripts/ablation-replace.mjs` (anchor 1 to 0, blob `208905c1ae7c` to `98fa82b5718f`). Expected direction red, observed red: 1 failed (the `defineStack` refusal), 24 passed. The composition door is a separate call and its tests stayed green, as they should. Restored: blob equals the HEAD blob `208905c1ae7c`, and `git diff HEAD` is empty. No build is involved, because the test imports `src` through a relative path. **Reverse verification of the rebuilt `.d.ts` downstream:** a temporary file in `packages/platform-objects/src` calling `ObjectSchema.create({ …, membershipKeys: { portal_supplier_ids: { userField, valueField, filter } } })` typechecks (exit 0). A stale declaration file would refuse the key through `NoExcessObjectKeys`. The same call with `valueField: 5` fails with TS2322 at the probe (exit 2). A third leg found that an unknown member inside an entry (`limit: 5`) typechecks (exit 0): the compile-time excess-key guard does not reach into record values, so the runtime parse is the refusal there, as pinned above. The probe was removed and `git status` is clean. On the final head `b2b98c41`, through `os-verify-lock.sh`: - `pnpm --filter @objectstack/spec test`: 713 files passed, 21684 tests passed, 1 todo. `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/metadata-core exec vitest run`: 19 files, 796 tests passed. `pnpm --filter @objectstack/metadata-core typecheck`: exit 0. - `protocol.meta-types-degenerate-derivation.test.ts`: 18 passed. - Downstream readers of `ObjectSchema.shape` or the liveness ledger: 4 `@objectstack/lint` files (694 tests) and 4 `@objectstack/objectql` files (160 tests) passed, on a closure rebuilt from this head (`pnpm --filter 'PKG...' build`). ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) on the final head `b2b98c41` derives 116 families. All 116 were run on that head, one at a time, exit codes captured before any pipe: **116 exit 0**. `--ran` reconciliation: 116 derived, 116 run, 0 NOT MEASURED, 0 unrun. The changeset gates are among them (`check-changeset-no-major`, `check-adr-0087-registration` and their self-tests, against `origin/main`). - A first pass on `b595703e` found one real finding, fixed in `b2b98c41`: `check:spec-parsed-alias` (ADR-0122) asked for `MembershipKeyParsed` as `z.infer` and `Parsed` twins for the map and the name. Its other non-zero codes were exit 3 (PREREQUISITE NOT MET: a stale `dist` after a test edit, and the `lint` dist not yet built), which the final run on a rebuilt closure answers 0. - `check-plugin-teardown-shape --self-test` needs the pinned fixture commit `621a4876` in this shallow clone; fetched with `--depth=1`, then exit 0. - `pnpm check:dev-prereqs` (local-only family, this diff touches the spec build): exit 0. - Every family CI runs beyond the derived set is the PR's check-runs. ## Acceptance notes - **The registry's `extend` door, for PR 2 or 3.** `ObjectExtensionSchema` refuses `membershipKeys`, but `SchemaRegistry.registerObject` in `@objectstack/objectql` accepts a full object body under `ownership: 'extend'`. For `defaultRowScope` it refuses that by name (`EXTENSION_DEFAULT_ROW_SCOPE_REFUSED_CODE`, `registry.ts`). It has no such refusal for `membershipKeys` (read at `registry.ts`, the `ownership === 'extend'` check beside the field-type door). Whether the extension merge then carries the key onto the owner's object is NOT MEASURED here. Today it has no effect either way, because nothing reads the key. The ruling places cross-package reach in the shape (refusal 10), and this door is outside this PR's file surface. Carrier: #25234 PR 2, before the reader stages a key. - **Compile-time reach.** `NoExcessObjectKeys` does not judge members inside a `membershipKeys` entry (leg 3 above). The runtime parse refuses them with a prescription at every door. This is a property of the record-valued slot, not of this key. - `rls.zod.ts`'s strict-site count moves 4 to 5 in the strictness ledger (the new `strictObject` entry); the map itself is a `z.record`, not a site. --- _Generated by [Claude Code](https://claude.ai/code/session_01M4rJHRAZDFqLYQ9dHMH339)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 842a0ae commit dc532e8

26 files changed

Lines changed: 872 additions & 37 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-core': patch
4+
---
5+
6+
feat(spec): a roster object can declare its membership keys, `ObjectSchema.membershipKeys`
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
10+
11+
`ObjectSchema` gains one optional key, `membershipKeys`: the `current_user.KEY` sets this object defines, by name, the metadata twin of the registered membership resolver (ADR-0105 D11). `current_user.KEY` is to be the distinct values of `valueField` on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, so a permission set's row policy can read `supplier in current_user.portal_supplier_ids` instead of a stamp on every record. The entry shape is exported as `MembershipKeySchema` from `@objectstack/spec/security` (types `MembershipKey` and `MembershipKeyParsed`), with the map `MembershipKeysSchema` (`MembershipKeys`, `MembershipKeysParsed`), the name rule `MembershipKeyNameSchema` (`MembershipKeyName`, `MembershipKeyNameParsed`) and the platform bound `MEMBERSHIP_KEY_MAX_VALUES` (1,000).
12+
13+
- **What it parses.** `userField` and `valueField` (required column names), `filter` (CEL over this object's own columns), `label` and `description`.
14+
- **What it refuses.** Any other member (`object`, which is implied, and an authored size such as `limit`, each with a prescription); a key name that is not snake_case or is one of the kernel's own context names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`); `valueField` naming the same column as `userField`; and `membershipKeys` on an object extension, so a key can only be declared by the package that owns the roster object.
15+
- **`defineStack` and `composeStacks`** refuse one key declared on two objects, in the cross-reference envelope (`STACK_CROSS_REFERENCE_INVALID`, 422): a key name is one request-wide name. Under `objectConflict: 'merge'`, two stacks declaring a different `membershipKeys` on one object are refused like every other object-level collection.
16+
- **What it does today: nothing.** No server reads the key in this release, so a policy reading a declared key matches no rows, as it would for any key nothing supplies. Authoring the key raises the liveness lint's `liveness-planned-property` warning until the security plugin stages it. Scope rows with positions or a registered membership resolver in the meantime.
17+
- **`@objectstack/metadata-core`: the field-level-security projection classifies the new key.** When an object document is served to a caller who cannot read some of its fields, each membership key whose `userField`, `valueField` or `filter` names such a field is dropped whole from the served document; the others are served unchanged.
18+
- The row-level security docs no longer say the runtime stages `territory_account_ids` or `team_member_ids`: those are app-supplied keys, and the docs now say where a membership key comes from.
19+
- Additive: every object that parsed before still parses, and its parsed output is unchanged.

‎content/docs/references/api/metadata.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -971,6 +971,7 @@ Metadata query with filtering, sorting, and pagination
971971
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
972972
| **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). |
973973
| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. |
974+
| **membershipKeys** | `Record<string, { userField: string; valueField: string; filter?: string; label?: string; … }>` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The contract the server is to meet once it reads the key: the set is read once per request, as a system read inside the caller's organization and regardless of what the caller may read on this object; it holds at most 1,000 values, and a larger set, a failed read or a key nothing resolves makes every policy reading it match no rows. Not applied yet: no server reads this key in this release, so a policy reading `current_user.KEY` matches no rows. Until it is applied, scope rows with positions or a registered membership resolver. |
974975
| **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
975976
| **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) |
976977
| **actions** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) |

‎content/docs/references/data/object.mdx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ const result = ApiMethod.parse(data);
174174
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
175175
| **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). |
176176
| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. |
177+
| **membershipKeys** | `Record<string, { userField: string; valueField: string; filter?: string; label?: string; … }>` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The contract the server is to meet once it reads the key: the set is read once per request, as a system read inside the caller's organization and regardless of what the caller may read on this object; it holds at most 1,000 values, and a larger set, a failed read or a key nothing resolves makes every policy reading it match no rows. Not applied yet: no server reads this key in this release, so a policy reading `current_user.KEY` matches no rows. Until it is applied, scope rows with positions or a registered membership resolver. |
177178
| **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
178179
| **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) |
179180
| **actions** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) |
@@ -448,6 +449,16 @@ const result = ApiMethod.parse(data);
448449
| **positions** | `string[]` | optional | Positions this policy applies to (omit for all) |
449450
| **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active |
450451

452+
### Nested Shape: `Object.membershipKeys[string]`
453+
454+
| Property | Type | Required | Description |
455+
| :--- | :--- | :--- | :--- |
456+
| **userField** | `string` | ✅ | The column of this object that holds the user a row belongs to: a single-valued user reference (`user`, or a `lookup` / `master_detail` to `sys_user`). A row counts for the signed-in user when this column holds their user id. |
457+
| **valueField** | `string` | ✅ | The column of this object whose values form the set: a reference (`lookup`, `master_detail` or `user`), multi-valued allowed. A policy compares the set with a column that references the same object, or with `id` on that object. Never the same column as `userField`. |
458+
| **filter** | `string` | optional | Optional condition a roster row must also meet, in canonical CEL over this object's own columns and within the row-level security subset a policy's `using` lowers (e.g. "active == true"). It may not read any `current_user.*` value. |
459+
| **label** | `string` | optional | Human-readable name of the set |
460+
| **description** | `string` | optional | What the set holds and who it is for |
461+
451462
### Nested Shape: `Object.publicSharing`
452463

453464
| Property | Type | Required | Description |

‎content/docs/references/index.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Protocol reference — every schema by module
33
navTitle: Protocol Reference
4-
description: Every schema published by @objectstack/spec — 1544 schemas across 14 protocol modules
4+
description: Every schema published by @objectstack/spec — 1547 schemas across 14 protocol modules
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -29,12 +29,12 @@ counts are sums of the rows they head. Regenerate with
2929
| [Kernel Protocol](/docs/references/kernel) | 30 | 161 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. |
3030
| [Marketplace Protocol](/docs/references/marketplace) | 4 | 30 | The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. |
3131
| [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. |
32-
| [Security Protocol](/docs/references/security) | 5 | 31 | Permission sets, row-level security, sharing rules, tenancy posture. |
32+
| [Security Protocol](/docs/references/security) | 5 | 34 | Permission sets, row-level security, sharing rules, tenancy posture. |
3333
| [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
3434
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
3535
| [System Protocol](/docs/references/system) | 36 | 285 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
3636
| [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
37-
| **Total** | **197** | **1544** | 14 protocol modules |
37+
| **Total** | **197** | **1547** | 14 protocol modules |
3838

3939
---
4040

@@ -266,7 +266,7 @@ Declarative test suites — scenarios, steps, actions and assertions.
266266

267267
## Security Protocol
268268

269-
**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **5 pages, 31 schemas**
269+
**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **5 pages, 34 schemas**
270270

271271
Permission sets, row-level security, sharing rules, tenancy posture.
272272

@@ -275,7 +275,7 @@ Permission sets, row-level security, sharing rules, tenancy posture.
275275
| [`explain.zod.ts`](/docs/references/security/explain) | `AccessMatrix`, `AccessMatrixEntry`, `AuthzPosture`, `ExplainDecision`, `ExplainLayer`, `ExplainMatchedRule`, `ExplainOperation`, `ExplainRecordAttribution`, `ExplainRequest` |
276276
| [`misc`](/docs/references/security/misc) *(no single source file)* | `CapabilityDeclaration`, `OrgScopingEntitlement`, `PlatformGlobalObjects`, `TenancyPosture`, `TenantLayer0Verdict` |
277277
| [`permission.zod.ts`](/docs/references/security/permission) | `AdminScope`, `EffectiveObjectPermission`, `FieldPermission`, `ObjectAccessScope`, `ObjectPermission`, `PermissionSet` |
278-
| [`rls.zod.ts`](/docs/references/security/rls) | `DefaultRowScope`, `RLSEvaluationResult`, `RLSOperation`, `RLSUserContext`, `RowLevelSecurityPolicy` |
278+
| [`rls.zod.ts`](/docs/references/security/rls) | `DefaultRowScope`, `MembershipKey`, `MembershipKeyName`, `MembershipKeys`, `RLSEvaluationResult`, `RLSOperation`, `RLSUserContext`, `RowLevelSecurityPolicy` |
279279
| [`sharing.zod.ts`](/docs/references/security/sharing) | `CriteriaSharingRule`, `OWDModel`, `ShareRecipientType`, `SharingLevel`, `SharingRule`, `SharingRuleType` |
280280

281281
---

0 commit comments

Comments
 (0)