Observation from implementing objectstack-ai/ats#13 (PR objectstack-ai/ats#48), cli 17.3.0.
What
RlsMembershipContext carries userId, tenantId, accessible_org_ids, positions, permissions — and nothing about the request itself. plugin-security's stageRlsMembership(context) is called from computeLayeredRlsFilter for every object's RLS computation and memoised per execution context (context.__rlsMembershipStaged), so a resolver runs exactly once per request, but it runs for every request that reaches RLS at all, and it cannot tell which object or operation the request is for.
Why it matters
The contract names this seam for "app-shaped sets: the accounts in a rep's territories, the records a case team can touch" — sets that are usually the result of a query. In ats the applicant set (applicant_candidate_ids, the distinct ats_application.candidate ids across the caller's employer organizations) backs a policy on ats_candidate only, yet the read that produces it runs on every request by an employer-side caller — a read of ats_job pays for a query on ats_application whose result no policy on ats_job consumes. The app can only skip the work by the caller's shape (callers with no accessible_org_ids skip it), never by the request's.
Possible shapes (not a ruling)
- Add
object and operation (or the set of policy keys actually referenced by the applicable policies) to RlsMembershipContext, so a resolver can return {} for a key no applicable policy reads.
- Or let the resolver declare which keys apply to which objects (
keys: [{ key, objects }]) and have stageRlsMembership skip staging when no applicable policy on the object names a declared key.
Either keeps the fail-closed contract intact: a key that is not resolved still makes its policies drop out.
Evidence
Observation, not a defect: the contract behaves as written. Filed unlabelled beyond finding for triage.
Observation from implementing objectstack-ai/ats#13 (PR objectstack-ai/ats#48), cli 17.3.0.
What
RlsMembershipContextcarriesuserId,tenantId,accessible_org_ids,positions,permissions— and nothing about the request itself.plugin-security'sstageRlsMembership(context)is called fromcomputeLayeredRlsFilterfor every object's RLS computation and memoised per execution context (context.__rlsMembershipStaged), so a resolver runs exactly once per request, but it runs for every request that reaches RLS at all, and it cannot tell which object or operation the request is for.Why it matters
The contract names this seam for "app-shaped sets: the accounts in a rep's territories, the records a case team can touch" — sets that are usually the result of a query. In ats the applicant set (
applicant_candidate_ids, the distinctats_application.candidateids across the caller's employer organizations) backs a policy onats_candidateonly, yet the read that produces it runs on every request by an employer-side caller — a read ofats_jobpays for a query onats_applicationwhose result no policy onats_jobconsumes. The app can only skip the work by the caller's shape (callers with noaccessible_org_idsskip it), never by the request's.Possible shapes (not a ruling)
objectandoperation(or the set of policy keys actually referenced by the applicable policies) toRlsMembershipContext, so a resolver can return{}for a key no applicable policy reads.keys: [{ key, objects }]) and havestageRlsMembershipskip staging when no applicable policy on the object names a declared key.Either keeps the fail-closed contract intact: a key that is not resolved still makes its policies drop out.
Evidence
@objectstack/speccontracts—RlsMembershipContext(five request facts, no object/operation).@objectstack/plugin-security17.3.0 dist —stageRlsMembership(context)passes{ userId, tenantId, accessible_org_ids, positions, permissions }; called fromcomputeLayeredRlsFilterregardless ofobject.src/security/rls-membership-resolver.tsdocuments the per-request cost this imposes.Observation, not a defect: the contract behaves as written. Filed unlabelled beyond
findingfor triage.