Skip to content

Commit 3c1eb63

Browse files
feat(spec,lint,plugin-security): a membership key may yield the caller's own roster rows (valueField: 'id') (#31174)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiPx4rNcMCWwQpaDfQfZiVSp)_ Fixes #31129 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: follow-up #31176 ## 维护者速读 - **改了什么:** 行级权限(RLS)的「成员键」现在可以写 `valueField: 'id'`:`current_user.KEY` 就是当前用户在名册对象上「自己那几行」的 id。员工表可以声明 `my_employee_ids`,请假单的规则写 `employee in current_user.my_employee_ids`,员工就只看到自己的请假单。 - **为什么改:** 执行裁决 6708428544(A,「同意」)。HR、服务台、门户这类应用里「人就是一条记录」,以前只能给每行加 `owner_id` 或建关联表,发布时才发现此路不通。 - **风险与回滚:** 这个键只产出 `userField` 等于当前用户的那些行,本身不放任何数据;比较对象仍被严格检查(拿它去比别的对象的列照样拒绝)。上限 1,000、超限失效关闭不变。只放宽验收,无数据迁移;回滚即 revert。 - **先看:** `packages/lint/src/validate-membership-key-declarations.ts`(`isRosterOwnId`:只认驱动提供的主键 `id`,作者自定义的同名字段照常判);`packages/lint/src/validate-rls-predicate-enforceability.ts`(`membershipKeyContextOf`:此键的目标对象即名册本身);`packages/plugins/plugin-security/src/object-membership-keys.ts`(读名册时只投影一次 `id`)。 - **两分钟验证:** `pnpm --filter @objectstack/plugin-security exec vitest run src/object-membership-keys.test.ts -t 31129`,预期 4 个用例全绿:员工 u_x 只看到 LR-1、LR-4,u_y 只看到 LR-2,跨组织那行被墙挡掉。 - **需要你做的:** 无,席位落地。 ## What changes A declared membership key (`ObjectSchema.membershipKeys`) may now take `valueField: 'id'`, the roster object's own primary key. `current_user.KEY` is then the ids of the caller's own rows of the roster, "the records I am" (an `employee`, `agent` or `customer` roster). The exclusion did not live in a Zod refine: `MembershipKeySchema` on `main` has none for `id`. It lived in the lint judge (`membership-key-value-field-invalid`, where `id` resolved to an untyped empty slice and was refused as "not a reference") and in the contract prose. This PR lifts it in both places: - **spec** (`packages/spec/src/security/rls.zod.ts`): the membership-key module docblock, the `valueField` JSDoc and `.describe()` now say F is a reference column on X or X's own `id`, and that such a key holds ids of X itself. Prose only; the U = F `superRefine` and the reserved-name refusal are unchanged. References regenerated (`content/docs/references/{data/object,security/rls}.mdx`). - **lint, declaration judge** (`validate-membership-key-declarations.ts`): new exported predicate `isRosterOwnId(column, verdict)`: true only for the driver-provisioned `id` (the object graph serves it as an injected column with an empty slice). An author-declared field named `id` is judged by its own definition, like any other column. `userField: 'id'` is still refused (no user reference). - **lint, policy-target judge** (`validate-rls-predicate-enforceability.ts`, refusal 11): `membershipKeyContextOf` uses the same predicate and sets such a key's target to the declaring object. So `employee in current_user.my_employee_ids` on a row whose `employee` looks up the roster is accepted, as is `id in …` on the roster itself; a column referencing another object, a non-reference column, or another object's `id` is still refused. - **lint, explanations** (`rule-explanations.ts`): `membership-key-value-field-invalid` and `rls-predicate-membership-key-target` explain the `id` case. - **plugin-security, resolver** (`object-membership-keys.ts`): the roster read projects `['id']` instead of `['id', 'id']` when F is `id`. Value extraction, de-duplication, the `MEMBERSHIP_KEY_MAX_VALUES` bound, the Layer 0 wall and fail-closed misses are the existing path, unchanged. - One changeset, `minor` on `@objectstack/spec`, `@objectstack/lint`, `@objectstack/plugin-security`. `id` is not treated as a reference column anywhere else: FLS masking (`object-schema-fls-references.ts`, `membershipKeys: recordOf(ruleEntry)`) only string-matches denied field names, and its existing test already serves a key with `valueField: 'id'`; `stack.zod.ts`, the registry's extension refusal, `metadata-protocol` and `security-plugin.ts` read only key names, never `valueField`; no dangling-reference audit or object-graph traversal reads it. Earlier rulings in the touched files were kept intact (grep `ruling|Ruling-ref|maintainer|⛔`): `rls.zod.ts` "Declarative membership keys (ADR-0105 D11, the metadata twin; #25234 ruling A)", "Raised only by a ruling" (the 1,000 bound), "#24164 ruling E′", and the maintainer's mainstream-platform criterion at L648; `validate-membership-key-declarations.ts` "[#25234 ruling A, refusals 3 to 6]"; `validate-rls-predicate-enforceability.ts` "#20158, #19995 ruling C", the two `⛔` notes (L531, L570) and the "refuse by ruling" comparand note; `object-membership-keys.ts` "[#25234 ruling A]" and "rider 1 of the ruling"; `rule-explanations.ts` "⛔ One copy". The only ruled refusal that moved is the `id` half of refusal 6, lifted by ruling 6708428544. ## Tests - `packages/spec/src/data/object-membership-keys.test.ts`: `valueField: 'id'` parses through `ObjectSchema` and the `object` metadata-type door. - `packages/lint/src/validate-membership-key-declarations.test.ts`: `id` accepted (with and without `filter`) and at the save door; an authored text field named `id` still refused (rule + severity); `userField: 'id'` still refused (rule + severity); the text-column refusal now also asserts severity and the new hint. - `packages/lint/src/validate-rls-predicate-enforceability.membership-keys.test.ts`: a new describe over an `hr_employee` roster with `my_employee_ids: { userField: 'user', valueField: 'id' }`: accepted (`employee in …`, negated, `id in …` on the roster); refused with `rls-predicate-membership-key-target` + severity `error` (reference to `hr_department`, user reference, text column, `id` of `hr_leave_request`); CLI door and save door agree; the explanation states the target. - `packages/plugins/plugin-security/src/object-membership-keys.test.ts` (shared harness, real `SecurityPlugin` + `ObjectQL` over SQLite): the acceptance case from the order: u_x sees `lr_1`, `lr_4` only, u_y sees `lr_2`, u_x's employee row in another organisation is dropped by the wall; projection is `['id']`; a caller with no employee row and an undeclared key see nothing; stub-host de-duplication, exactly-the-bound resolves, over-the-bound stays unstaged; explain names the declaration and links `lr_1` to row `e_05` via `membership:my_employee_ids`. Ablations (each through `scripts/ablation-replace.mjs`, mutation proven on disk, restored to the HEAD blob): disabling the `primaryKey` accept in `valueFieldFault` reds the two accept tests; dropping `targetOf.set(decl.key, decl.object)` reds the refusal cases (the key falls to "not judged"); reverting the resolver's projection reds the acceptance case. ## Acceptance notes - The Fable full review (comment below) raised two should-fixes and four nits; both should-fixes and two nits are fixed in this PR, and the delta re-review passed. The two left: the "column not declared" hint and the U = F schema message do not mention `id`; the first is unreachable for `id` (always present), the second fires only where `id` is the wrong answer. - Skills and the RLS guide ("the person is a record" example) are the `domain:skills` follow-up named in the ruling, not here. ## Verification All readings at head `bad5140f` (branched from `origin/main` `9f61e06e`, merged with `origin/main` `cbe20ab0` once before opening, regenerated after). <details><summary>Package tests and typecheck</summary> - `pnpm --filter @objectstack/lint test` :: exit 0 — Test Files 197/197, Tests 8340/8340 - `pnpm --filter @objectstack/plugin-security test` :: exit 0 — Test Files 248/248, Tests 5799 passed, 45 skipped - `pnpm --filter @objectstack/metadata-core test` :: exit 0 — Test Files 25/25, Tests 966/966 - `pnpm --filter @objectstack/spec exec vitest run src/data/object-membership-keys.test.ts` :: exit 0 — 27/27 - `pnpm --filter @objectstack/lint typecheck`, `pnpm --filter @objectstack/plugin-security typecheck`, `pnpm --filter @objectstack/spec typecheck` :: exit 0 - `eslint --no-inline-config` over the touched `.ts` files :: exit 0 - After the review fixes: the two lint membership-key files, `rule-id-barrel-exports` and `object-membership-keys.test.ts` re-run :: exit 0 (56/56, 25/25) </details> <details><summary>Generated artifacts</summary> - `pnpm --filter @objectstack/spec check:generated` :: exit 1 — `check:docs` stale (the two reference pages), then `check:generated --fix` :: exit 0, committed in `12458b7b`. </details> <details><summary>Derived gates (dispatch-gates --commands, 108 families)</summary> - 104 run :: exit 0 on the first pass. Four answered exit 3 (prerequisite) and were rerun after building their prerequisite: `node scripts/check-plugin-teardown-shape.mjs --self-test` (its pinned fixture commit fetched) :: exit 0; `pnpm --filter @objectstack/spec run check:skill-examples` (client-react built) :: exit 0; `pnpm check:i18n` (its build closure) :: exit 0; `pnpm check:dual-build-cjs-loads` :: exit 0. - `dispatch-gates --ran`: "108 derived famil(ies) accounted for — 108 run, 0 NOT-MEASURED". - Full dogfood not run: the title has no `!` and `Clause-②` has the `(widening)` arm. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2bjT2KeFnLTtWu2yPW <!-- signed: domain:spec#4 · marchtian · session_017ihL2eHDXrsKgGAUHq956U --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 620b6e6 commit 3c1eb63

12 files changed

Lines changed: 377 additions & 33 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/plugin-security': minor
5+
---
6+
7+
feat(spec,lint,plugin-security): a membership key may yield the caller's own roster rows (`valueField: 'id'`)
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: added
11+
12+
A declared membership key (`ObjectSchema.membershipKeys`) may now name `id`, the roster object's own primary key, as its `valueField`. `current_user.KEY` is then the ids of the signed-in user's own rows of that object, "the records I am". This covers the apps where the person is a record: an HR `employee`, a service-desk `agent`, a portal `customer`.
13+
14+
```ts
15+
// on the employee roster object
16+
membershipKeys: {
17+
my_employee_ids: { userField: 'user', valueField: 'id' },
18+
}
19+
// in a permission set
20+
rowLevelSecurity: [
21+
{ name: 'own_leave', object: 'hr_leave_request', operation: 'select', using: 'employee in current_user.my_employee_ids' },
22+
]
23+
```
24+
25+
The key yields only rows whose `userField` is the caller, so it grants nothing by itself. What a policy grants with it is the author's choice, as for any other key.
26+
27+
- `os validate`, `os build`, `os lint` and the server's save gate no longer refuse `valueField: 'id'` with `membership-key-value-field-invalid`. They still refuse any other column that is not a `lookup`, `master_detail` or `user` reference. `id` is still no user reference as `userField`.
28+
- `rls-predicate-membership-key-target` treats such a key as holding ids of the roster object itself. A policy may compare it with a column that references the roster, or with the roster's own `id`. A comparison with a column of another object, a non-reference column, or another object's `id` is refused as before.
29+
- The security plugin's roster read projects `id` once for such a key. The set is de-duplicated, bounded by `MEMBERSHIP_KEY_MAX_VALUES` and fails closed over the bound, as before.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,7 @@ const result = ApiMethod.parse(data);
458458
| Property | Type | Required | Description |
459459
| :--- | :--- | :--- | :--- |
460460
| **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. |
461-
| **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`. |
461+
| **valueField** | `string` | ✅ | The column of this object whose values form the set: a reference (`lookup`, `master_detail` or `user`), multi-valued allowed, or `id` for the ids of the signed-in user's own rows of this object (an employee roster's "the employee records I am"). A policy compares the set with a column that references the object the values belong to, or with `id` on that object. Never the same column as `userField`. |
462462
| **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. |
463463
| **label** | `string` | optional | Human-readable name of the set |
464464
| **description** | `string` | optional | What the set holds and who it is for |

‎content/docs/references/security/rls.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ const result = DefaultRowScopeSchema.parse(data);
171171
| Property | Type | Required | Description |
172172
| :--- | :--- | :--- | :--- |
173173
| **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. |
174-
| **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`. |
174+
| **valueField** | `string` | ✅ | The column of this object whose values form the set: a reference (`lookup`, `master_detail` or `user`), multi-valued allowed, or `id` for the ids of the signed-in user's own rows of this object (an employee roster's "the employee records I am"). A policy compares the set with a column that references the object the values belong to, or with `id` on that object. Never the same column as `userField`. |
175175
| **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. |
176176
| **label** | `string` | optional | Human-readable name of the set |
177177
| **description** | `string` | optional | What the set holds and who it is for |

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1786,9 +1786,12 @@ const MEMBERSHIP_KEY_VALUE_FIELD_INVALID_EXPLANATION: RuleExplanation = {
17861786
MEMBERSHIP_KEY_RESOLUTION,
17871787
'The set holds F\'s values, and a policy compares it with a column that references the same object, or ' +
17881788
'with `id` on that object. So F must be a reference column of X: `lookup`, `master_detail` or `user`. A ' +
1789-
'multi-valued one is allowed, and its values are flattened. A column X does not have makes the read ' +
1790-
'fail; any other type holds values no policy can compare with a record. F may not be U\'s column: the ' +
1791-
'schema refuses that, because the set would hold only the caller\'s own id.',
1789+
'multi-valued one is allowed, and its values are flattened. F may also be `id`, X\'s own primary key: the ' +
1790+
'set is then the caller\'s own rows of X, "the records I am" (an `employee` roster\'s ' +
1791+
'`my_employee_ids`), compared with a column that references X, such as a leave request\'s `employee`. A ' +
1792+
'column X does not have makes the read fail; any other type holds values no policy can compare with a ' +
1793+
'record. F may not be U\'s column: the schema refuses that, because the set would hold only the ' +
1794+
'caller\'s own user id.',
17921795
'F is judged on the object as it runs, an `objectExtensions` entry that replaces it folded in first.',
17931796
],
17941797
};
@@ -1824,15 +1827,18 @@ const RLS_PREDICATE_MEMBERSHIP_KEY_TARGET_EXPLANATION: RuleExplanation = {
18241827
covers: 'what a membership key can be compared with',
18251828
paragraphs: [
18261829
MEMBERSHIP_KEY_RESOLUTION,
1827-
'The set holds ids of the object F references; call it T (`sys_user` for a `user` field). A policy can ' +
1830+
'The set holds ids of the object F references; call it T (`sys_user` for a `user` field, and the ' +
1831+
'declaring object X itself when F is `id`, the caller\'s own rows of X). A policy can ' +
18281832
'only find those ids in a column that references T, or in the `id` of T itself. Tested against a ' +
18291833
'reference to another object, against a column that is not a reference, or against the `id` of another ' +
18301834
'object, the set never matches: `x in KEY` matches no row, and `!(x in KEY)` matches every row. Neither ' +
18311835
'is the policy that was written. The compiler accepts the comparison, because it knows the predicate ' +
18321836
'and not what the declaration collects; the rule reads the declaration.',
18331837
'So test the key against the column that references T — on a purchase order whose `supplier` is a lookup ' +
1834-
'to the supplier object, `supplier in current_user.portal_supplier_ids` — or against `id` when the ' +
1835-
'policy is on T itself. A key whose declaring object is not in the stack, or that two objects declare, ' +
1838+
'to the supplier object, `supplier in current_user.portal_supplier_ids`; on a leave request whose ' +
1839+
'`employee` is a lookup to an employee roster declaring `my_employee_ids` with `valueField: \'id\'`, ' +
1840+
'`employee in current_user.my_employee_ids` — or against `id` when the policy is on T itself. A key ' +
1841+
'whose declaring object is not in the stack, or that two objects declare, ' +
18361842
'is not judged here.',
18371843
],
18381844
};

‎packages/lint/src/validate-membership-key-declarations.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,41 @@ describe('validateMembershipKeyDeclarations — refusal 6: `valueField` is a ref
141141
expect(judge({ objects: [roster({ valueField: 'contact_user' }), supplier] })).toEqual([]);
142142
});
143143

144+
it("[#31129 ruling A] accepts `id`, the roster row's own primary key: the caller's own rows", () => {
145+
expect(judge({ objects: [roster({ valueField: 'id' }), supplier] })).toEqual([]);
146+
expect(judge({ objects: [roster({ valueField: 'id', filter: undefined }), supplier] })).toEqual([]);
147+
});
148+
149+
it("[#31129] only the driver's primary key counts: an authored text field named `id` is still refused", () => {
150+
const authoredId = parsed({
151+
name: 'srm_supplier_contact',
152+
label: 'Supplier contact',
153+
fields: { ...ROSTER_FIELDS, id: { type: 'text', label: 'Id' } },
154+
membershipKeys: { portal_supplier_ids: { userField: 'user', valueField: 'id' } },
155+
});
156+
const findings = judge({ objects: [authoredId, supplier] });
157+
expect(brief(findings)).toEqual([{ rule: MEMBERSHIP_KEY_VALUE_FIELD_INVALID, path: `${KEY_PATH}.valueField` }]);
158+
expect(findings[0].severity).toBe('error');
159+
expect(findings[0].message).toContain('is a `text` field, not a reference');
160+
});
161+
162+
it("[#31129] `id` is X's own key only as `valueField`: as `userField` it is still no user reference", () => {
163+
const findings = judge({ objects: [roster({ userField: 'id', valueField: 'supplier' }), supplier] });
164+
expect(brief(findings)).toEqual([{ rule: MEMBERSHIP_KEY_USER_FIELD_INVALID, path: `${KEY_PATH}.userField` }]);
165+
expect(findings[0].severity).toBe('error');
166+
expect(findings[0].message).toContain('not a user reference');
167+
});
168+
169+
it('[#31129] the save door accepts an object write whose key takes `valueField: \'id\'`', () => {
170+
const saved = runRuntimeAuthoringRules({
171+
type: 'object',
172+
item: roster({ valueField: 'id' }),
173+
context: { objects: [supplier], permissions: [] },
174+
rlsMembershipResolverKeys: [],
175+
});
176+
expect(saved.errors.filter((f) => f.rule.startsWith('membership-key-'))).toEqual([]);
177+
});
178+
144179
it('refuses a column the object does not declare, and a column that is no reference', () => {
145180
const missing = judge({ objects: [roster({ valueField: 'supplierr' }), supplier] });
146181
expect(brief(missing)).toEqual([{ rule: MEMBERSHIP_KEY_VALUE_FIELD_INVALID, path: `${KEY_PATH}.valueField` }]);
@@ -149,6 +184,8 @@ describe('validateMembershipKeyDeclarations — refusal 6: `valueField` is a ref
149184
const text = judge({ objects: [roster({ valueField: 'supplier_name' }), supplier] });
150185
expect(brief(text)).toEqual([{ rule: MEMBERSHIP_KEY_VALUE_FIELD_INVALID, path: `${KEY_PATH}.valueField` }]);
151186
expect(text[0].message).toContain('is a `text` field, not a reference');
187+
expect(text[0].severity).toBe('error');
188+
expect(text[0].hint).toContain('or at `id`');
152189
});
153190
});
154191

‎packages/lint/src/validate-membership-key-declarations.ts‎

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,14 @@
2828
* wrong ones. A registry-injected column (`owner_id`, `created_by`) is a
2929
* column of the object and is judged by its injected definition.
3030
* - {@link MEMBERSHIP_KEY_VALUE_FIELD_INVALID} (refusal 6) — F is not a column
31-
* of the merged object, or not a reference (`lookup`, `master_detail` or
32-
* `user`). The set holds F's values, and a policy compares it with a column
33-
* that references F's target or with that target's `id` (refusal 11), which
34-
* only a reference has. `multiple: true` is allowed: its values are
35-
* flattened. F naming U's column is refused by `MembershipKeySchema` itself.
31+
* of the merged object, or neither a reference (`lookup`, `master_detail` or
32+
* `user`) nor `id`, the object's own primary key. The set holds F's values,
33+
* and a policy compares it with a column that references F's target or with
34+
* that target's `id` (refusal 11): a reference names its target, and `id`
35+
* names X itself, so the key holds the caller's own rows of X (#31129 ruling
36+
* A, "the records I am"). Any other column has no target to compare with.
37+
* `multiple: true` is allowed: its values are flattened. F naming U's column
38+
* is refused by `MembershipKeySchema` itself.
3639
* - {@link MEMBERSHIP_KEY_DECLARED_TWICE} (refusal 3) — one key on two objects.
3740
* `defineStack` and `composeStacks` refuse it inside one stack; this is the
3841
* door they do not see, the runtime publish gate, where the other declaration
@@ -105,7 +108,11 @@ function str(v: unknown): string {
105108
/** The platform's user object, the target of every `user` field. */
106109
const USER_OBJECT = 'sys_user';
107110

108-
/** The field types a membership key's `valueField` may have. */
111+
/**
112+
* The field types a membership key's `valueField` may have. The one other
113+
* column it may name is `id`, X's own primary key ({@link isRosterOwnId}),
114+
* which has no field type: the driver, not a definition, provisions it.
115+
*/
109116
const VALUE_FIELD_TYPES: ReadonlySet<string> = new Set(['lookup', 'master_detail', 'user']);
110117

111118
/** One `membershipKeys` entry, where the stack declares it. */
@@ -219,12 +226,32 @@ function userFieldFault(meta: GraphField): string | null {
219226
return null;
220227
}
221228

222-
/** F's verdict: `null` when it is a reference, else the reason. */
223-
function valueFieldFault(meta: GraphField): string | null {
229+
/** F's verdict: `null` when it is a reference or X's own `id`, else the reason. */
230+
function valueFieldFault(column: Extract<ColumnVerdict, { kind: 'ok' }>): string | null {
231+
if (column.primaryKey) return null;
232+
const { meta } = column;
224233
if (meta.type && VALUE_FIELD_TYPES.has(meta.type)) return null;
225-
return `is a \`${meta.type ?? 'untyped'}\` field, not a reference (\`lookup\`, \`master_detail\` or \`user\`)`;
234+
return `is a \`${meta.type ?? 'untyped'}\` field, not a reference (\`lookup\`, \`master_detail\` or \`user\`) or \`id\``;
226235
}
227236

237+
/**
238+
* [#31129 ruling A] Does a membership key's resolved `valueField` name X's
239+
* own primary key, so the set is the caller's own rows of X? Only the `id` the
240+
* driver provisions: the graph serves it as an injected column with an empty
241+
* slice. An author-declared field that happens to be named `id` is judged by
242+
* its own definition, like any other column. Shared by the declaration judge
243+
* and the policy-target judge, so the two cannot disagree about it.
244+
*/
245+
export function isRosterOwnId(column: string, verdict: { injected?: true; meta?: GraphField }): boolean {
246+
return column === 'id' && verdict.injected === true && !verdict.meta?.type;
247+
}
248+
249+
/** One of U / F, resolved on the merged object — see {@link columnOf}. */
250+
type ColumnVerdict =
251+
| { kind: 'ok'; meta: GraphField; primaryKey: boolean }
252+
| { kind: 'missing'; candidates: ReadonlySet<string> }
253+
| { kind: 'skip' };
254+
228255
/**
229256
* The column one of U / F names, judged on the merged object: `ok` with its
230257
* field slice, `missing` with the object's declared names, or `skip` when the
@@ -236,14 +263,15 @@ function columnOf(
236263
object: string,
237264
column: string,
238265
tied: ReadonlySet<string> | undefined,
239-
): { kind: 'ok'; meta: GraphField } | { kind: 'missing'; candidates: ReadonlySet<string> } | { kind: 'skip' } {
266+
): ColumnVerdict {
240267
if (!column || tied?.has(column)) return { kind: 'skip' };
241268
const verdict = resolveFieldPath(graph, object, column);
242269
if (!verdict || isUnjudgeable(verdict)) return { kind: 'skip' };
243270
if (verdict.kind === 'field-unknown') return { kind: 'missing', candidates: verdict.candidates };
244271
if (verdict.kind !== 'ok') return { kind: 'skip' };
245272
// `id` maps to an empty slice: the driver's primary key, never a reference.
246-
return { kind: 'ok', meta: verdict.meta ?? {} };
273+
// As U it is no user reference; as F it is the roster row's own id.
274+
return { kind: 'ok', meta: verdict.meta ?? {}, primaryKey: isRosterOwnId(column, verdict) };
247275
}
248276

249277
/**
@@ -319,7 +347,7 @@ export function validateMembershipKeyDeclarations(
319347
suggestName(decl.valueField, value.candidates),
320348
});
321349
} else if (value.kind === 'ok' && decl.valueField !== decl.userField) {
322-
const fault = valueFieldFault(value.meta);
350+
const fault = valueFieldFault(value);
323351
if (fault) {
324352
findings.push({
325353
severity: 'error',
@@ -329,7 +357,7 @@ export function validateMembershipKeyDeclarations(
329357
message:
330358
`\`valueField\` '${decl.valueField}' ${fault}, so the set holds values no policy can compare ` +
331359
'with a record',
332-
hint: 'Point `valueField` at a `lookup`, `master_detail` or `user` column, or reference the values\' object',
360+
hint: 'Point `valueField` at a `lookup`, `master_detail` or `user` column, or at `id` for the caller\'s own rows',
333361
});
334362
}
335363
}

0 commit comments

Comments
 (0)