Repository navigation
feat(plugin-security): a declared membership key is staged per request, beside the registered resolver - #25528
Conversation
…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
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4rJHRAZDFqLYQ9dHMH339
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4rJHRAZDFqLYQ9dHMH339
📓 Docs Drift CheckThis PR changes 3 package(s): 32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 149 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…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
Contract reviewServed-tier: 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 ① Derived judgmentsSpec hits (the Liveness. The reader (plugin-security), judged against the ruling and its riders:
objectql Public surface. ② Semver levelChangeset
Clause-②: no — right as declared; the diff publishes no accept-set or public-surface change. ③ Boundary flags
Implemented-by: VERDICT: PASS Record rendered 2026-10-23T21:34Z on head |
席位核对 · ACCEPT ·
|
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 ofExecutionContext.rlsMembership, now stages theObjectSchema.membershipKeysthat PR 1 (#25513) declared, beside the registeredrls-membership-resolver. It also closes the objectql registryextenddoor that PR 1's acceptance notes named. Liveness flipsplannedtolive. PR 3 (the authoring rules) carriesCloses #25234.Serial against #25233 (PR #25515 also touches plugin-security): whichever lands second merges
main. This head already mergedmainat74496c64(which contains #25515).What a declared key resolves to, and when
current_user.KEY= the distinct values ofvalueField(F) on the rows of the declaring roster object X whoseuserField(U) is the caller and on whichfilterholds, inside the caller's own Layer 0 wall for X.object-membership-keys.ts#stageDeclaredMembershipKeys). From the policies about to compile, take thecurrent_user.*names theirusingandcheckread. They are parsed once per predicate text by@objectstack/formula'sparseCelToAst, after the compiler's ownsqlPredicateToCelbridge, 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 onqa_poandqa_asnissues one roster read (pinned).security-plugin-rls-filter.ts). The read door stages just before Layer 1 compiles, with thecompilablelist. The writecheckstages withwithCheck. Ausingand itschecktwin 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.readDeclaredKey): X whereU == userId, AND the compiledfilter, AND the caller's Layer 0 filter for X. It is projected toidand 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
{ isSystem: true }and nothing of the caller's. The middleware's totalisSystembypass 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.computeTenantLayer0VerdictovertenantLayer0InputOf(...)is computed withisPlatformAdmin: falsewhatever the caller holds, so no crossing applies, and a roster row in another organisation never counts.tenantLayer0InputOfis a new builder intenant-layer.ts. It holds the input literalcomputeLayeredRlsFilterused to inline, comments included, and the read door now builds its input through it, so the two cannot diverge.tenantId. With one, the engine scoped the driver to the active organisation on its own, which would have narrowed agroup-posture key below the wall. Thewherewall is therefore the only organisation scope, and the Layer 0 ablation below proves it is load-bearing.qa_supplier_contact, and its own find of the roster is refused withPERMISSION_DENIED, while its PO read resolves the key.MEMBER;EXTERNALwithaudience: 'external';PLATFORM_ADMINholdingadmin_full_access;TENANT_ADMIN) produce byte-identicalfindarguments, asserted in full: thewherewith the org term,fields, the limit, andcontext: { isSystem: true }.MEMBER,audience: 'external'andTENANT_ADMINcallers all read the same rows, so keys are not scoped to externals.Fail closed
[](the compiler drops the policy, as for any empty set)filterrefused byRLSCompiler.compileFilteragainst X's columns with no caller variables (non-lowering shape, a column X lacks, anycurrent_user.*)keys, or one it staged this request)SecurityPlugin.start, naming the key and every declaring object) and once at request timeA 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)
rlslayer'sdetailgains one sentence per key this object's policies read. It names the key with itslabelanddescription, 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 whichPermissionEvaluator.checkObjectPermissiongrantsinsertorupdateon X. An object whose policies read no key is explained byte-identically (control pinned).business_rlsrule readsvia: '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 projectsidbeside F, so no second read is made.ExplainDecisiongains 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).narrowsat object level (the existing composed-deny recognition), as for a resolver key.objectql: the
extenddoorSchemaRegistry.registerObjectrefuses anownership: 'extend'contribution carryingmembershipKeys. It answersINVALID_METADATA/ 422 with the nameExtensionMembershipKeysRefusedError, beside thedefaultRowScoperefusal and before the contributor list opens. Measured first:mergeObjectDefinitionscopies onlyfields,validations,indexes,label,pluralLabelanddescription, so the extender's key registered and was then dropped in silence. The new test isregistry-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:membershipKeysand its 5 children arelive, andauthorWarn/authorHintare dropped in the same edit, as the row's note asked. The evidence isstageRlsMembership,stageDeclaredMembershipKeysandreadDeclaredKey, withmembershipKeySourceas the second read point.labelanddescriptionare live throughdescribeMembershipKeyDeclaration, which is why explain names them.membershipKeysdescribe and TSDoc and therls.zod.ts"Where a membership key comes from" section and module note no longer say "not applied". The accept set is unchanged.check:generated --fixregenerated 4 reference pages andstate-counts/object.md..changeset/25234-membership-keys-staged.md:minoron@objectstack/plugin-security,patchon@objectstack/objectqland@objectstack/spec.Tests
packages/plugins/plugin-security/src/object-membership-keys.test.tshas 21 tests on the realSecurityPlugin+ObjectQLoverSqlDriver,isolatedposture. 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].check, and a CONTROL without the declaration (zero rows).current_userfilter, 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.Ablations, each restored byte-identically from a saved copy:
stageRlsMembership: 12 red, 9 green.stageRlsMembership(context): only the insert-checktest red.isSystem: 11 red, including both rider-1 pins.where(system context withouttenantId): 8 red.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 --noEmitexit 0.@objectstack/spec: 792 files, 22953 passed, 1 todo.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 124 families. All 124 were run one at a time, with exit codes captured before any pipe.--ranreconciliation on heade35ae5da: 124 derived, 124 run, 0 NOT-MEASURED, 0 unrun, all exit 0.e35ae5da:check:doc-authoringrefused the tracker id in three runtime log strings. The ids are gone from the strings.check-engine-split-ratioexited 2 on the shallow clone and passed aftergit fetch --shallow-since=2026-07-18.check:skill-examples,check:dual-build-cjs-loadsandcheck:i18nexited 3 (no dist) and passed after a full workspace build.e35ae5da. The other 119 ran on21cace53, which differs frome35ae5daonly by those three string literals.For the seat: the Tier H skills row, re-worded for
liveThe row from report 6370454361 says "planned: no server stages it yet". With this PR it should read:
🤖 Generated with Claude Code
https://claude.ai/code/session_01M4rJHRAZDFqLYQ9dHMH339