Skip to content

Commit e5eab06

Browse files
feat(plugin-security): a declared membership key is staged per request, beside the registered resolver (#25528)
<!-- 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-②: no Metadata-protocol: semantic PR 2 of 3 of ruling A on #25234 (ruling 6355640929, design 6344270438, landing plan 6370632236): the reader. `stageRlsMembership`, the one producer of `ExecutionContext.rlsMembership`, now stages the `ObjectSchema.membershipKeys` that PR 1 (#25513) declared, beside the registered `rls-membership-resolver`. It also closes the objectql registry `extend` door that PR 1's acceptance notes named. Liveness flips `planned` to `live`. PR 3 (the authoring rules) carries `Closes #25234`. Serial against #25233 (PR #25515 also touches plugin-security): whichever lands second merges `main`. This head already merged `main` at `74496c64` (which contains #25515). ## What a declared key resolves to, and when `current_user.KEY` = the distinct values of `valueField` (F) on the rows of the declaring roster object X whose `userField` (U) is the caller and on which `filter` holds, inside the caller's own Layer 0 wall for X. - **Which keys, when** (`object-membership-keys.ts#stageDeclaredMembershipKeys`). From the policies about to compile, take the `current_user.*` names their `using` and `check` read. They are parsed once per predicate text by `@objectstack/formula`'s `parseCelToAst`, after the compiler's own `sqlPredicateToCel` bridge, and the parse is cached. A name the ObjectQL registry declares, and this request has not staged, is resolved. A key no policy reads is never resolved. The memo is per key on the execution context: a request that reads the key on `qa_po` and `qa_asn` issues one roster read (pinned). - **Both compile sites** (`security-plugin-rls-filter.ts`). The read door stages just before Layer 1 compiles, with the `compilable` list. The write `check` stages with `withCheck`. A `using` and its `check` twin therefore read the same keys. The resolver half runs first and is unchanged (`stageResolverMembership`). It now records which keys it staged, so a collision can withdraw them. - **The read** (`readDeclaredKey`): X where `U == userId`, AND the compiled `filter`, AND the caller's Layer 0 filter for X. It is projected to `id` and F and limited to 1,001 rows, under `{ isSystem: true }`, and Layer 1 is not consulted. ## Rider 1: Layer 0 system read, independent of audience and posture - The read's context is `{ isSystem: true }` and nothing of the caller's. The middleware's total `isSystem` bypass means X's own row policies, which may read the very key this read feeds, are not consulted. A caller with no read on X still resolves its key. - The wall is the caller's own. `computeTenantLayer0Verdict` over `tenantLayer0InputOf(...)` is computed with `isPlatformAdmin: false` whatever the caller holds, so no crossing applies, and a roster row in another organisation never counts. `tenantLayer0InputOf` is a new builder in `tenant-layer.ts`. It holds the input literal `computeLayeredRlsFilter` used to inline, comments included, and the read door now builds its input through it, so the two cannot diverge. - The system context deliberately carries no `tenantId`. With one, the engine scoped the driver to the active organisation on its own, which would have narrowed a `group`-posture key below the wall. The `where` wall is therefore the only organisation scope, and the Layer 0 ablation below proves it is load-bearing. - **Pins:** - Real engine: the portal set has no grant on `qa_supplier_contact`, and its own find of the roster is refused with `PERMISSION_DENIED`, while its PO read resolves the key. - Unit: four callers (`MEMBER`; `EXTERNAL` with `audience: 'external'`; `PLATFORM_ADMIN` holding `admin_full_access`; `TENANT_ADMIN`) produce byte-identical `find` arguments, asserted in full: the `where` with the org term, `fields`, the limit, and `context: { isSystem: true }`. - **Rider 2:** `MEMBER`, `audience: 'external'` and `TENANT_ADMIN` callers all read the same rows, so keys are not scoped to externals. ## Fail closed | Case | Staged | Logged | |:--|:--|:--| | no roster row | `[]` (the compiler drops the policy, as for any empty set) | no | | more than 1,000 rows, or more than 1,000 values after flattening | unstaged | warn | | read throws; schema unreadable | unstaged | warn | | `filter` refused by `RLSCompiler.compileFilter` against X's columns with no caller variables (non-lowering shape, a column X lacks, any `current_user.*`) | unstaged | warn, plus the compiler's own | | tenant wall denies (no organization scope) | unstaged | warn | | no signed-in user | unstaged | warn | | a key two registry objects declare | unstaged | error, once per logger | | a key the registered resolver owns (its `keys`, or one it staged this request) | unstaged; the resolver's value is withdrawn from the bag | error at boot (`SecurityPlugin.start`, naming the key and every declaring object) and once at request time | A key already in the bag that the resolver did not put there was supplied with the context, and it is left as is, as the resolver leaves it. ## Explain (rider 3) - **Object level.** The `rls` layer's `detail` gains one sentence per key this object's policies read. It names the key with its `label` and `description`, then X, F, U, the filter, and "resolved to N value(s)" or "did not resolve (REASON: detail)". It then says "Whoever can write 'X' can grant this key: holders of [...]". The holders are the security catalog's permission sets for which `PermissionEvaluator.checkObjectPermission` grants `insert` or `update` on X. An object whose policies read no key is explained byte-identically (control pinned). - **Record level.** The `business_rls` rule reads `via: 'membership:KEY'`. The detail names the roster row that links the record ("the principal's 'qa_supplier_contact' row 'c_17' links them to 'S-1' (its 'supplier'), which this record's 'supplier' holds"). The row id comes from the staging read, which projects `id` beside F, so no second read is made. `ExplainDecision` gains no field (#24451's choice). - Not changed: an empty membership set still reads `narrows` at object level (the existing composed-deny recognition), as for a resolver key. ## objectql: the `extend` door `SchemaRegistry.registerObject` refuses an `ownership: 'extend'` contribution carrying `membershipKeys`. It answers `INVALID_METADATA` / 422 with the name `ExtensionMembershipKeysRefusedError`, beside the `defaultRowScope` refusal and before the contributor list opens. Measured first: `mergeObjectDefinitions` copies only `fields`, `validations`, `indexes`, `label`, `pluralLabel` and `description`, so the extender's key registered and was then dropped in silence. The new test is `registry-extension-membership-keys-refused.test.ts` (6). It covers three refused shapes, including an empty map; an owner with no key; and two controls (an extension without the key folds, and the owner re-registers). ## Liveness and spec wording - `liveness/object.json`: `membershipKeys` and its 5 children are `live`, and `authorWarn` / `authorHint` are dropped in the same edit, as the row's note asked. The evidence is `stageRlsMembership`, `stageDeclaredMembershipKeys` and `readDeclaredKey`, with `membershipKeySource` as the second read point. `label` and `description` are live through `describeMembershipKeyDeclaration`, which is why explain names them. - The `membershipKeys` describe and TSDoc and the `rls.zod.ts` "Where a membership key comes from" section and module note no longer say "not applied". The accept set is unchanged. `check:generated --fix` regenerated 4 reference pages and `state-counts/object.md`. - **Changeset** `.changeset/25234-membership-keys-staged.md`: `minor` on `@objectstack/plugin-security`, `patch` on `@objectstack/objectql` and `@objectstack/spec`. ## Tests `packages/plugins/plugin-security/src/object-membership-keys.test.ts` has 21 tests on the real `SecurityPlugin` + `ObjectQL` over `SqlDriver`, `isolated` posture. In the fixture, `u_x`'s roster has S-1 active, S-3 inactive, and S-2 active but in organisation Y, so the key is `[S-1]`. - Read door, insert `check`, and a CONTROL without the declaration (zero rows). - Riders 1 and 2, as above. - The memo (one read across two objects) and laziness (zero reads for a policy that reads no key). The key extraction covers dot, index and SQL-bridge spellings, a string literal, and kernel names. - Fail-closed rows: no roster row, the bound (1,001 rows red, exactly 1,000 green), a `current_user` filter, a phantom-column filter, a key declared twice, and a resolver collision with the boot error. CONTROL: the resolver alone still supplies an undeclared key, with no error. - Explain at object level (positive and both miss kinds), at record level, and a CONTROL. **Ablations, each restored byte-identically from a saved copy:** - Declared staging removed from `stageRlsMembership`: 12 red, 9 green. - Write-side call reverted to `stageRlsMembership(context)`: only the insert-`check` test red. - Roster read run under the caller's context instead of `isSystem`: 11 red, including both rider-1 pins. - Layer 0 term dropped from the roster `where` (system context without `tenantId`): 8 red. - objectql refusal disabled: 4 refusal tests red, 2 controls green. **Suites on this head's code, through `os-verify-lock.sh`:** - `@objectstack/plugin-security`: 219 files, 4594 passed, 45 skipped. `typecheck` (incl. the test layer) exit 0. - `@objectstack/objectql`: 467 files, 8705 passed. `tsc --noEmit` exit 0. - `@objectstack/spec`: 792 files, 22953 passed, 1 todo. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 124 families. All 124 were run one at a time, with exit codes captured before any pipe. `--ran` reconciliation on head `e35ae5da`: 124 derived, 124 run, 0 NOT-MEASURED, 0 unrun, all exit 0. - One real finding, fixed in `e35ae5da`: `check:doc-authoring` refused the tracker id in three runtime log strings. The ids are gone from the strings. - Four first-pass non-zero codes were environment, not findings. `check-engine-split-ratio` exited 2 on the shallow clone and passed after `git fetch --shallow-since=2026-07-18`. `check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n` exited 3 (no dist) and passed after a full workspace build. - Those five were re-run on `e35ae5da`. The other 119 ran on `21cace53`, which differs from `e35ae5da` only by those three string literals. ## For the seat: the Tier H skills row, re-worded for `live` The row from report 6370454361 says "planned: no server stages it yet". With this PR it should read: > | `current_user.KEY` (a declared membership key) | the values of `valueField` on the rows of the roster object that declares `membershipKeys.KEY` whose `userField` is the caller and on which its `filter` holds, inside the caller's organization (for `IN`; read once per request even when the caller cannot read the roster; more than 1,000 values, a failed read, or a key a registered resolver also owns matches no rows) | --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01M4rJHRAZDFqLYQ9dHMH339 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 798d480 commit e5eab06

17 files changed

Lines changed: 1493 additions & 79 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
'@objectstack/objectql': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
feat(plugin-security): a declared membership key is staged per request, so a policy reading `current_user.KEY` reads the roster
8+
9+
Clause-②: no
10+
Metadata-protocol: semantic
11+
12+
`ObjectSchema.membershipKeys` is now applied. The security plugin's request staging (`stageRlsMembership`, the one producer of `ExecutionContext.rlsMembership`) gains the declarative second source beside the registered `rls-membership-resolver`: when a row policy about to compile reads `current_user.KEY` and an object declares `KEY`, the key is resolved from that roster object's rows.
13+
14+
- **What the key holds.** The distinct values of `valueField` on the roster rows whose `userField` is the signed-in user and on which the optional `filter` holds, inside the caller's own organization wall.
15+
- **How it is read.** Lazily and once per request: only a key a policy about to compile reads is resolved, on the read door and the write `check` alike, and one request that reads the key on several objects issues one read. The read is the platform's own system read: the caller needs no read on the roster object, and the caller's posture and audience do not change it (a portal contact and an internal member resolve a key the same way). The roster object's own row policies are not consulted.
16+
- **Every miss fails closed.** No roster row stages an empty set. More than 1,000 values (`MEMBERSHIP_KEY_MAX_VALUES`), a failed read, a `filter` that does not compile for the roster object (a shape that does not lower, a column the object lacks, any `current_user.*`), a key two objects declare, and a key the registered resolver also owns leave the key unresolved. Every policy reading such a key matches no rows, through the compiler's existing warning. A key both sources claim is logged as an error at boot, naming both owners, and again by the request that meets it.
17+
- **Explain.** The object-level `rls` layer names each key the policies read: its label and description, the roster object, `valueField`, `userField`, the filter, and the size it resolved to or why it did not, and the permission sets whose holders can write the roster object (whoever can write a roster row can grant the key). A record-grained rule reads `via: 'membership:KEY'`, and its detail names the roster row that links the record to the principal.
18+
- **`@objectstack/objectql`: an `extend` contribution cannot declare a membership key.** `SchemaRegistry.registerObject` refuses one that carries `membershipKeys`, with `INVALID_METADATA` (`status: 422`) and the error name `ExtensionMembershipKeysRefusedError`, naming the object and the extending package, beside the `defaultRowScope` refusal. The extension merge never carried the key onto the object, so it registered and then never resolved. The manifest path already refused it at authoring.
19+
- **`@objectstack/spec`**: the `membershipKeys` describe and the row-level security docs no longer say the key is not applied, and the liveness row is `live`.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -971,7 +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. |
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 set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. |
975975
| **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. |
976976
| **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) |
977977
| **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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +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. |
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 set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. |
178178
| **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. |
179179
| **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) |
180180
| **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/security/rls.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@ through one of two sources:
6060
- a **registered membership resolver** (ADR-0105 D11, the
6161
`rls-membership-resolver` service), code an installed plugin runs; or
6262
- a **declared membership key**: `ObjectSchema.membershipKeys` on the roster
63-
object whose rows define the set. Not applied yet: the security plugin
64-
does not read the declaration in this release.
63+
object whose rows define the set, which the security plugin stages
64+
per request beside the resolver's keys. A key both sources claim
65+
resolves from neither.
6566

6667
The platform stages no other key. A policy reading a key neither source
6768
supplies drops out and matches no rows (fail closed).

0 commit comments

Comments
 (0)