Skip to content

feat(security)!: resolve current_user.queues kernel-side and reserve the queues membership key - #27044

Merged
os-zhuang merged 5 commits into
mainfrom
claude/project-thread-1d234m
Oct 28, 2026
Merged

os-zhuang merged 5 commits into
mainfrom
claude/project-thread-1d234m

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #27016

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: skills/objectstack-data/rules/security.md lists the kernel names a membership key may not take; queues is added there (paid for by a deletion in the same file; the ratchet row is lowered 3693 → 3687 to sit at the measurement). The current_user.* placeholder table and the queue authoring guide are #26475's.

维护者速读

  • 改了什么:queues 加入 RESERVED_RLS_MEMBERSHIP_KEYS;plugin-security 从 ownership-queue 服务的 queuesOf 解析 current_user.queues(与认领闸门 isMember 同一份成员答案),每个请求最多一次,且只在即将编译的策略读到这个键时才解析。
  • 为什么改:按 spec+server: an ownership queue primitive (queue entity, membership, claim) and declarative assignment rules, so an app routes ownerless records without app-level round-robin (maintainer ruling D6 = A) #25769 裁决 6457475657(甲)——一个内核侧生产者;应用 resolver 提供的 queues 被筛掉并失败即关闭。
  • 风险与回滚:声明为收窄(席位 1 裁定):membershipKeys 再也不能声明名为 queues 的键,resolver 的 queues 答案被丢弃并 WARN。仓内声明/resolver 普查为 0。回滚 = revert 本 PR。
  • 先看:packages/plugins/plugin-security/src/kernel-queue-membership.ts(新生产者)、rls-compiler.ts 的 userContextOf 一行、security-plugin-rls-filter.ts 的 stageRlsMembership、.changeset/27016-queues-reserved-membership-key.md。
  • 两分钟验证:pnpm --filter @objectstack/plugin-security exec vitest run src/kernel-queue-membership.test.ts src/rls-reserved-membership-keys.test.ts,预期全绿。
  • 需要你做的:无,席位落地。

What changes

  • spec (packages/spec/src/contracts/rls-membership-resolver.ts): queues joins RESERVED_RLS_MEMBERSHIP_KEYS, with a doc comment saying what it holds, who resolves it and why an app may not. The module header's "What does NOT belong here" and the keys doc name it too. rls.zod.ts (the "Where a membership key comes from" docblock) and the ObjectSchema.membershipKeys .describe() list it among the kernel names; the reference pages are regenerated (gen:docs, 4 files).
  • plugin-security:
    • New kernel-queue-membership.ts: stageKernelQueues resolves queuesOf(userId, tenantId) once per request and stores it on the context under a module-private symbol, with the principal and organization it was resolved for; kernelQueuesOf answers only for that identity. So a caller cannot forge it (context.queues is never read), and a context copied for another user or organization does not carry it.
    • stageRlsMembership stages it first, and only when a policy about to compile reads current_user.queues (policiesReadCurrentUserKey, a sibling of membershipKeyReadsOf on the same parsed-once CEL walk in object-membership-keys.ts). Both compile sites (read using, write check) already call it with their policies.
    • RLSCompiler.userContextOf sets queues: kernelQueuesOf(executionContext) beside positions, and RLSUserContext declares it.
    • SecurityPlugin.ownershipQueueMembership() resolves the same ownership-queue service as ownershipQueues() (the claim gate's), for queuesOf.
    • The existing screens need no change: the resolver screen in stageResolverMembership and the compiler merge both refuse a reserved key by name, so with queues on the list an app resolver's or a bag's queues is dropped and the policy reads the kernel value or fails closed.
  • lint: KERNEL_KEY_RUNTIME_TYPE gains queues: 'array' (the probe already binds a key with no ExecutionContext field as an array). The three os explain texts that list the kernel keys add queues; the LISTS sentence no longer says ExecutionContext declares every list key, since queues has no ExecutionContext field. The unknown-variable fix: line says "Read a kernel key (…)" instead of "Read a pre-resolved key (…)": the longer key list put it at 201 characters against its 200 bound.
  • skills: the reserved-name list in skills/objectstack-data/rules/security.md.

ADR-0142 D3 amended in place. The D3 sentence that named the queue service as the registered IRlsMembershipResolver for queues (option B's mechanism) now says what the tree does: queues is a reserved key that plugin-security resolves kernel-side from the queue service, the way it resolves positions, and an app resolver answering queues is screened out and fails closed. It carries an inline Amended note citing ruling 6457475657 (#25769, letter A). The Execution plan bullet about the PR that already landed is left as history.

Why

Ruling 6457475657 on #25769 (letter A): queues is reserved and resolved kernel-side in plugin-security, one producer, like positions; an app resolver that supplies it is screened and fails closed. Options B (plugin-sharing's own resolver) and C (drop the key) were not chosen.

On the declaration: the card said yes (widening) / none. Re-measured, RESERVED_RLS_MEMBERSHIP_KEYS is also read by MembershipKeyNameSchema (rls.zod.ts, the refine on ObjectSchema.membershipKeys key names), so the change refuses an authored membershipKeys.queues at parse. Seat 1 ruled it no (narrowing) / narrowed, with ADR-0087 not-required (no-migration-prescription) and a **The remedy.** line, since the new name is the author's to choose.

Readers and producers (measured on origin/main 7eb613c)

  • RESERVED_RLS_MEMBERSHIP_KEYS readers: MembershipKeyNameSchema (spec), stageResolverMembership and RLSCompiler.userContextOf (plugin-security), membershipKeyReadsOf (plugin-security, excludes reserved names from declared-key staging), lint's PRERESOLVED_USER_KEYS. Every one reads the list by import, so no copy needed editing.
  • Pins that count the list: spec object-membership-keys.test.ts (it.each over the list, no edit), plugin-security rls-reserved-membership-keys.test.ts (CELLS must equal the list: queues cell added), lint KERNEL_KEY_RUNTIME_TYPE and the explanation-text pins.
  • Who moves: zero membershipKeys declarations named queues and zero resolvers declaring queues in packages, examples, qa, skills or content.
  • queuesOf vs isMember in plugin-sharing's OwnershipQueueService: both answer through specsReachUser with the access purpose (switched-off positions confer nothing on either). A new test there pins that they agree for every user, queue and organization of the fixture.
  • plugin-sharing's own sitting-record path (SharingService.callerQueues) also reads queuesOf; it is the owner match's disjunct, not the RLS key, and is unchanged.

Tests

  • plugin-security/src/kernel-queue-membership.test.ts (12 cases, through the whole plugin's getReadFilter): a member reads { queue: { $in: [their queues] } } in the active organization, and another organization's set in another; a non-member gets the deny sentinel, byte-identical to no queue service; the set agrees with isMember; resolved once per request and not at all when no policy reads the key; no user or a throwing service fails closed (one WARN); an app resolver answering queues is dropped with the reserved-key WARN and the kernel set stands, a non-member it tried to seat reads nothing, and with no queue service it does not fill the gap; a queues field or bag entry on the context is never read; a copied context for another user or organization does not carry the set.
  • plugin-security/src/rls-reserved-membership-keys.test.ts: the queues cell (bag refused byte-identically on using and check, kernel value wins).
  • plugin-sharing/src/ownership-queue.test.ts: queuesOf agrees with isMember (both answers occur, so not vacuous).
  • Probe: owner_id == null && queue in current_user.queues compiles to {"$and":[{"owner_id":{"$null":true}},{"queue":{"$in":[…]}}]}.

Ablation (on committed heads, restored with git checkout HEAD): compiler line set to queues: undefined gives 5 failed (member, organization, isMember agreement, resolver screened, queues kernel-wins cell); queues removed from the built spec list gives 4 failed (is reserved, resolver screened, no-service gap, caller-forged field).

Verification

Runs on head 8660c53ef (worktree off origin/main 7eb613c3):

  • Under the lock: @objectstack/spec 738 files / 22131 passed (1 todo); @objectstack/plugin-sharing 57 files / 1279 passed; @objectstack/lint 170 files / 7510 passed; @objectstack/plugin-security 234 files / 5065 passed, 45 skipped; typecheck for all four packages exit 0. (The first lint run found two pins this diff moves: the unknown-variable fix: line at 201 characters, and the engine-judge test's copy of the explanation's key list. Both fixed in 8660c53ef, then lint and plugin-security re-ran green.)
  • pnpm --filter @objectstack/spec check:generated: only content/docs/references/** was stale; regenerated with gen:docs and committed.
  • dispatch-gates --commands: 115 derived commands. 111 exit 0 on the first sweep. check-skills-token-ratchet was red (+3 tokens on security.md), paid by a deletion in the same file, then exit 0. check:skill-examples, check:dual-build-cjs-loads and check:i18n exited 3 (built output missing) and exit 0 after the dogfood closure build.
  • Full dogfood (owed by the narrowing arm), @objectstack/dogfood^... closure build plus the whole suite under the lock: 344 files passed, 3 skipped, 1 failed (348); 2788 tests passed, 20 skipped, 2 failed. Both failures are packaged-action-disabled-dispatch-live.dogfood.test.ts (acceptance[0] and [5]/[2]), and they reproduce when that file runs alone. Not this diff: the test reads the first showcase_task unsorted ($top=1) and expects it not yet done, and here that row was already done: true before the action ran (before equals after). Nothing in the showcase or dogfood stacks reads current_user.queues (git grep), so the new staging never runs there. Reported to the seat as an out-of-scope finding.

Human Minimum

Each item is one rule from
CONTRIBUTING.md › Human Minimum,
which links the AGENTS.md text that states it.

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (No formatter ran. pnpm lint is CI's repo-wide run.)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed (gen:docs; check:generated otherwise up to date).
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzhD6Fqfyavauv2KoGA

@os-zhuang
os-zhuang marked this pull request as ready for review October 28, 2026 03:00
@os-zhuang
os-zhuang enabled auto-merge October 28, 2026 03:00
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/lint, @objectstack/plugin-security, @objectstack/spec, touching 41 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (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.

33 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 3230bdbf76844966623e21db5ac940e4b83fd1b9.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/security/rls.zod.ts) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 55 of 207 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 — 150 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 3230bdbf76844966623e21db5ac940e4b83fd1b9 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 3230bdbf76844966623e21db5ac940e4b83fd1b9

⚠️ 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 3230bdbf76844966623e21db5ac940e4b83fd1b9 → 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: 8660c53ef566e869a3da97d3afdac7f511e25b25
Local-runs: none
Scope: packages/spec/src/contracts/rls-membership-resolver.ts, packages/spec/src/data/object.zod.ts, packages/spec/src/security/rls.zod.ts, skills/objectstack-data/rules/security.md, packages/lint/src/rule-explanations.ts, packages/lint/src/validate-rls-predicate-enforceability.ts, packages/lint/src/validate-rls-predicate-enforceability.test.ts, packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts, packages/plugins/plugin-security/src/kernel-queue-membership.ts, packages/plugins/plugin-security/src/kernel-queue-membership.test.ts, packages/plugins/plugin-security/src/object-membership-keys.ts, packages/plugins/plugin-security/src/rls-compiler.ts, packages/plugins/plugin-security/src/rls-reserved-membership-keys.test.ts, packages/plugins/plugin-security/src/security-plugin-rls-filter.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-sharing/src/ownership-queue.test.ts + packages/spec/src/contracts/ownership-queue-service.ts, packages/spec/src/data/object-membership-keys.test.ts, packages/plugins/plugin-sharing/src/ownership-queue-service.ts, packages/plugins/plugin-sharing/src/sharing-service.ts, packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts, packages/plugins/plugin-security/src/ownership-queue-gate.ts, packages/plugins/plugin-security/src/security-plugin-object-inputs.ts, packages/plugins/plugin-security/src/explain-engine.ts, docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md, scripts/check-skills-token-ratchet.mjs, scripts/check-adr-0087-registration.mjs

Inputs read: card #27016 (body, claim 6458269562, amendment 6458307227, os-dev-report 6459486195), ruling 6457475657 and brief 6455628190 on #25769, PR #27044 body and file list (21 paths, equal to git diff 7eb613c3..8660c53e), the net diff, the cited code at the head, and the head's check-runs. Not the dispatch order and not the seat's conclusions.

Check-runs on the head at the time of this record: 47 total, 27 success, 8 skipped, 0 failure, 12 in progress — Test Core (1/8) to (8/8), Build Core, Type Check UI, Type Check · workspace, Type Check · consumer gates. Green already: Governed Surface Queue Guard, Check Changeset, PR-body guards, Lint · CI, release & engine gates, Lint · PM dispatch-gates, Spec property liveness, Dogfood Regression Gate, Test UI. The verdict below stands on the diff and the cited code; the landing is Tier H (skills/**) and waits for the maintainer's hand and for the twelve running jobs regardless.

① Derived judgments

  1. queues joins RESERVED_RLS_MEMBERSHIP_KEYS (rls-membership-resolver.ts). Right: ruling 6457475657 letter A, verbatim. Every reader reads the list by import — MembershipKeyNameSchema (rls.zod.ts, the refine), stageResolverMembership and RLSCompiler.userContextOf (plugin-security), membershipKeyReadsOf, lint's PRERESOLVED_USER_KEYS = new Set(RESERVED_RLS_MEMBERSHIP_KEYS) — so no copy drifted. The three pins that count the list move with it: spec object-membership-keys.test.ts (it.each over the list, no edit), plugin-security CELLS (gains the queues cell), lint KERNEL_KEY_RUNTIME_TYPE (queues: 'array').
  2. Accept-set narrowing at ObjectSchema.membershipKeys: a key named queues is now refused at parse by the refine. Right, and this is the arm the amendment 6458307227 declared. Census re-run at the merge-base 7eb613c3 over packages, examples, qa, skills, content: 24 non-test files mention membershipKeys; none declares a key named queues (the only queues: lines are stack.zod.ts's stack slot, not a membership key); no resolver lists queues in keys; no rlsMembership bag carries it. Zero movers — the body's claim holds.
  3. One producer, kernel-queue-membership.ts. stageKernelQueues reads host.ownershipQueueMembership(), which SecurityPlugin resolves from the same OWNERSHIP_QUEUE_SERVICE slot ownershipQueues() (the claim gate's) resolves, and calls queuesOf(userId, tenantId ?? null). In plugin-sharing's OwnershipQueueService both queuesOf and isMember go through specsReachUser with newPass() = access purpose, so a switched-off position confers nothing on either; the new plugin-sharing pin walks every (user, org, queue) of the fixture and is non-vacuous. Agreement with the claim gate holds for the same (user, queue, organization) triple. Right.
  4. Storage under a module-private symbol, bound to (userId, tenantId). kernelQueuesOf answers only when both match the context it is asked on; { ...ctx } copies the symbol-keyed own property but a copy for another principal or organization answers undefined; the explain engine's delegator context (explain-engine.ts) is a fresh buildContextForUser object, so it resolves on its own. context.queues and context.rlsMembership.queues are never read: the compiler merge skips reserved names by name, and userContextOf takes queues from kernelQueuesOf alone. No leak across users or organizations found. Right.
  5. Fail closed. No userId (the ADR-0138 guest principal is principalKind: 'guest' with no user id, so it is covered) → never resolved; no service → resolveOnto returns without staging; queuesOf throws → one WARN, not staged; a non-array answer → not staged; a frozen context → caught, not staged. Unresolved current_user.queues takes the compiler's existing unresolved-variable path → deniedBy → RLS_DENY_FILTER; a member of no queue gets [] → empty-membership → deny. Never all queues. Right.
  6. Lazy, once per request. policiesReadCurrentUserKey walks the parsed CEL after sqlPredicateToCel, so the legacy queue IN (current_user.queues) spelling (the ADR's own) is detected too; both compile sites (computeRlsFilter at the compilable call, computeWriteCheckFilter at withCheck) pass their policies. PENDING is a WeakMap per context, so a settled or failed resolution is not retried on the same context. Right.
  7. Resolver screen. stageResolverMembership drops a resolver's queues by name with the RESERVED context key WARN; the compiler merge is the second screen for a pre-seeded bag. Both are the existing screens, unchanged; adding the name to the list is what closes them. Right.
  8. No ExecutionContextSchema field. The ruling says "the way it resolves positions"; positions is a schema field core writes at context assembly. The dev departed from that shape on purpose (a schema field is a forgeable string key; the service is a plugin service). Right — the lint's kernelKeyProbe binds a key with no declared field as an array, which is what the key holds, and the LISTS sentence in the explanation was reworded to stop claiming ExecutionContext declares every list key. Consistent.
  9. Lint. queues becomes a kernel list key: field in current_user.queues stays accepted, current_user.queues == x now earns the type fault instead of the unknown-variable finding. The three os explain texts and the engine-judge pin list it; the fix: line's reword "Read a pre-resolved key" → "Read a kernel key" keeps the one-line bound (160 characters before the suggestName suffix, against 166) and matches the explanation's own vocabulary ("The kernel-resolved current_user keys"). Right.
  10. Published prose. rls.zod.ts docblock, ObjectSchema.membershipKeys .describe(), and skills/objectstack-data/rules/security.md list the kernel names with queues; the four content/docs/references/** pages are gen:docs output of those two spec edits (not reviewed; check:generated is CI's). The skill file's prose is accurate to the code: the refusal it teaches is the refine's.

② Semver level

Changeset .changeset/27016-queues-reserved-membership-key.md: @objectstack/spec, @objectstack/plugin-security, @objectstack/lint, @objectstack/skills (packages/skills, published) at minor. Matches what the diff publishes: the kind is narrowed, so minor on every package the diff moves; plugin-sharing changes only a test, publishes nothing, needs no entry. Nothing extra. Both declaration lines are line-initial in the PR body and the changeset body — Clause-②: no (narrowing) and Metadata-protocol: narrowed — and agree with the amendment 6458307227. The title carries !, the body a **BREAKING** banner, exactly one ADR-0087 marker in the gate's form, not-required (no-migration-prescription) …, a category CATEGORIES accepts, and the **The remedy.** line (rename the key; the new name is the author's). Check Changeset and PR-body guards are green on the head.

Clause-② read against the tree: the narrowing is real (the refine refuses the name) and the widening half is the platform's own value where before there was none, which the changeset states. Correct.

③ Boundary flags

  • Dev flag (os-dev-report, out-of-scope 2): current_user.queues inside a field permission when resolves only if a row policy on the same context staged it; compileRowPredicate (security-plugin-object-inputs.ts:397) never stages. Answered: read in the tree, correct, and the outcome is the entry locking (fail closed), the same as declared membership keys since Spec design: declarative membership keys for RLS (current_user.<key> from a lookup on records whose user field is the current user) #25234. Not a widening and not this card's; no card owed from this record.
  • Dev flag (out-of-scope 1): two packaged-action-disabled-dispatch-live.dogfood.test.ts failures on the dev's box, reproducing alone, in a stack nothing of which reads current_user.queues. Not this diff; the seat routes it. Dogfood Regression Gate is green on the head.
  • open_questions: none declared.
  • Boundary noted, no fix on this head: the set is resolved for the ACTIVE organization (tenantId), as ADR-0142 D3 defines it and as plugin-sharing's callerQueues already reads it; the claim gate and sitsInCallersQueue ask membership in the ROW's organization (queueOrganizationOf). Under a group posture whose Layer 0 wall admits another organization's rows, a policy queue in current_user.queues would match that organization's rows carrying a same-named authored queue the caller does not sit in there. This is the ADR's definition and the pre-existing D3 read path, not something this PR introduces or could narrow alone; the authoring guide (execution-plan item 4) should say "active organization" in so many words.
  • Boundary noted, fail closed: a context object whose userId or tenantId is changed in place AFTER staging answers undefined (identity mismatch) and is never re-resolved on that object (PENDING settled). No such in-place mutation exists in the tree after context assembly (resolve-authz-context.ts writes at build; the explain engine builds a fresh dctx).
  • Escalation, should-fix outside this PR: ADR-0142 D3 on main still says "the queue service is the registered IRlsMembershipResolver for that one key" — option B's mechanism. The ruling chose A and this tree implements A. The decision record now names a mechanism the code does not use; a one-line amendment citing ruling 6457475657 is owed on a docs/adr PR (Tier H, outside this card's file surface). Not a defect of this head.
  • Skills token ratchet: security.md measures 3687 tokens against its 3693 row (base: 3693 at zero headroom); the +3 of , \queues`was paid by deleting the clause "not a stamp on every data row," — a genuine deletion, not a re-wrap, and the lost clause is a second anti-pattern the roster sentence still implies. The row was not lowered; the script makes lowering optional ("may be lowered … encouraged").Lint · repo & tooling gates` is among the green runs that carry this gate.

④ Design judgment

The narrowing, read against the published spec on main: before this PR the spec allowed any snake_case key not on the reserved list, so membershipKeys: { queues: … } and a resolver's queues were accepted, while ADR-0142 D3 already named current_user.queues as a platform-computed set. Reserving the name makes the spec's own refusal real — a declaration or resolver that could supply queues could seat any caller in any queue, which is exactly the "wall an app could redefine would not be a wall" reasoning the list already carries for accessible_org_ids. No declared capability is taken away at the consumer: the capability the ADR declares (a policy reads the caller's queues) is delivered on this head, and a key that could only ever have conflicted with it is withdrawn. Zero movers measured.

Tried to break it:

  • Forge: context.queues, context.rlsMembership.queues, a resolver answering queues, a bag pre-seeded before the resolver runs — each path either never reads the name or drops it by name; the compiler merge is the choke point both faces pass. Held (and pinned in kernel-queue-membership.test.ts through the whole plugin's getReadFilter).
  • Leak: a context copied for another principal (delegation) or organization — identity-bound answer, undefined. Held.
  • Stale: the same context served twice in a request — resolved once; a membership change mid-request is not re-read, which is the same per-request semantics positions, org_user_ids and the resolver bag have. Acceptable.
  • Fail open: no service, throwing service, no user, guest, non-array answer, frozen context — all unresolved → deny. Never a permissive filter. Held.
  • Agreement with the claim gate: same service slot, same specsReachUser / access pass; differs only in which organization is asked, noted in ③ as the ADR's own definition.

Findings:

  • must-fix: none.
  • should-fix (follow-up, not this PR's surface): amend ADR-0142 D3's resolver sentence to the ruled mechanism (one kernel-side producer in plugin-security from the ownership-queue service), citing 6457475657.
  • should-fix (optional, this file): lower the skills/objectstack-data/rules/security.md ratchet row from 3693 to the measured 3687, the ratchet's ordinary direction.

Implemented-by: claude/project-thread-1d234m
Reviewed-by: session_01WGufzfhobWEgUUrjz4rrbh

VERDICT: PASS

…security.md token ceiling

Claude-Session: https://claude.ai/code/session_01WGufzhD6Fqfyavauv2KoGA
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l and removed size/m labels Oct 28, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Test Core (8/8) failed on the previous head 8660c53e in @objectstack/rest: one test, src/data-nested-object-door.test.ts:288, "the cap: a condition matching more related records than the cap is REFUSED, never truncated; at the cap it is served whole", failed with Test timed out in 5000ms (1 failed / 6546 passed in the package).

Not this PR's:

The new head e37497434 (the two contract-review should-fixes) has a fresh CI run, and that run counts as this failure's one re-run.


Generated by Claude Code

@os-zhuang
os-zhuang added this pull request to the merge queue Oct 28, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:spec seat 1 (#6017) · session session_01WGufzfhobWEgUUrjz4rrbh · 2026-10-28T03:51Z

Head e37497434c3983744185c320e40e5f698d1e3251. Card #27016, a child of #25769 under ruling 6457475657 (letter A). Claim 6458269562 with amendment 6458307227 (narrowing arm), dev report 6459486195.

  • Contract review: owed at CONTRACT_REVIEW_TIER on the narrowing face. The review (6459684406) at 8660c53e was a PASS with no must-fix. It found:
    • queues joins RESERVED_RLS_MEMBERSHIP_KEYS. Because MembershipKeyNameSchema reads that list, an authored membershipKeys.queues is refused, so this is a narrowing. The census at the merge base is zero.
    • plugin-security's kernel-queue-membership.ts is the one producer. It reads queuesOf off the same ownership-queue slot the claim gate's isMember reads, and both answer through specsReachUser over the access pass; a plugin-sharing pin holds them equal. The value is stored once per request, keyed to the user and organization.
    • It fails closed in every degraded case: no user, no service, a throw, a non-array answer, or a frozen context. It never answers all queues.
    • An app resolver's queues is dropped by name with the RESERVED WARN, and the compiler merge skips reserved names on every path. No path reads context.queues.
    • The lint rule binds queues as an array, and the reworded fix line ("Read a kernel key") fits the rule-explanation vocabulary.
  • Since then: e3749743 closes both should-fixes.
    • The ADR-0142 D3 sentence that described option B's mechanism is amended in place to ruling A, with an inline Amended note citing 6457475657.
    • The security.md ratchet row is lowered 3693 → 3687 to sit at the measurement.
    • The seat added the ADR line to the body and corrected the ratchet sentence.
  • Shape: Fixes #27016, 23 files, +482/−29. One root changeset: @objectstack/spec, @objectstack/plugin-security, @objectstack/lint and @objectstack/skills, each minor, with a BREAKING banner, ADR-0087 not-required (no-migration-prescription) and a **The remedy.** line. Clause-②: no (narrowing) and Metadata-protocol: narrowed.
  • Gates: check-runs on this head are 42 success, 10 skipped and 0 red. mergeable_state is clean. The previous head's red Test Core (8/8) was the known rest cap-test timeout objectql: since #26930 a predicate delete walks every matched row one by one; 1,001 rows cost ~1.7 s (18×), and rest's nested-object-door cap test now nears its 5 s budget in the merge queue #27004 (PR comment 6459939973), and it is green here.
  • Landing: Tier H (skills/objectstack-data/**). os-zhuang, a GOVERNED_APPROVERS account, APPROVED at 8660c53e, and that review is neither dismissed nor superseded. Under the queue guard's ruling the approval's commit is not pinned, so the approval lifts Tier H, and this seat lands it: automerge_enable in this act.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head e37497434c3983744185c320e40e5f698d1e3251 · 2026-10-28T04:53Z

Triage seat, under the maintainer-ruled gap rule (#27062, 「可以,就按这个执行」), condition (b). The record on file (6459684406, PASS) names 8660c53e. e3749743 (03:14Z) adds an ADR-0142 D3 amendment and a ratchet row. The head has been ready for about 98 minutes with no record and no claim on it. Triage runs the delta review at CONTRACT_REVIEW_TIER. If the lane's reviewer claims or posts a record on this head first, triage withdraws.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e37497434c3983744185c320e40e5f698d1e3251
Local-runs: none
Scope: packages/spec/src/contracts/rls-membership-resolver.ts, packages/spec/src/data/object.zod.ts, packages/spec/src/security/rls.zod.ts, docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md, skills/objectstack-data/rules/security.md + scripts/check-skills-token-ratchet.mjs, packages/plugins/plugin-security/src/kernel-queue-membership.ts, packages/plugins/plugin-security/src/kernel-queue-membership.test.ts, packages/plugins/plugin-security/src/rls-compiler.ts, packages/plugins/plugin-security/src/security-plugin-rls-filter.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/object-membership-keys.ts, packages/plugins/plugin-security/src/rls-reserved-membership-keys.test.ts, packages/plugins/plugin-sharing/src/ownership-queue-service.ts, packages/spec/src/contracts/ownership-queue-service.ts, packages/formula/src/rls-predicate.ts, packages/lint/src/validate-rls-predicate-enforceability.ts, .changeset/27016-queues-reserved-membership-key.md, docs/adr/README.md, scripts/adr-index.mjs, .github/workflows/lint.yml

Delta review on a moved head. The record on file (6459684406, PASS, Reviewed-by the lane's session) names 8660c53ef566e869a3da97d3afdac7f511e25b25. git diff 8660c53e prr-27044 --stat is exactly two files, docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md (+6/−2) and scripts/check-skills-token-ratchet.mjs (+4/−1), one commit e3749743; the rest of the tree is byte-identical to the head the earlier PASS judged. The net diff against the merge base 7eb613c3 is 23 files (+482/−29), equal to the PR's file list.

Inputs read: card #27016 (body; claim 6458269562; amendment 6458307227; os-dev-report 6459486195), ruling 6457475657 and brief 6455628190 on #25769 (fetched by gh api; the ruling's issue_url is #25769 and its html_url is the one the ADR note links), PR #27044 body and file list, the net diff, the two delta hunks, the cited code at prr-27044, and the head's check-runs. Not the dispatch order and not the seat's ACCEPT (6460206914), which is verified below, not adopted.

Check-runs on e3749743: 52 total, 42 success, 10 skipped, 0 failure, 0 pending. Green include Governed Surface Queue Guard, Check Changeset, PR-body guards, every Lint · job (the one carrying the step "Published skills token ratchet (#10473) + its self-test", lint.yml:5346–5352, among them), Spec property liveness, Test Core (1/8)…(8/8), Dogfood Regression Gate, Build Core, every Type Check · job. The previous head's red Test Core (8/8) (the @objectstack/rest cap-test 5 s timeout, #27004, PR comment 6459939973) does not reproduce on this head.

① Derived judgments

The two delta files publish nothing and move no accept set. Each is judged in its own right, then the earlier record's ten judgments are re-read against the unchanged tree.

  1. ADR-0142 D3 "Who sees it." amendment against ruling 6457475657 (letter A). The ruling: "queues joins RESERVED_RLS_MEMBERSHIP_KEYS, and plugin-security resolves it from the ownership-queue service into current_user.queues the way it resolves positions — one kernel-side producer; an app resolver that supplies queues is screened out (fails closed)." The amended sentence: "queues is a reserved key (RESERVED_RLS_MEMBERSHIP_KEYS) that @objectstack/plugin-security resolves kernel-side from the queue service, the way it resolves positions, so an authored policy or view can read it too, and an app IRlsMembershipResolver that answers queues is screened out and fails closed". Every clause of the ruling is present; nothing is added beyond it. "so an authored policy or view can read it too" is the sentence's pre-existing tail (the option-B sentence ended the same way) — not a widening introduced by the amendment. Not wider, not narrower. Right.
  2. The amended sentence against the code on this head. "queue names": IOwnershipQueueService.queuesOf returns "the machine names of every queue userId is a member of" and OwnershipQueueService.queuesOf pushes name; the queue column holds a machine name (queue.zod.ts:93). "in the active organization": organizationOf(context) is context.tenantId, passed as queuesOf(userId, tenantId ?? null). "reserved key (RESERVED_RLS_MEMBERSHIP_KEYS)": rls-membership-resolver.ts list ends 'queues'. "plugin-security resolves kernel-side from the queue service": kernel-queue-membership.ts#resolveOnto via SecurityPlugin#ownershipQueueMembership() on the OWNERSHIP_QUEUE_SERVICE slot. "an authored policy or view can read it": RLSCompiler.userContextOf sets queues: kernelQueuesOf(executionContext). "an app IRlsMembershipResolver that answers queues is screened out and fails closed": stageResolverMembership (security-plugin-rls-filter.ts:724–730) WARNs and continues on a reserved name; the compiler merge (rls-compiler.ts:984) skips it a second time; unresolved, the key takes the existing unresolved-variable path to RLS_DENY_FILTER. Right. One simile is loose and is the ruling's own: plugin-security reads positions off the context core assembles, and resolves queues itself, lazily, from a service. The load-bearing claims (reserved, kernel-side, one producer in plugin-security, screened, fail-closed) all hold, so this is noted, not a finding.
  3. Self-contradiction inside the record. D3's amended sentence is now the only place in the Decision sections that names a mechanism; Context §1 (:145) describes the general IRlsMembershipResolver contract, not queues; D9, Consequences (:612), Open item 2 and merge question 3 say "pre-resolved set" without a mechanism. One sentence still states option B: Execution plan item 1 (:805), "the queue service and its queues membership-resolver key in plugin-sharing". The PR body calls this "left as history"; it carries no annotation, so a reader of the plan is told plugin-sharing supplies the key as a resolver while D3 says plugin-security resolves it kernel-side. ADR-0028's amendment under a ruling rewrote "the three sentences that restate it", and ADR-0055 annotates bullets kept for history with "⚠️ Amended … ⛔ it is no longer a statement of…". Should-fix (④).
  4. The "Amended" note's form. Inline, parenthetical, bold keyword, ISO date, ruling id linked to the exact comment URL, parent issue, letter, and the sentence's prior content recorded. The **Status**: line (:3) is untouched: it still reads Proposed (2026-10-25) with no **Amended** marker. docs/adr/README.md Conventions: "A reversed decision is a new ADR or an amended status line, never a quiet changeset" and "The status line is the single fact … and any free prose after it (dates, implementation notes, amendments)"; ADR-0028, 0050, 0055, 0029, 0030 all carry · **Amended** (date, ref — one line) on the status line pointing at the inline note. A census on main finds 29 records with inline amendment prose and no status-line marker (0138 and 0139 among them), and no gate reads the status line for it (check-adr-anchors.mjs:71 excludes status lines; adr-index.mjs reads the first keyword only). So the form is accepted in the corpus but short of the README's convention. Should-fix (④), not a must-fix.
  5. Ratchet row 3693 → 3687. skills/objectstack-data/rules/security.md at prr-27044 is 14747 bytes; ceil(14747 / 4) = 3687, the new row, headroom 0. The file has no frontmatter and no version: line, so the plain ceil(wc -c / 4) convention applies (TOKEN_CONVENTION, header lines 44–113). At the base 7eb613c3 it is 14771 bytes → 3693, the old row, so the row sat at zero headroom before and sits at zero after: pinned AT the measurement, the shrink-only direction the header makes optional ("may be LOWERED by any PR that shrinks its file"). The row comment's arithmetic ("+3 tokens … net −6") matches −24 bytes. The file is unchanged since 8660c53e (14747 bytes at both), so the row re-locks the count the earlier record measured. Right.
  6. Earlier record's judgments ①.1–①.10, re-read on this head. The tree outside the two delta files is identical, and each was spot-checked in the cited code rather than taken on faith: the list ends 'queues'; MembershipKeyNameSchema refines on !RESERVED_RLS_MEMBERSHIP_KEYS.includes(name) and is read at rls.zod.ts:891 by the membershipKeys refine; lint's PRERESOLVED_USER_KEYS = new Set(RESERVED_RLS_MEMBERSHIP_KEYS); kernelQueuesOf answers only when staged.userId and staged.organizationId match the context asked on; PENDING is a WeakMap; stageRlsMembership stages queues first and only when policiesReadCurrentUserKey(policies, 'queues'); both compile sites pass their policies (:432 compilable, :643 withCheck); the :222 call without policies stages the resolver half only, as before. One parity the earlier record asserted is confirmed at the source: currentUserReadsOf walks parseCelToAst(sqlPredicateToCel(predicate)), and sqlPredicateToCel rewrites IN → in leaving parentheses in place, so the ADR's own spelling queue IN (current_user.queues) is detected by the lazy stager through the same bridge and parser the compiler uses; the reserved-keys pin's queues cell drives the compiler with exactly that SQL spelling (kernelWins), and kernel-queue-membership.test.ts drives the whole plugin with the CEL spelling. The membershipKeyReadsOf refactor (cache the unfiltered walk, filter reserved names on return) preserves its callers' answers. The changeset names @objectstack/skills, whose package.json describes it as "the repository's skills/** tree" synced by scripts/sync-catalog.mjs, so the skill edit is published and the entry is owed. All ten hold.

② Semver level

Unchanged since 8660c53e: .changeset/27016-queues-reserved-membership-key.md — @objectstack/spec, @objectstack/plugin-security, @objectstack/lint, @objectstack/skills, each minor; Clause-②: no (narrowing) and Metadata-protocol: narrowed line-initial in the changeset body and in the PR body; **BREAKING** banner; one ADR-0087 marker not-required (no-migration-prescription); a **The remedy.** line. The delta adds no published file: docs/adr/** and scripts/** ship in no package, and plugin-sharing still changes only a test. minor on a narrowed kind matches what the diff publishes; the narrowing is real (the refine refuses the name at parse; census at the merge base zero). Check Changeset and PR-body guards are green on this head. Clause-②: correct as declared.

③ Boundary flags

  • Dev flags (os-dev-report 6459486195): open_questions: []. Out-of-scope 1 (two packaged-action-disabled-dispatch-live.dogfood.test.ts failures on the dev's box, a stack that reads no current_user.queues): not this diff; Dogfood Regression Gate green on this head; the seat routes it. Out-of-scope 2 (current_user.queues in a field-permission when resolves only if a row policy on the same context staged it; compileRowPredicate does not stage): read in the tree, correct, fails closed exactly as declared keys have since Spec design: declarative membership keys for RLS (current_user.<key> from a lookup on records whose user field is the current user) #25234; no widening, no card owed from this record.
  • Earlier record's escalation "amend ADR-0142 D3 to the ruled mechanism": answered by e3749743 on this head, with the two form findings in ④.
  • Earlier record's optional should-fix "lower the ratchet row to 3687": answered by e3749743, verified at the measurement (①.5).
  • Boundary carried over, unchanged: the set is resolved for the ACTIVE organization (ADR-0142 D3's definition, plugin-sharing's callerQueues reads the same), while the claim gate asks membership in the ROW's organization; under a group posture admitting another organization's rows a same-named authored queue there could match. The ADR's definition, not this PR's to narrow; the authoring guide (Execution plan item 4) should say "active organization".
  • Boundary noted, pre-existing and not this PR's: the **Status**: line still reads Proposed (2026-10-25) although the record was merged ("this record becomes Accepted by the maintainer's merge") and its Execution plan item 1 landed as feat!: ownership queues and assignment rules (ADR-0142 phase 2) #26337. The README's status-hygiene rule puts the flip in the PR that lands the implementation; that PR was feat!: ownership queues and assignment rules (ADR-0142 phase 2) #26337, not this one. It is the same line the ④ should-fix touches.
  • Landing: Tier H (skills/**, docs/adr/**) is the seat's whole-PR concern, outside this record's inputs; the seat's ACCEPT reads os-zhuang's approval at 8660c53e as lifting it. Not re-judged here.
  • Drift check (6459486805): advisory; its "checkout carried uncommitted changes" line is the runner's, not the head's.

④ Design judgment

docs/adr/0142-…md (the delta's substance) against its first consumer and its execution plan: the first consumer is this head's kernel-queue-membership.ts + userContextOf, and D3 as amended describes that code accurately (①.2). Tried to break it: a reader deriving the producer from D3 lands on plugin-security and the service slot the claim gate uses — correct; a reader deriving it from Execution plan item 1 lands on "a queues membership-resolver key in plugin-sharing" — a mechanism the tree does not have and the ruling refused (option B). The record therefore carries two mechanisms, one dated and one not.

  • should-fix: annotate Execution plan item 1's clause "the queue service and its queues membership-resolver key in plugin-sharing" as superseded by D3 as amended (ruling 6457475657), in the form ADR-0055 uses for bullets kept as history, or rewrite it as ADR-0028 did for restating sentences. Not a must-fix: it is plan prose under "after acceptance", D3 is the deciding text, and the amendment note dates the change.
  • should-fix: add · **Amended** (2026-10-28, ruling 6457475657 on #25769, letter A — D3's current_user.queuesproducer is plugin-security kernel-side, the reserved keyqueues; see the note in D3) to the **Status**: line, per the README's "amended status line" convention and the 0028/0050/0055 precedent; regenerate docs/adr/README.md with pnpm adr:index if the line's first keyword changes. Not a must-fix: 29 records carry inline-only notes and no gate refuses the form.

packages/spec/src/contracts/rls-membership-resolver.ts, packages/spec/src/data/object.zod.ts, packages/spec/src/security/rls.zod.ts (unchanged since the PASS) against ruling A and their first consumer: the list is the single source every reader imports; the three prose copies of the kernel-name list (the keys doc, the .describe(), the docblock) are hand-maintained and all carry queues on this head; the refine makes the spec's own refusal real, and the capability the ADR declares is delivered by the same head. Forge, leak, stale and fail-open attempts from the earlier record were re-walked in the code (①.6) and hold. No finding.

skills/objectstack-data/rules/security.md (unchanged since the PASS) against the spec it teaches on this head: the "One name per stack" bullet lists the seven kernel names the refine refuses, in the list's order; the paid deletion ("not a stamp on every data row,") removes a second anti-pattern the roster sentence still implies. The card carries no eval prompt (skills docs are #26475's). The ratchet row now sits at the file's measurement. No finding.

scripts/check-skills-token-ratchet.mjs (cited, not a hit): the row is a measurement, not a budget; the comment's basis line ("3693 to 3687 (#27016): RE-LOCK at the landed count", quoted here with the arrow spelled out) follows the file's own row-comment convention.

Findings:

  • must-fix: none.
  • should-fix: Execution plan item 1 still states option B's mechanism, unannotated (ADR-0142 :805).
  • should-fix: no **Amended** marker on the **Status**: line (ADR-0142 :3); the same edit is the natural place to flip the stale Proposed.

Implemented-by: claude/project-thread-1d234m
Reviewed-by: session_01AavokzJ5DndAwitDXvKy4U

VERDICT: PASS


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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec+security: reserve the queues RLS membership key and resolve current_user.queues kernel-side from the ownership-queue service

3 participants