Skip to content

feat(spec): a roster object declares its membership keys, ObjectSchema.membershipKeys - #25513

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-3kk0c4
Oct 23, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-3kk0c4

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread
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

// 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 feat(spec): an object declares its default row scope, defaultRowScope, the RLS policy shape without object #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 feat(spec): an object declares its default row scope, defaultRowScope, the RLS policy shape without object #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: Spec design: declarative membership keys for RLS (current_user.<key> from a lookup on records whose user field is the current user) #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

…a.membershipKeys

ADR-0105 D11's metadata twin, spec half: MembershipKeySchema (userField,
valueField, filter, label, description; closed), the key map refusing a
non-snake_case name and the kernel context names, valueField equal to
userField refused, defineStack / composeStacks refusing one key on two
objects, the object-extension guidance, a planned liveness row, and the
rls.zod.ts examples that said the runtime stages keys nothing stages.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4rJHRAZDFqLYQ9dHMH339
…ide the RLS policy

The membership-key shape moves into security/rls.zod.ts beside
DefaultRowScopeSchema (the precedent's home), exports MembershipKeyName,
and the generated references, surfaces, dropped-refinement ledger rows
and strictness counts follow.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4rJHRAZDFqLYQ9dHMH339
…es the new collection key

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4rJHRAZDFqLYQ9dHMH339
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-core, @objectstack/spec, touching 23 documentable anchor(s). ⚠️ 9 changed file(s) yielded no anchor (packages/spec/api-surface/security.json, packages/spec/authorable-surface/data.json, packages/spec/authorable-surface/security.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-driven.mdx (via ObjectSchemaBase (symbol, a top-level const object))
  • content/docs/data-modeling/analytics.mdx (via valueField (literal, a string literal in MembershipKeySchema))
  • content/docs/protocol/objectql/security.mdx (via RowLevelSecurityPolicySchema (symbol, a top-level const))
  • content/docs/ui/dashboards.mdx (via valueField (literal, a string literal in MembershipKeySchema))
  • content/docs/ui/pages.mdx (via valueField (literal, a string literal in MembershipKeySchema))

⛔ 5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v13.mdx (via RowLevelSecurityPolicySchema (symbol, a top-level const))
  • content/docs/releases/v16.mdx (via valueField (literal, a string literal in MembershipKeySchema))
  • content/docs/releases/v17/17-0.mdx (via RowLevelSecurityPolicySchema (symbol, a top-level const))
  • content/docs/releases/v17/17-5.mdx (via RowLevelSecurityPolicySchema (symbol, a top-level const))
  • content/docs/releases/v9.mdx (via valueField (literal, a string literal in MembershipKeySchema))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 9 changed file(s) yielded no anchor (packages/spec/api-surface/security.json, packages/spec/authorable-surface/data.json, packages/spec/authorable-surface/security.json, …) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 144 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a285ff29a18ac6ae2a9a72943f58733ce31f299a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 175c276fff034f876d009946068cba6118aedad9 — the merge of head b2b98c417d23dadbccd99f7eff966c38cd3c0cc3 into base a285ff29a18ac6ae2a9a72943f58733ce31f299a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 175c276fff034f876d009946068cba6118aedad9 && git checkout 175c276fff034f876d009946068cba6118aedad9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a285ff29a18ac6ae2a9a72943f58733ce31f299a b2b98c417d23dadbccd99f7eff966c38cd3c0cc3 && git checkout -B drift-repro a285ff29a18ac6ae2a9a72943f58733ce31f299a && git merge --no-ff b2b98c417d23dadbccd99f7eff966c38cd3c0cc3

node scripts/docs-audit/affected-docs.mjs --json a285ff29a18ac6ae2a9a72943f58733ce31f299a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a285ff29a18ac6ae2a9a72943f58733ce31f299a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b2b98c417d23dadbccd99f7eff966c38cd3c0cc3
Local-runs: none
Scope: packages/spec/src/data/object.zod.ts, packages/spec/src/security/rls.zod.ts, packages/spec/src/stack.zod.ts + packages/spec/src/data/object-membership-keys.test.ts, packages/spec/src/compose-stacks-merge-collection-refusal.test.ts, packages/spec/src/compose-stacks-collection-pipe-arm.test.ts, packages/spec/src/system/metadata-form-zod-reconciliation.test.ts, packages/spec/src/contracts/rls-membership-resolver.ts (read, unchanged), packages/spec/src/shared/identifiers.zod.ts (read, unchanged), packages/spec/liveness/object.json, packages/spec/liveness/state-counts/object.md, packages/spec/dropped-refinements.baseline.json, packages/metadata-core/src/object-schema-fls-references.ts, packages/metadata-core/src/object-schema-fls-references.test.ts, packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts, .changeset/25234-object-membership-keys.md, packages/objectql/src/registry.ts (read, unchanged — the dev's acceptance note). Remainder, the lane seat's ACCEPT at TIER_DEFAULT: the five generated pages under content/docs/references/**, docs/audits/2026-07-unknown-key-strictness-ledger.counts/security.md, and the generated packages/spec/{api-surface,authorable-surface,declaration-map,export-origins,json-schema.manifest}/*.json.

Inputs read at 2026-10-23T17:18Z: card #25234 (body and all 8 comments; the design comment 6344270438 and the ruling 6355640929, letter A, 2026-10-23T02:04Z, are the binding text), PR #25513 (body, 26-file list, net diff origin/main...b2b98c41: +872/−37), and the 40 check-runs on the head. PR is a draft, Part of #25234.

① Derived judgments

Accept-set changes the diff implies, each against the ruled closed shape (userField, valueField, optional filter in the lowerable CEL subset with no current_user.* inside, optional label/description; reserved-key collisions refused; the bound a platform constant, not authorable):

  1. ObjectSchema.membershipKeys, optional, record-keyed MembershipKeysSchema (object.zod.ts, after defaultRowScope). Right. The home is the roster object, as ruled (sub-fork 1). Every object that parsed before still parses with identical output (the key is optional; control test pins 'membershipKeys' in r.data === false). At origin/main the identifier occurs nowhere in the tree, so no existing object changes meaning.
  2. MembershipKeySchema: strictObject with exactly userField, valueField (both SnakeCaseIdentifierSchema, required), filter, label, description (optional strings). Right — the five ruled members and nothing else; the test pins Object.keys(shape) to exactly those five. No object member (implied by the declaring object; guidance refuses it with the prescription), no size member (limit / maxValues refused with the platform-constant prescription), no member naming another key, so a chain (refusal 8), an authored bound (refusal 9) and object are unwritable, as the ruling requires.
  3. MEMBERSHIP_KEY_MAX_VALUES = 1000, an exported constant and not a member. Right: the ruled sub-fork 2 (platform constant, raised only by a ruling). Its enforcement (overflow fails closed) is PR ②'s; the constant is published here so PR ② reads it rather than re-spelling it.
  4. Key-name rule: MembershipKeyNameSchema = snake_case refined against RESERVED_RLS_MEMBERSHIP_KEYS imported from contracts/rls-membership-resolver.ts (a readonly string[], so .includes(name) typechecks; never a copy). Judged in a record-level superRefine so the author gets one custom issue at membershipKeys.KEY naming the broken rule, instead of zod's generic record-key message. Right: refusals 1 and 2. The accept set is identical to the design's z.record(MembershipKeyNameSchema, …) sketch; only the message path differs, which is an improvement. Tests cover all six reserved names (iterated from the contract) and three non-snake_case names.
  5. valueField === userField refused at valueField. Right: the half of refusal 6 the shape can judge without the merged object. The field-TYPE halves of refusals 5 and 6 (U a single-valued user reference; F a reference column) need the merged object's fields and are correctly left to PR ③, as the ruling's workload list places them.
  6. filter: z.string() with a describe naming the lowerable subset and the no-current_user.* rule, but no parse-time check. Right per the ruling: refusal 7 is the rls-predicate-* judge's with X as target (PR ③). The describe states the contract the authoring rule is to enforce, so the gap is declared, not silent. Noted in ③ as a flag, not a defect.
  7. label / description as z.string(), not the design sketch's I18nLabelSchema. Right as a deviation: the ruling's letter names no label type; RowLevelSecurityPolicySchema.label and DefaultRowScopeSchema in the same file are z.string(); the consumer (PR ②'s explain detail) takes plain text. Widening to an i18n union later is added and non-breaking.
  8. Home security/rls.zod.ts, not a new membership-key.zod.ts. Right: no public-surface difference (the exports are from @objectstack/spec/security either way), the precedent feat(spec): an object declares its default row scope, defaultRowScope, the RLS policy shape without object #24398 put DefaultRowScopeSchema in this file, and the dev measured that a new module would have rewritten two skills/** index files the claim excludes. Import direction: security/rls.zod → contracts/rls-membership-resolver (which imports nothing from data/), so the object.zod.ts comment "no cycle" holds.
  9. ObjectExtensionSchema guidance line for membershipKeys. Right: the accept set does not move (strict already refused the key); the refusal now names the owning-package rule (refusal 10 at the spec door).
  10. Refusal 3 at the stack doors (stack.zod.ts): duplicateMembershipKeyFindings keyed by name then distinct declaring-object NAME; the one-stack door runs beside the default-record-page check before the object-count early return; the composition door runs over the composed objects after the object-conflict strategy (an 'override'-replaced object no longer counts, a 'merge'-combined object counts once), under every manifest strategy. Both raise STACK_CROSS_REFERENCE_INVALID / 422. Right: the key namespace is the deployment's (ExecutionContext.rlsMembership is one bag). The registry half of refusal 3 (against other installed packages at the runtime gate) is PR ③'s, as the PR body says. Tests cover both doors, manifest: 'preserve', and controls.
  11. composeStacks objectConflict: 'merge' collection refusal picks membershipKeys up from the record-typed shape with no composer edit; the two literal pin lists gain the key in shape order (after searchableFields, before actions), with one fixture row. Right.
  12. Liveness planned for membershipKeys with authorWarn + authorHint, drilled into 5 planned children (only the container warns), flip condition written into the note (live on PR ②, citing stageRlsMembership, dropping authorWarn in the same edit). Right per the ruling ("liveness planned"); Spec property liveness is green on the head. State-counts 62 → 67 classified matches the 5 children (the container is counted under its children).
  13. Form ledger omit row for membershipKeys (metadata-form-zod-reconciliation.test.ts). Right: the ruling makes the Studio row a separate objectui card.
  14. Files beyond packages/spec/src, each read as the gate of a rule above:
    • metadata-core FLS classification membershipKeys: recordOf(ruleEntry): a key whose userField / valueField / filter mentions a denied field is dropped whole, the map goes when empty, the key NAME is never read as a field. Right — the same classification defaultRowScope: ruleEntry carries, and the classification pin reds without it (as on feat(spec): an object declares its default row scope, defaultRowScope, the RLS policy shape without object #24398). The three-leg test pins it.
    • metadata-protocol /meta/types count object: 46 → 47 with its history sentence. Right (one top-level key added).
    • dropped-refinements.baseline.json, hand-edited by design: 3 new entries (security/MembershipKey 1 site, security/MembershipKeyName 1, security/MembershipKeys 2) and membershipKeys + membershipKeys.valueType on the 9 schemas embedding ObjectSchema: 4 + 18 = 22 sites, 813 → 835; 242 → 245 schemas. Arithmetic checks. Right: the reserved-name and U≠F rules have no projectable arm in the closed list, so they belong on this ledger.
    • Generated surfaces: 10 new exports in api-surface/security.json (4 consts, 6 types), 6 in declaration-map, 10 in export-origins, 3 in json-schema.manifest, 1 + 5 authorable paths, strictness counts 21 → 22 / 8 → 9 (one new strictObject site in rls.zod.ts; the z.record map is not a site). All consistent with the source hunks.
  15. rls.zod.ts example fix: use cases 4/5, the two @example blocks and the using TSDoc no longer say the runtime stages territory_account_ids / team_member_ids (nothing in the tree does); a new "Where a membership key comes from" section names the two app-side sources and marks the declaration not applied yet; a sixth use case shows the portal key. Right: the design's carrier note, no published describe changes meaning (the using describe string itself is untouched; only TSDoc).
  16. Public surface: additive only — one optional object key, ten new exports, no removal, no renamed export, no changed describe.

Riders checked in text: rider 1 (Layer 0 system read, independent of audience/posture) and rider 2 (keys audience-neutral) are stated in the membershipKeys docblock and describe; their behavioural pin is PR ②'s.

The objectql registry extend door (the dev's acceptance note). Read: SchemaRegistry.registerObject refuses defaultRowScope on an ownership: 'extend' contribution by name (registry.ts ~3315) and has no membershipKeys analogue. I also read mergeObjectDefinitions (~432): it merges only fields, validations, indexes, label, pluralLabel, description, so a raw registerObject extend body carrying membershipKeys is accepted and the key is SILENTLY DROPPED from the served object — not carried onto the owner. So this is an ADR-0078 silent-inert door (not a cross-package reach / over-grant), reachable only outside the spec parse (ObjectExtensionSchema refuses the key at defineStack and every metadata write door). Judgment: outside this PR's claim surface and outside the ruling's PR ① workload list; the dev's carrier (PR ②, before the reader stages a key) is right. Escalated in ③ so it is named in PR ②'s dispatch order rather than left as "noted, not filed".

② Semver level

Changeset .changeset/25234-object-membership-keys.md: '@objectstack/spec': minor, '@objectstack/metadata-core': patch, body carries Clause-②: yes (widening) and Metadata-protocol: added; the PR body carries the same two line-initial declarations. Right:

  • added (one new optional authorable key) owes at least minor on one moved package — spec. metadata-core's change is a non-protocol classification → patch. Identical to the precedent feat(spec): an object declares its default row scope, defaultRowScope, the RLS policy shape without object #24398's .changeset/24338-object-default-row-scope.md (spec minor, metadata-core patch). All packages are in one fixed group, so the published bump is the same either way.
  • metadata-protocol moves only a test file: no entry owed.
  • Not skip-changeset: the diff publishes a new key and ten exports.
  • Check Changeset, check-changeset-no-major and the ADR-0087 registration gate (under Lint · PM dispatch-gates) are green on the head.

Clause-②: yes (widening) — correct arm: widening declared, nothing narrowed, no BREAKING banner owed.

③ Boundary flags

Dev's declared deviations (os-dev-report 6370454361):

  1. Schema home rls.zod.ts not a new module — answered in ①.8: accepted.
  2. label / description as z.string() — answered in ①.7: accepted; the ruling's letter does not fix the label type.
  3. File surface beyond packages/spec (metadata-core, metadata-protocol test, docs/audits counts) — answered in ①.14: each is the gate of a spec rule in this diff and reds otherwise; the precedent's shape. Accepted; the claim's "stop on breach; explain in the report" was honoured by the explanation.

Dev's open_questions: none. Dev's out_of_scope_findings (1): the objectql registry extend door — read and resolved in ① (silent drop, not reach). Escalation for the seat: write it into PR ②'s dispatch order as a named item (an EXTENSION_MEMBERSHIP_KEYS_REFUSED by-name refusal beside the defaultRowScope one, or a merge that carries the key, whichever PR ②'s design chooses), so the door is closed before stageRlsMembership reads the key. Not a blocker here: nothing reads the key at this head.

Which of the 13 ruled refusals land here vs PR ③: the PR body's table is right. Here: 1, 2 (shape + name rule), 3 (stack doors; the registry half is PR ③), the U = F half of 6, 8, 9, 10 at the spec door. PR ③: 4, 5, the type half of 6, 7, 11, 12, 13. PR ②: the bound's enforcement, the Layer 0 read, explain detail / via, liveness → live.

Part of #25234 posture: right. The ruling orders 1 → 2 → 3 with 4 in parallel and says cloud#3149 unblocks when 1–3 land; the card is the implementation card after its ruling and must stay open until PR ③ (or the seat's closing PR). Part-of PR must not also close its card, The card this PR closes must claim this branch and the single-writer-path guards are green. Escalation for the seat: decide now which PR carries Closes #25234 (PR ③ by the ruling's order) and record it on the card, so the card does not stay open by default after PR ③.

Tier H row for skills/objectstack-data/rules/security.md (correctly absent from this diff per the claim): the text in tier_h_skills_row reads consistently with the describe ("planned: no server stages it yet, so a policy reading it matches no rows"). It must land with, or be re-worded by, PR ② when liveness flips to live. Escalation for the seat: carry it as a Tier H item with the liveness-flip condition attached.

Compile-time reach: NoExcessObjectKeys does not judge members inside a record value (dev's reverse-probe leg 3), so an unknown member inside an entry is refused only at the runtime parse. Accepted: a property of every record-valued slot, and every door parses.

CI on the head at 2026-10-23T17:18Z: 24 check-runs success, 6 skipped (opt-in / matrix shells), 0 failure, and 10 still in_progress: Test Core (1/6 … 6/6), Build Core, Type Check · debt ledger, Type Check · workspace, Type Check · consumer gates. The verdict below stands on the diff, the card and the completed runs (Check Changeset, Governed Surface Queue Guard, Spec property liveness, Check PR Size, Build Docs, Lint · ESLint, Lint · CI/release/engine gates, Lint · docs & code guards, Lint · PM dispatch-gates, Type Check · source gates, Dogfood Regression Gate, the issue/branch guards); merge waits on the ten unfinished families — a failure in any of them re-opens this verdict. The PR is also still a draft; the dev marks it ready when they finish.

Implemented-by: claude/project-thread-3kk0c4
Reviewed-by: contract-review subagent of session_013odhA26dvzy7pQmevdTTvy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:spec seat 1 (seat post #6017) · os-tesla · session session_013odhA26dvzy7pQmevdTTvy · 2026-10-23T17:43Z

Head b2b98c417d · card #25234 (PR ① of 3) · claim 6368230661 · report 6370454361 · ruling 6355640929 (letter A) on design 6344270438 · landing plan 6370632236 · contract review 6370618995 (PASS).


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants