Symptom
On the stock showcase, on current origin/main, a persona holding the plain contributor position:
GET /data/showcase_invoice/:id → 404 (the app's invoice_own_rows select narrowing correctly hides an invoice it does not own), andPATCH /data/showcase_invoice/:id → 200, and the row actually changes (ground truth: re-read as admin).
Same on showcase_invoice_line, which is controlled_by_parent on the invoice.
That is the #1994 class — "you can't mutate what you can't see" — still open for this authoring shape, one issue after #7665 closed it for the select-only shape.
Found by the per-declared-position probe personas added for #7978 (PR to follow); it is not reachable by the base probe persona, which holds no positions.
Evidence
objectstack verify --rls --app examples/app-showcase/objectstack.config.ts, position persona contributor:
HOLE showcase_invoice: the probe cannot read it (GET 404) yet MUTATED it by id (PATCH 200) — by-id write bypassed RLS (#1994 class)
HOLE showcase_invoice_line: the probe cannot read it (GET 404) yet MUTATED it by id (PATCH 200) — by-id write bypassed RLS (#1994 class)
The contrast is the diagnosis. On the very same row, in the same run:
| persona | authored on the update class | verdict on showcase_invoice |
|---|
base probe persona (verifier-authored owner narrowing, select only) | nothing | rls-consistent — refused |
contributor (the app's own set) | invoice_owner_immutable, operation: 'update', check only, no using | rls-hole — written |
And showcase_task — the same positions: ['contributor'] select-only narrowing but no update-class policy — is rls-consistent, and flips to rls-hole when the #7665 derivation is ablated. So the derivation works; something switches it off for the invoice.
Mechanism — two sites, and BOTH have to move
1. The derivation is suppressed by a policy that gates nothing.
packages/plugins/plugin-security/src/security-plugin.ts (write-visibility floor, ~L4030):
if(collected.length===0&&(operation==='update'||operation==='delete')&& ...
){collected=this.collectRLSPolicies(permissionSets,object,'select',(context?.positions??[])asstring[]);}collected here is the update-class collection, and getApplicablePolicies matches on object + positions + operation only — it never asks whether the policy carries a using predicate. The showcase's invoice_owner_immutable is operation: 'update' with check only, so for a caller holding contributor the collection is non-empty, collected.length === 0 is false, and the select narrowing is never derived. The collected policy then compiles to no row filter (it has no using), so Layer 1 is null and every write-side row gate composed from it — the by-id pre-image gate at step 2.7 included — is a no-op again.
#7665 criterion 5 says derive only when no update-scope predicate exists. A check clause is not a scope predicate; it is post-image validation (ADR-0058 D4). Counting it as one is what re-opens the hole.
2. The second belt is not there either — the check is dropped for position-scoped callers.
Same file, computeWriteCheckFilter (~L4141):
constwithCheck=this.collectRLSPolicies(permissionSets,object,operation).filter(...)
heldPositions is not passed. getApplicablePolicies then evaluates the applicability domain against [], so any policy declaring positions is filtered out of the post-image check for every caller — including invoice_owner_immutable, whose whole point is that a contributor cannot write an invoice into a state they do not own. That is positions on a check policy being inert (ADR-0090 P2 / ADR-0049 enforce-or-remove, the same class #3539 closed for org-scoped policies), and it is why the write was not stopped on the way out either.
Fixing only 1 leaves position-scoped check clauses dead; fixing only 2 leaves the by-id write ungated for any row the check happens to accept. They belong in one change.
Reproduce
pnpm turbo run build --filter=@objectstack/cli... --filter=@objectstack/example-showcase...
OS_LOG_LEVEL=error node packages/cli/bin/run.js verify --app examples/app-showcase/objectstack.config.ts --rls
with the #7978 position personas in place (they are what mints a contributor-holding probe). Without them the run is green, which is exactly why this sat unseen.
Impact
Real, and not showcase-specific: any app that authors a using narrowing for a position plus any update-class policy — even one that only validates the post-image — loses by-id write scoping on that object. The showcase is the shipped example of the recommended authoring style, so this is the shape apps are being taught to write.
Scope
⛔ Not fixed in the #7978 PR — that PR is the verifier that found it, and tuning either the probe or this policy to keep the run green is the one thing it must not do. Filed unassigned for triage.
Symptom
On the stock showcase, on current
origin/main, a persona holding the plaincontributorposition:GET /data/showcase_invoice/:id→ 404 (the app'sinvoice_own_rowsselect narrowing correctly hides an invoice it does not own), andPATCH /data/showcase_invoice/:id→ 200, and the row actually changes (ground truth: re-read as admin).Same on
showcase_invoice_line, which iscontrolled_by_parenton the invoice.That is the #1994 class — "you can't mutate what you can't see" — still open for this authoring shape, one issue after #7665 closed it for the select-only shape.
Found by the per-declared-position probe personas added for #7978 (PR to follow); it is not reachable by the base probe persona, which holds no positions.
Evidence
objectstack verify --rls --app examples/app-showcase/objectstack.config.ts, position personacontributor:The contrast is the diagnosis. On the very same row, in the same run:
showcase_invoiceselectonly)rls-consistent— refusedcontributor(the app's own set)invoice_owner_immutable,operation: 'update',checkonly, nousingrls-hole— writtenAnd
showcase_task— the samepositions: ['contributor']select-only narrowing but no update-class policy — isrls-consistent, and flips torls-holewhen the #7665 derivation is ablated. So the derivation works; something switches it off for the invoice.Mechanism — two sites, and BOTH have to move
1. The derivation is suppressed by a policy that gates nothing.
packages/plugins/plugin-security/src/security-plugin.ts(write-visibility floor, ~L4030):collectedhere is the update-class collection, andgetApplicablePoliciesmatches on object +positions+ operation only — it never asks whether the policy carries ausingpredicate. The showcase'sinvoice_owner_immutableisoperation: 'update'withcheckonly, so for a caller holdingcontributorthe collection is non-empty,collected.length === 0is false, and the select narrowing is never derived. The collected policy then compiles to no row filter (it has nousing), so Layer 1 is null and every write-side row gate composed from it — the by-id pre-image gate at step 2.7 included — is a no-op again.#7665 criterion 5 says derive only when no update-scope predicate exists. A
checkclause is not a scope predicate; it is post-image validation (ADR-0058 D4). Counting it as one is what re-opens the hole.2. The second belt is not there either — the
checkis dropped for position-scoped callers.Same file,
computeWriteCheckFilter(~L4141):heldPositionsis not passed.getApplicablePoliciesthen evaluates the applicability domain against[], so any policy declaringpositionsis filtered out of the post-image check for every caller — includinginvoice_owner_immutable, whose whole point is that a contributor cannot write an invoice into a state they do not own. That ispositionson acheckpolicy being inert (ADR-0090 P2 / ADR-0049 enforce-or-remove, the same class #3539 closed for org-scoped policies), and it is why the write was not stopped on the way out either.Fixing only 1 leaves position-scoped
checkclauses dead; fixing only 2 leaves the by-id write ungated for any row the check happens to accept. They belong in one change.Reproduce
with the #7978 position personas in place (they are what mints a
contributor-holding probe). Without them the run is green, which is exactly why this sat unseen.Impact
Real, and not showcase-specific: any app that authors a
usingnarrowing for a position plus any update-class policy — even one that only validates the post-image — loses by-id write scoping on that object. The showcase is the shipped example of the recommended authoring style, so this is the shape apps are being taught to write.Scope
⛔ Not fixed in the #7978 PR — that PR is the verifier that found it, and tuning either the probe or this policy to keep the run green is the one thing it must not do. Filed unassigned for triage.