Skip to content

feat(plugin-security): a declared membership key is staged per request, beside the registered resolver - #25528

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

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

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread
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 (fix(plugin-security)!: collectRLSPolicies applies an object's declared default row scope when no held set carries a row policy for it #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.ai/code/session_01M4rJHRAZDFqLYQ9dHMH339

…stered resolver

stageRlsMembership now resolves the `ObjectSchema.membershipKeys` the
policies about to compile read: per key, lazily, memoized per request, as
the platform's own Layer 0 system read of the roster object (caller's wall,
no crossing, no Layer 1), bounded at MEMBERSHIP_KEY_MAX_VALUES, every miss
failing closed. A key the registered resolver also owns, or that two
objects declare, resolves from neither, with an error at boot and request.

Explain names each key the rls layer reads (label, description, roster
object, fields, filter, size or miss, and the roster's writers), and a
record-grained rule reads via 'membership:KEY' with the linking roster row.

objectql: registerObject refuses an `extend` contribution carrying
`membershipKeys` (ExtensionMembershipKeysRefusedError), beside the
defaultRowScope refusal; the merge never carried the key.

The shared Layer 0 input builder moves to tenant-layer.ts
(tenantLayer0InputOf) so the roster read and the read door build one input.
Liveness for membershipKeys flips planned -> live.

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

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/plugin-security, @objectstack/spec, touching 71 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/liveness/object.json, packages/spec/liveness/state-counts/object.md, packages/spec/src/security/rls.zod.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 74496c64b0637103f87cceec3f37932a48813e65.

⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/liveness/object.json, packages/spec/liveness/state-counts/object.md, packages/spec/src/security/rls.zod.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: packageId (4 routes)
  • 3 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 40 pages), current_user (literal, 35 pages), organization_id (literal, 38 pages)
  • 11 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 149 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 74496c64b0637103f87cceec3f37932a48813e65 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b38533b0c17392f1f004a3f2c2407973591e669e — the merge of head 5f35aff38a34a4aa5b170d2803e12ce43b3d0dcd into base 74496c64b0637103f87cceec3f37932a48813e65, 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 b38533b0c17392f1f004a3f2c2407973591e669e && git checkout b38533b0c17392f1f004a3f2c2407973591e669e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 74496c64b0637103f87cceec3f37932a48813e65 5f35aff38a34a4aa5b170d2803e12ce43b3d0dcd && git checkout -B drift-repro 74496c64b0637103f87cceec3f37932a48813e65 && git merge --no-ff 5f35aff38a34a4aa5b170d2803e12ce43b3d0dcd

node scripts/docs-audit/affected-docs.mjs --json 74496c64b0637103f87cceec3f37932a48813e65

⚠️ 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 74496c64b0637103f87cceec3f37932a48813e65 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…warn

The optional-error sink contract refuses a logger whose `error` and `warn`
are both optional: an absent `error` must fall back to a `warn` that exists.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5f35aff38a34a4aa5b170d2803e12ce43b3d0dcd
Local-runs: none
Scope: packages/spec/src/data/object.zod.ts, packages/spec/src/security/rls.zod.ts + packages/plugins/plugin-security/src/object-membership-keys.ts, packages/plugins/plugin-security/src/security-plugin-rls-filter.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/tenant-layer.ts, packages/plugins/plugin-security/src/explain-engine.ts, packages/plugins/plugin-security/src/rls-compiler.ts (read, unchanged: compileFilter and the bag merge), packages/core/src/security/tenant-layer0-verdict.ts (read, unchanged), packages/core/src/security/assemble-execution-context.ts (read, unchanged), packages/objectql/src/registry.ts, packages/objectql/src/plugin.ts (read, unchanged: the manifest extend path), packages/spec/liveness/object.json, .changeset/25234-membership-keys-staged.md, the two new test suites

Judged against ruling 6355640929 (letter A, riders 1 to 4), design 6344270438 and landing plan 6370632236 on card #25234. Inputs: the card body and all 10 comments, the PR body, its 17-file list, the net diff against main at the merge base 74496c64, and the 42 check-runs on the head (36 success, 6 skipped, all six opt-in or matrix shells; 0 failed). The remainder of the diff (4 generated reference pages, state-counts/object.md) is the lane seat's ACCEPT at TIER_DEFAULT.

① Derived judgments

Spec hits (the hits paths). object.zod.ts changes the membershipKeys TSDoc and describe text only; rls.zod.ts changes two docblocks only. No key, type, refinement, default or export moves: the accept set is unchanged, the describe now states the contract the reader meets instead of "not applied yet", and the 4 regenerated reference pages carry the same sentence. Right. MEMBERSHIP_KEY_MAX_VALUES = 1000 (rls.zod.ts:798) stays the one constant, not authorable, as the ruling's sub-fork 2 fixed it.

Liveness. membershipKeys and its 5 children flip planned to live, authorWarn / authorHint dropped in the same edit as PR 1's note asked. Every evidence anchor named exists on this head (stageRlsMembership in security-plugin-rls-filter.ts, stageDeclaredMembershipKeys, readDeclaredKey, describeMembershipKeyDeclaration in object-membership-keys.ts, membershipKeySource in explain-engine.ts). Spec property liveness check green. Right.

The reader (plugin-security), judged against the ruling and its riders:

  • Every miss fails closed. readDeclaredKey returns unresolved for no user, tenant wall deny, filter refused, over the bound (rows or flattened values), and a thrown read or schema load; stageOneKey returns unresolved for a key two registry objects declare and for a resolver collision. Only a resolved outcome writes the bag; a zero-row read writes []. Downstream, compileFilter (rls-compiler.ts:1071 to 1086) turns an unresolved variable or an empty set into deniedBy and, with no granting sibling, RLS_DENY_FILTER, with the existing WARN. The filter is judged by the same compileFilter with an empty principal context and X's field guard, so current_user.* inside it, a phantom column and a non-lowering shape all land on RLS_DENY_FILTER and are treated as a miss; null is also treated as a miss, which is the safe reading of a return that otherwise means "nothing applied". Right. Pinned by the real-engine suite (1,001 rows red / 1,000 green; current_user filter; phantom column; declared twice; collision; CONTROL no-declaration reads zero rows).
  • The roster read is confined to the caller's own tenant wall, with no platform-admin crossing (rider 1). The wall is computeTenantLayer0Verdict(tenantLayer0InputOf({..., isPlatformAdmin: false})). In computeTenantLayer0Verdict (core) the only crossing is isPlatformAdmin && posturePermitsCrossTenant; with isPlatformAdmin pinned false the none outcomes left are structural (single posture, tenancy disabled, object without organization_id), exactly the cases where the read door applies no wall to X either; isolated with no active org and group with an empty access set give deny, which is a miss. The find runs under context: { isSystem: true } with no tenantId, so the where carries the wall as its only org scope and the middleware's total isSystem bypass keeps X's own row policies (which may read this very key) out of the read: no cycle, no caller posture, no audience. Right. Pinned: the unit test asserts byte-identical find arguments (where with the org term, fields, limit, context) for MEMBER, EXTERNAL/external, PLATFORM_ADMIN with admin_full_access, and TENANT_ADMIN; the real-engine test shows the portal set's own roster read is PERMISSION_DENIED while its PO read resolves the key, and a roster row in organisation Y never counts.
  • Audience-neutral (rider 2). No code path reads audience or posture; MEMBER, external and TENANT_ADMIN resolve the same rows (pinned). Right.
  • The bound is enforced. limit: MEMBERSHIP_KEY_MAX_VALUES + 1; more than 1,000 rows, or more than 1,000 distinct values after flattening a multi-valued F, is over-bound. Right.
  • A collision is refused, not shadowed. resolverOwns is resolver.keys includes the key OR the resolver staged it on this request (noteResolverStagedKeys); on a hit the resolver's bag value is deleted before Layer 1 compiles, the outcome is unresolved, and an error names both owners once per logger and again at boot (SecurityPlugin.start via resolverCollisionsOf). A key two objects declare resolves from none of them. The one case left as-is is a bag value the resolver did not supply: rlsMembership is a NonEntryExecutionContextField in core's assemble-execution-context.ts, so no transport entry point assembles it from a request; it is server-constructed, and leaving it is the resolver's existing precedence. Right, and declared in the PR body.
  • Lazy, per key, per request. Keys are read off the policies' using and check through parseCelToAst(sqlPredicateToCel(...)), reserved kernel names excluded; the memo is a WeakMap on the execution context; both compile sites (computeLayeredRlsFilter before Layer 1, computeWriteCheckFilter with withCheck) call stageRlsMembership(context, policies, object). Right; one read across qa_po + qa_asn pinned, zero reads for a key-free policy pinned.
  • Nothing an ordinary object without membershipKeys does changes. stageDeclaredMembershipKeys returns before touching state when no policy reads a non-reserved current_user.* name; a read of an undeclared key returns undefined with no log and no bag write, so the compiler's existing WARN/deny path is untouched; stageResolverMembership is the old body plus bookkeeping; tenantLayer0InputOf reproduces the read door's former input literal term for term (posturePermitsCrossTenant = isPrivate || tenancyDisabled || isBetterAuthManaged, as at origin/main:168); explain adds text and via only when membershipKeyOutcomesFor returns outcomes (CONTROL pinned at object and record level). Right. The declared-key staging also does not alter the compiler's reserved-name refusal at rls-compiler.ts:985 to 991.
  • Explain (rider 3). Object-level detail names the key with label/description, X, F, U, the filter, the resolved size or the miss reason, then "Whoever can write X can grant this key" with the permission sets checkObjectPermission grants insert or update on X. Record-level business_rls rule carries via: 'membership:KEY' and names the linking roster row from the staging read's id projection (no second read). ExplainDecision gains no field. Right. Observation, not a defect: when a record is excluded or no value links, via still names the first key the object's policies read; the design asked for via on the policy's rule and this is that.

objectql extend door (landing-plan item 2). registerObject refuses an ownership: 'extend' contribution carrying membershipKeys beside the defaultRowScope refusal, INVALID_METADATA / 422, name ExtensionMembershipKeysRefusedError. Measured on this head: mergeObjectDefinitions (registry.ts:432 to 466) copies only fields, validations, indexes, label, pluralLabel, description, so the key was being dropped in silence (ADR-0078); the manifest path (plugin.ts:9322 to 9335) copies a closed key list and ObjectExtensionSchema is strict, so the direct registerObject call was the only door. EXTENSION_MEMBERSHIP_KEYS_REFUSED_CODE is not re-exported from packages/objectql/src/index.ts (nor is its defaultRowScope sibling), so no public surface moves. Right. 6 tests, 2 controls.

Public surface. object-membership-keys.ts is not re-exported from plugin-security's index; SecurityPlugin.stageRlsMembership is private and its new parameters are optional. No published accept set or public surface widens or narrows. Clause-②: no is right.

② Semver level

Changeset .changeset/25234-membership-keys-staged.md: @objectstack/plugin-security minor, @objectstack/objectql patch, @objectstack/spec patch. Both declaration lines are present in the PR body and the changeset body: Clause-②: no and Metadata-protocol: semantic.

  • Metadata-protocol: semantic is the right kind: no key is added, refused or tombstoned; the meaning of an authorable key (membershipKeys) moves from "declared, not applied" to "applied", which is the semantic definition in AGENTS.md.
  • Under the ruled level rule (check-changeset-no-major.mjs: a kind other than none asks for at least one moved package at minor+; semantic under a bare no is a kind like the others), plugin-security minor satisfies it; objectql and spec, which publish no surface change, take patch. Check Changeset is green on the head.
  • Clause-②: no with no arm matches ①: the accept set and public surface are unchanged.

Clause-②: no — right as declared; the diff publishes no accept-set or public-surface change.

③ Boundary flags

  1. PR body: "This head already merged main at aa2dbab7." Stale. The head's second parent is 74496c64 (git show 5f35aff3 --format=%P), which is also the merge base against origin/main; aa2dbab7 was the earlier merge (21cace53). The dev records the correction in os-dev-report 6374979058 (the relay has no PR-edit op). Every other body statement I could check holds: 17 files, +1493 / −79 (sums of the file list), check-runs 36/6/0, the fail-closed table, the pins, the deviations. The body's suite counts (219 files / 4594) are the pre-merge run and the report's (220 / 4607) the post-merge run; both are labelled as such, so not a second stale statement. Answered, not blocking; the seat may ask the dev to edit the line when the relay allows.
  2. Rider 4 (serial with Security: an external organisation role whose members do not see the member directory and reach data only through explicit grants (portal users on hosted environments) #25233). PR feat(security): an external organization role — portal users read their own identity rows and reach data only through explicit grants #25515 (9019af9f) is an ancestor of the merged 74496c64 (git merge-base --is-ancestor yes); this PR landed second and merged main after it, as the rider orders. Answered.
  3. Deviation: tenantLayer0InputOf extracted. Read term for term against origin/main's inlined literal: identical inputs, so the read door's wall is unchanged. Answered.
  4. Deviation: no tenantId on the system context. The where carries the caller's Layer 0 as the only org scope (unit pin asserts the org term); with a tenantId the engine would have narrowed a group key to the active org below the wall. Answered; the direction is correct and the pin holds it.
  5. Tier H skills/objectstack-data/rules/security.md row. Not in this diff (no skills/** path; Governed Surface Queue Guard green). The re-worded live text is in the PR body and report 6374979058 for the seat to land as its own governed PR, per landing plan 6370632236. Carried forward, not this PR's.
  6. PR 3 dependencies. The publish-time refusals 5 to 7 and 11 to 13 (field types, filter lowering, policy comparison) are the authoring rules' (PR 3), as the ruling orders; until then a wrong declaration fails closed at runtime with a WARN, which is this PR's contract. Open by design, not a flag on this PR.
  7. open_questions: none in report 6374979058. The design's dependency on Security: an external organisation role whose members do not see the member directory and reach data only through explicit grants (portal users on hosted environments) #25233 (premise 5) is settled by the ruling itself (EXTERNAL rung evaluates RLS).
  8. Observation (no action). readDeclaredKey keeps only string values of F; a non-string value is dropped, which narrows (fails closed), never widens.

Implemented-by: claude/project-thread-3kk0c4
Reviewed-by: session_013odhA26dvzy7pQmevdTTvy

VERDICT: PASS

Record rendered 2026-10-23T21:34Z on head 5f35aff38a34a4aa5b170d2803e12ce43b3d0dcd.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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

Head 5f35aff38a · card #25234 (PR ② of 3) · claim 6368230661 · report 6374979058 · ruling 6355640929 (letter A) on design 6344270438 · landing plan 6370632236 · contract review 6375197134 (PASS).

  • Shape: draft, base main, body line Part of #25234 (PR ③ carries Closes). +1493 / −79. check-governed-merges: not governed, 1572 lines ≤ 3000 ⇒ ordinary queue landing. The Tier H skills/objectstack-data/rules/security.md row is not in this diff; it goes as its own governed PR.
  • Contract: stageRlsMembership stages a declared membershipKeys entry only when a policy reads it. Every miss leaves the key unresolved, and the compiler then denies. The roster read stays inside the caller's own Layer 0 wall with the platform-admin crossing off. The 1,000-value bound is enforced, a resolver collision is refused rather than shadowed, and an object with no membership keys takes an early return identical to main. objectql refuses membershipKeys on an extend contribution by name (422).
  • Changeset: minor @objectstack/plugin-security, patch @objectstack/objectql and @objectstack/spec, Metadata-protocol: semantic, Clause-②: no. Check Changeset success.
  • Serial order: feat(security): an external organization role — portal users read their own identity rows and reach data only through explicit grants #25515 landed first; this head merged main at 74496c64, which contains it. The stale aa2dbab7 line in the PR body is corrected by the seat.
  • Gates pinned to the head: 42 check-runs on 5f35aff38a, 36 success + 6 skipped, 0 red.
  • Landing: pr_ready + automerge_enable through the relay now. Spec design: declarative membership keys for RLS (current_user.<key> from a lookup on records whose user field is the current user) #25234 stays pm:dispatched for PR ③.

Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation protocol:data size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants