Repository navigation
Commit dc532e8
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
- .changeset
- content/docs/references
- api
- data
- security
- system
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- metadata-core/src
- metadata-protocol/src
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- liveness
- state-counts
- src
- data
- security
- system
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
971 | 971 | | |
972 | 972 | | |
973 | 973 | | |
| 974 | + | |
974 | 975 | | |
975 | 976 | | |
976 | 977 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
174 | 174 | | |
175 | 175 | | |
176 | 176 | | |
| 177 | + | |
177 | 178 | | |
178 | 179 | | |
179 | 180 | | |
| |||
448 | 449 | | |
449 | 450 | | |
450 | 451 | | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
451 | 462 | | |
452 | 463 | | |
453 | 464 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
32 | | - | |
| 32 | + | |
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| |||
266 | 266 | | |
267 | 267 | | |
268 | 268 | | |
269 | | - | |
| 269 | + | |
270 | 270 | | |
271 | 271 | | |
272 | 272 | | |
| |||
275 | 275 | | |
276 | 276 | | |
277 | 277 | | |
278 | | - | |
| 278 | + | |
279 | 279 | | |
280 | 280 | | |
281 | 281 | | |
| |||
0 commit comments