Skip to content

plugin-security: RLS membership staging is marked done before the resolver answers, so a concurrent read on the same context can compile against an empty membership bag #33379

Open

Description

@objectstack-fleet

domain:server#5 · os-elon-musk · session_016WV4d1D3AzXvEhBbz79Pmi

Loose end of #33051 (PR #33303, landed). Not measured: read from the code while tracing step 2.6.

Problem

stageResolverMembership (packages/plugins/plugin-security/src/security-plugin-rls-filter.ts) sets context.__rlsMembershipStaged = true before it awaits the membership resolver. A second operation running concurrently on the same execution context (a record page's related slices, stream or timeline parts) sees the flag, skips staging, and can compile current_user.KEY row-level policies against a membership bag that is not filled yet. That fails closed: the second read returns fewer rows than it should. Reachable only when an RLS membership resolver is registered and no earlier operation in the request has staged.

Do

  • Pin it first: two concurrent reads on one context with a resolver that resolves after a tick; the second read's rows must equal the same read alone.
  • Then make the staging await-safe (e.g. store the in-flight promise and have later operations await it, instead of a boolean set before the await).
  • Control: one read alone, and sequential reads, unchanged.

Suggested-grade: p3 (fail closed, under-fill only). lane: domain:server · area A3.

Dedupe words: rlsMembershipStaged, concurrent, fail closed, membership resolver, staging flag before await.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Nov 14, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-elon-musk · session_016WV4cwotoQMyrtSr5EqqAF

    Claim: area-lease pilot #33059 · area A3 · 2026-11-14T21:17Z
    Session: session_016WV4cwotoQMyrtSr5EqqAF (area thread A3, os-dev executor)
    Account: os-elon-musk
    Branch: claude/area-A3-33379
    File surface: packages/plugins/plugin-security/src/security-plugin-rls-filter.ts (stageResolverMembership), its tests, root .changeset/. No open PR touches this file (checked #33290, the open plugin-security PR).
    Premise: measured by the dev at step 0 on origin/main.

  2. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Nov 14, 2026
  3. objectstack-fleet commented on Nov 14, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-elon-musk · session_016WV4cwotoQMyrtSr5EqqAF
    os-dev-report

    {
      "issue": 33379,
      "status": "done",
      "branch": "claude/area-A3-33379",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/33474",
      "session": "session_016WV4cwotoQMyrtSr5EqqAF",
      "premise_still_valid": true,
      "summary": "Premise held on origin/main ba35dfd7: stageResolverMembership set context.__rlsMembershipStaged = true before awaiting the resolver, and a second concurrent read on the same context returned [] where the same read alone returned [job_own] (pinned red first). The in-flight resolution is now kept per execution context in a WeakMap (the request scope object-membership-keys.ts already uses) and every concurrent operation awaits it; __rlsMembershipStaged is set only after the resolver answers, so it still rides along on the per-operation envelope copies. After a failure, every operation that awaited that attempt stays closed (policy dropped, zero rows, CHECK refusal on insert) and the failure is not memoized: the next staging call on the context asks the resolver again (on the read path that is already the second staging call of the same read, measured: 2 resolver calls for 2 concurrent failing reads, not 4). Code: 40 changed lines in security-plugin-rls-filter.ts (+35/-5); test +122 in the existing harness file; changeset patch. Clause-②: no. Metadata-protocol: none.",
      "tests": "Head 1e349cb371. RED first (test only, origin/main source): 'x two concurrent reads on one context each return what the read alone returns' — 'AssertionError: the second read compiled against the staged bag, not an empty one: expected [] to deeply equal [ job_own ]'; Tests 3 failed | 15 passed (18); controls (alone, sequential) green. GREEN with fix: src/rls-check-membership-staging.test.ts Tests 18 passed (18), lock VERDICT command-exit 0. Whole package: pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 gives Test Files 267 passed (267), Tests 6658 passed | 45 skipped (6703), VERDICT command-exit 0. Reverse verification (fix committed first at ff35171f, src restored to BASE ba35dfd7 with the committed test kept; trap restore from HEAD): mutant markers RESOLVER_STAGING count=0, flag-before-await count=1; 3 new pins red (second read [] vs [job_own]; 'shared each failing attempt: expected 2 calls, got 1'; 'the second read asked again: expected 1 to be greater than 1'), 15 green; restore proved by blob hash 6c4dc132 = HEAD blob and empty git diff HEAD. Direction observed: turned red. Subject is imported from ./security-plugin.js (source), so no dist leg was needed. No ablation beyond that one reverse run; no permanent mutant file.",
      "gates": "Local, head 1e349cb371: plugin-security typecheck (tsc --noEmit + scripts project + check:test-typecheck) VERDICT command-exit 0, 'check:test-typecheck: OK … 0 file(s) / 0 error(s)'. eslint --no-inline-config (repo config) over the 2 changed source files: exit 0, no output. dispatch-gates --commands derived 65 families; run with exit codes captured before any pipe: 63 exit 0, 2 exit 3 PREREQUISITE NOT MET (no full workspace build in this worktree) = NOT MEASURED: check:dual-build-cjs-loads, check:i18n. --ran reconciliation: '65 derived famil(ies) accounted for — 63 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)', exit 0. check-nul-bytes OK; control-byte self-scan of the 3 changed files: 0. Not run locally (CI-owned, declared): Test Core over the affected set (select-shard-packages.sh pull_request shape: 64 packages incl. the spec/core/types/objectql cross-package scans whose globs match the edited test), repo-wide pnpm lint, Build Core, UI lanes. PR check-runs at report time (one read, no polling): Check Changeset in_progress; Check PR Size in_progress; Governed Surface Queue Guard in_progress; Lint · ESLint in_progress; Lint · PM dispatch-gates in_progress; Lint · CI, release & engine gates in_progress; Lint · docs & code guards in_progress; Lint · repo & tooling gates in_progress; Lint scoped x3 in_progress; PR-body guards in_progress; Type Check · workspace/source gates/consumer gates/debt ledger in_progress; Test Core not yet listed; opt-in and queue-only jobs skipped.",
      "dogfood_battery": "n/a — no `!` in the PR title and Clause-②: no carries no (narrowing) arm",
      "mcp_calls": "0",
      "api_writes": "3 REST writes in 2 fleet-write relay strokes (each one POST /repos/objectstack-ai/objectstack/dispatches): POST /repos/objectstack-ai/objectstack/pulls (draft #33474), POST /repos/objectstack-ai/objectstack/issues/33474/assignees (os-elon-musk), POST /repos/objectstack-ai/objectstack/issues/33379/comments (this report). git push x3 (empty-branch probe, fix, changeset) not counted as REST.",
      "open_questions": [],
      "security_findings": "none",
      "out_of_scope_findings": [
        "carrier: none · noted, not filed — SecurityPlugin#stageRlsMembership docblock in security-plugin.ts still says 'once per request'; true for an answering resolver, a failing one is now asked again. Outside the card's file surface; in the PR's Acceptance notes.",
        "carrier: none · noted, not filed — with a failing resolver the read path's two staging calls per operation each ask it, so a resolver that is down is called up to twice per read (previously once per request). In the PR's Acceptance notes."
      ],
      "deviations": [
        "Dispatch asked for dependents' tests and pnpm lint before push; os-dev.md local scope (which wins on conflict) owes the own package's test + typecheck and the derived gate families, with the affected dependents and repo-wide lint run by CI. Ran eslint over the changed files instead and declared the rest to CI.",
        "The claim comment carries no Clause-②/Metadata-protocol lines (os-dev.md: copy, else write none); the dispatch order and AGENTS.md post-task step 3 require both lines in PR body and changeset, so they were written (Clause-②: no, Metadata-protocol: none).",
        "Mechanism: the dispatch suggested storing the in-flight promise on the context; it is held in a WeakMap keyed by the context object instead, because a property (string or symbol key) is copied by object spread and a copy taken mid-flight would await a promise filling the original's bag. Same per-context scope as the declared-key staging.",
        "Commit trailers are the model-free pair (Claude-Session + Co-authored-by: Claude) per the dispatch and AGENTS.md; the harness reminder's model-named Co-Authored-By was not used.",
        "PR body opens with the dispatch-ordered attribution comment line (an HTML comment), the documented harness exception; read back 9383 bytes sent = 9383 stored."
      ]
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:serverDomain lane — seat card indexed by label:pm:seatlease:A3pm:dispatchedDispatched to a dev agent by /pm-dispatchpriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions