Repository navigation
feat(spec): the RLS deny sentinel and the composed ReadableIds leaf, declared once in the data entry; getReadFilter answers the one object (ADR-0139 D2) - #23144
Conversation
…its driver case-set, declared once in the data entry (ADR-0139 D2, D3)
RLS_DENY_FILTER is declared in packages/spec/src/data/readable-ids.ts, frozen,
with its docblock naming ADR-0139 D2 step 2 as the reader that recognises it by
identity. ReadableIds.compose builds the composed leaf { object, scope } under
its own brand; isComposedReadableIds and isReadableIds answer disjointly, and
the compose factory refuses the deny sentinel by identity. READABLE_IDS_CASES
is the per-driver case-set D3 names, executed here against a reference
evaluator.
Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ
Co-authored-by: Claude <noreply@anthropic.com>
…_FILTER by reference, and the tenant-wall recogniser decides by identity (ADR-0139 D2 step 2) rls-compiler.ts re-exports @objectstack/spec/data's RLS_DENY_FILTER under the same name and holds no second literal. The five getReadFilter fail-closed returns, the tenant wall's deny, the dropped-policy Layer 1 deny and the unresolvable-master controlled_by_parent deny return the frozen object itself instead of a spread copy, so the engine's composition can recognise it by identity. isTenantWallDenial compares the reference. Identity pins (toBe) cover each return. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…ugh typed copies (test-typecheck) Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…ta-entry exports Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…conformance gate makes it land with the driver suites or a maintainer-agreed ledger Classifying the case-set reds check-driver-conformance with five CONSUMED problems, one per driver; the author-takeable fix is the driver suites (ADR-0139 E3/E4, packages/drivers), and the ledger path is maintainer-only. The table and its oracle stay on claude/issue-23009-readable-ids-conformance. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…the case-set Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
… ReadableIds leaf; plugin-security binds the sentinel under its published type Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
… the sentinel's reference, not a copy (ADR-0139 D2 step 2) Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
… the repo root (check-spec-docblock-symbol-anchors) Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…ndOne — no deny path reads a row Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 144 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 dfbc73165a46da066dc89b0aeb46a8fba7628f62 && git checkout dfbc73165a46da066dc89b0aeb46a8fba7628f62
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 02bec6ca86e9f1237a3f82a9efff9cb3a53562ef 2b176bd2e614b180ddd7767f850f11a52f34463e && git checkout -B drift-repro 02bec6ca86e9f1237a3f82a9efff9cb3a53562ef && git merge --no-ff 2b176bd2e614b180ddd7767f850f11a52f34463e
node scripts/docs-audit/affected-docs.mjs --json 02bec6ca86e9f1237a3f82a9efff9cb3a53562ef
|
…Filter answers are the sentinel, and that a composed $and and the sharing layer's own deny are not Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read: card #23009 (body and all 5 comments: the cross-lane request 6123462901, the claim 6126106500, the two Check-runs on the head, as read at 2026-10-13T03:50Z: 28 completed/success, 10 in_progress, 8 completed/skipped, 0 failures. Success: ① Derived judgmentsPublic-surface changes the diff implies, each judged.
The identity switch in
Consumers that now receive the frozen object where they used to get a copy. The CRUD middleware marks it Could a test or gate pass while an identity, a refusal or a recogniser is wrong? Identities: every pin is The case-set. No file at the head names ② Semver level
③ Boundary flagsA. Shipped-sentence defects found on the head (the FAIL).
B. The dev's flags (report 6137424762
C. Both open questions, and the closing line.
D. Engine seat 2's two requests (6123462901): item 2 (by-reference returns, What clears the FAIL: one patch round on this branch fixing A1 (preferably remedy (a), with its pin) and A2's three wordings, regenerating nothing (TSDoc and a changeset line only; Implemented-by: VERDICT: FAIL |
…e uncomposed brand, and the scope wording says non-null, non-array object (contract review A1, A2) A hand-built value carrying both global-registry brands answered true to both recognisers, so the docblock's "no value answers true to both" was false. It now reads as an uncomposed leaf: isComposedReadableIds answers false and isReadableIds (unchanged) answers true. A pin holds the double-branded value and the comparand door's measured verdict on it. "Plain" is replaced by what isComposableScope checks, in readable-ids.ts and in the changeset. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read: card #23009 (body and all 7 comments: the cross-lane request 6123462901, the claim 6126106500, the three What moved since Check-runs on the head, as read at 2026-10-13T05:03Z: 30 completed/success, 8 in_progress, 8 completed/skipped, 0 failures. Success: ① Derived judgmentsA1 — the disjointness sentence, judged for every value. A1 — A1 — the pin and the dev's door verdict. The pin ( A1 — can admitting such a value ever widen what a caller reads? No. (a) One caller passes A2 — every "plain", judged against The public surface. Unchanged since the earlier record: The rest of the PR — did anything else change, and does any earlier judgment still hold? The 8 other files are byte-identical to ② Semver level
③ Boundary flagsA. The earlier FAIL's two items.
B. Report 6138557230's flags (
C. The earlier flags and the seat's disposition (reports 6137424762 and 6137777568, answer 6137605804) — carried. The files they concern did not move, so record 6137972237 ③ B–D stands by reference: the case-set re-routed to the PR that brings the driver suites with zero ledger rows (Q1 = B) and composites left composite (Q2 = A), both rightly ruled in-seat with the ADR-0139 E2-row deviation named and the record unedited; the D. The closing line. E. For the lander, not a flag. Implemented-by: VERDICT: PASS |
Fixes #23009
Clause-②: yes (widening: new exported members of the published spec
dataentry)ADR-0139 D2: the RLS deny sentinel and the composed
ReadableIdsleaf are declared once in@objectstack/spec/data, and every place in@objectstack/plugin-securitythat handed out a copy of the sentinel now returns the object itself. Not every zero-row answer ofgetReadFilteris the sentinel: a deny ANDed with another layer comes back as the composed$and, with the sentinel inside, and the record-sharing layer's own{ id: '__deny_all__' }comes back unchanged when sharing is the only layer that constrains. The per-driver case-set (D3) is not in this PR: the seat's answer moved it off this card (section below), so this PR delivers everything left on the card.What lands
Spec (
packages/spec/src/data/readable-ids.ts, exported from thedataentry)RLS_DENY_FILTER: the one literal in the tree, frozen, value unchanged (idequality against the UUID-shaped__rls_deny__string). Its docblock names ADR-0139 D2 step 2 as the reader that recognises it by identity.ComposedReadableIds({ object, scope }), its brandCOMPOSED_READABLE_IDS_BRAND(Symbol.for('objectstack.filter.readableIds.composed')), the constructorReadableIds.compose(leaf, scope)and the recogniserisComposedReadableIds.composethrows aTypeErrorfor anything but a brandedReadableIds.ofleaf, for a scope that is neitherundefinednor a non-null, non-array object, and forRLS_DENY_FILTERitself, compared by identity: D2 step 2 answers it$in: [], never a composed leaf.isReadableIds(composed) === falseandisComposedReadableIds(leaf) === false. No answerisReadableIdsgives today changes. It keeps meaning "an uncomposed leaf", which is what objectql'srefuseUncomposedReadableIdsand a refusing driver ask. Measured consequence: the comparand-shape door'sadmitReadableIdskeeps admitting the uncomposed leaf only, so a composed leaf handed to the engine from outside is refusedINVALID_FILTER/ 400 as a non-list comparand. It is never taken for a scope something else already computed. The alternative was the composed leaf carrying both brands, as D2's "still branded" could be read. Ablation leg B below shows what that does: the door admits the forged composed leaf. A hand-built value carrying both brands is not a composed leaf either:isComposedReadableIdsrefuses a value with an own uncomposed brand (patch round 2), so it reads as an uncomposed leaf.plugin-security
rls-compiler.tsholds no literal:export const RLS_DENY_FILTER = SPEC_RLS_DENY_FILTER, annotated with the plugin's publishedRecordtype: the spec object under the plugin's published name and type. The package export reads exactly as before, and the ADR-0139 D2 step 2 anchor (rls-compiler.ts#RLS_DENY_FILTER) is found through theconstbinding (H3:check-adr-symbol-anchorsaccepts aconstbinding and a named re-export alike; theconstkeeps the published type). The import block ofsecurity-plugin.tsis untouched.getReadFilterthat handed out{ ...RLS_DENY_FILTER }now returns the reference:getReadFilteritself: principal-less, sharing-scope failure, on-behalf-of, zero permission sets, resolution failure;getReadFilterreturns unchanged whenever that layer is the only one that constrains (andComposeLayershands back the single non-null layer by reference): the tenant wall's deny (tenant-layer.tstenantLayer0FilterOf), the dropped-policy Layer 1 deny incomputeLayeredRlsFilter, and the unresolvable-master deny incomputeControlledByParentFilter;isTenantWallDenialdecides by identity (filter === RLS_DENY_FILTER), as the engine will, and its displaced docblock now sits on it. A stale comment inresolveCbpRelationthat described the read leg's answer as a spread copy is corrected.rls-deny-sentinel-identity.test.ts(14 cases,toBeagainst the spec export, each deny return driven through the public method, plus the CRUD middleware injecting the frozen sentinel without editing it).tenant-layer.test.ts's pin of the old behaviour (not.toBe) is nowtoBe.What does not land here: the per-driver case-set (engine seat 2's request 1, second half)
readable-ids-conformance.ts(READABLE_IDS_CASES: a fixture, ninematchescases including the tenant-walled subquery and the NULL-safe$not, onerefuses-uncomposedcase, D3'sINTERNAL_ERROR/ 500 envelope) and its oracle test were written and are parked onclaude/issue-23009-readable-ids-conformance(commite641ab6d8, no PR). The seat's answer (comment 6137605804 on #23009) routes the case-set to the PR that brings the driver suites, with zero ledger rows, and the pointer 6137585321 on #22788 carries it. The gate verdict that answer rests on, as measured withscripts/check-driver-conformance.mjs(the requiredLint & Repo Gatesjob):CASE_SETSrow:x CLASSIFIED: readable-ids-conformance.ts exports READABLE_IDS_CASES, which no row of CASE_SETS names.(1 problem, exit 1).CASE_SETSrow (the parked commit): exit 1, five problems, one per driver:x CONSUMED: driver-memory does not run READABLE_IDS_CASES, and the same fordriver-mongodb,driver-sql,driver-sqlite-wasmanddriver-turso.packages/drivers/**, outside this card's surface). The other, a DEBT ledger row per driver, is marked⛔ MAINTAINER-ONLYin the gate's own text: "A ledger entry is a MEASURED, tracked exception the maintainer has agreed to … do not take this path to get CI green."ReadableIdsleaf once per parent object, fail closed, refuses it on write verbs and for D5's three classes, and marks the scope'policy'(D2, D5) #22861) imports. The case-set is not an E2 import.The dispatch's mechanism hypotheses
origin/main21c86444ethe only literal wasrls-compiler.ts. Classified: all eight spread sites reachgetReadFilter's top-level return. Five are ingetReadFilteritself.:8190,:8842andtenant-layer.ts:175come up throughandComposeLayerswhen the other layers are null. All eight now return the reference.service-analyticsplugin.tsbridge, thenresolveReadScopes(stores it in a map). The native-SQL face reads it (findCrossFieldComparand,compileScopedFilterToSql). The ObjectQL face runs the assert helpers (assertListComparandShapesreads;normalizeFilterComparandTypesis copy-on-write), callsmarkFilterSubtreeProvenance(a no-op on a frozen object, which leaves it unmarked and so withheld), then wraps it in a new$andor passes it as the filter.extra, marks it (a no-op), and setsast.whereto it or to a new$andaround it. A pin drives this path.computeControlledByParentFilterpasses the master filter to a systemql.findwhere.explain-enginereads it and publishes it asreadFilter.packages/objectql/src,packages/core/src/utils,plugin-sharing,plugin-security,service-analytics,driver-memoryanddriver-sqlfor in-place writes to a filter node (node.key =,delete,Object.assign,$and.push,Object.defineProperty,ast.where.x =) finds none.TypeErrorinstead of editing a deny. The changeset says so. The acceptance line holds in its plain reading.const, no ADR edit.git grep -n "__rls_deny__" -- packagesoutside tests at828798a9a:packages/spec/src/data/readable-ids.ts:154, the only one.lint/src/validate-rls-predicate-enforceability.ts:27,plugin-security/src/explain-engine.ts:205,:207,:208and:2405, andspec/src/security/explain.zod.ts:144,:152,:381and:390.plugin-security/CHANGELOG.md(3 hits) andspec/CHANGELOG.md(1).Verification (head
828798a9a)readable-ids.test.ts28 passed.filter-comparand-shape,filter-subtree-provenanceandexplain-zero-rows-sentinels.pin: 165 passed, 1 todo.pnpm --filter @objectstack/spec typecheckexit 0, test layer included.check:generatedexit 0, after a real build;api-surface/data.jsonandexport-origins/data.jsonregenerated, +4 each.not.toBepin intenant-layer.test.ts, updated). After the update, the identity, tenant-layer,rls-denial-observabilityandexplain-enginefiles: 189 passed.typecheckexit 0, test layer included.service-analytics:read-scope-sql,read-scope-sql-conformanceanddataset-rls-integration, 59 passed.objectql:engine-readable-ids-door,engine-aggregate-having-comparand-shapeandengine-comparand-type-door, 181 passed.lint: the fourvalidate-rls-predicate-enforceabilityfiles, 1059 passed.dispatch-gates --commandsderived 92 families. 89 ran at exit 0 at828798a9a. Two exited 3 (PREREQUISITE NOT MET), so they are NOT MEASURED:check:dual-build-cjs-loadsneeds every package'sdist/, andcheck:i18nneeds the built CLI closure.check:type-check-debtis NOT MEASURED by dispatch order (whole-workspace build; CI owns it).--ranreconciles: 92 derived, 89 run, 2 NOT-MEASURED, 1 unrun.require('@objectstack/plugin-security').RLS_DENY_FILTER === require('@objectstack/spec/data').RLS_DENY_FILTERistrue, and the ESM pair istruetoo.@objectstack/specunit suite. The run was cut at the 585 s foreground cap with no failure printed. CI runs it.Patch round 1 (head
e7221cba7)828798a9ais the changeset only. Two sentences are reworded, the headline and the identity bullet, so that each reads true against the composed$andand the sharing layer's__deny_all__. The frontmatter, the bump levels and theClause-②line are byte-identical to before.dispatch-gates --commandsate7221cba7gives the same 92 families as at828798a9a.e7221cba7, each exit 0:check-changeset-no-major(--base origin/mainand--self-test)check-adr-0087-registration(both halves)check-empty-changeset(both halves)check-closing-keyword-parity(both halves)node scripts/check-issue-citations.mjsandpnpm check:issue-citationspnpm check:doc-authoring,pnpm check:changeset-gate-self-tests,pnpm check:objectui-changeset,pnpm check:pm-changeset-deadline-censusandpnpm check:nul-bytes828798a9a, unchanged, because the diff touches only the changeset. That includes the two exit-3 NOT MEASURED lines andcheck:type-check-debt.Patch round 2 (head
2b176bd2e): the contract review's A1 and A2 (comment 6137972237)isComposedReadableIds's docblock said "Disjoint fromisReadableIds: no value answerstrueto both". A hand-built value carrying bothSymbol.forbrands, withobject: 'x'and an ownscope: undefined, answeredtrueto both. Measured one7221cba7:isReadableIdstrue andisComposedReadableIdstrue.isComposedReadableIdsanswersfalsefor a value that also carries an ownREADABLE_IDS_BRAND(one guard line). The universal sentence stays and is true. The docblock adds that such a value is never a composed leaf: when its uncomposed brand is valid,isReadableIdsanswerstrueand the value is refused as uncomposed, whichever recogniser is asked first.isReadableIdsis unchanged: its body is byte-identical toorigin/main.readable-ids.test.ts), on the double-branded value:isComposedReadableIdsisfalse;isReadableIdsistrue; the comparand door, as measured one7221cba7and unchanged, ADMITS it for the composing caller (admitReadableIds), as an uncomposed leaf the engine composes from the caller's access, and refuses itINVALID_FILTER/ 400 for every other caller.scripts/ablation-replace.mjsin wrap mode, under a restore trap): deleting the new guard line (anchor 1 → 0, blobd7c5fcde3a97→9219b80d529d) turns the pin red (Tests 1 failed | 28 passed). The restore leg prints blobd7c5fcde3a97== HEAD and an emptygit diff HEAD.undefinednor a plain, non-array object" (compose's@throws), "a plain, non-array object" (isComposedReadableIds's docblock and theisComposableScopecomment), and "a scope that is not a plain filter object" (the changeset).composeline. The enforcement is unchanged.readable-ids.tswere read and left alone. Two are the leaf's own module header (:23,:39) and describe a JSON-parsed or copied object, not this check. The other two (:144,:145) are the word "explain".Clause-②line are byte-identical.2b176bd2e:readable-ids.test.tswithfilter-comparand-shape,filter-subtree-provenanceandexplain-zero-rows-sentinels.pin: 166 passed, 1 todo.pnpm --filter @objectstack/spec typecheckexit 0.2b176bd2e:dispatch-gates --commandsderives the same 92 families.check:api-surface,check:export-origins,check:docs,check:generated,check-spec-docblock-symbol-anchors(both halves), the changeset family andcheck-closing-keyword-parity.check:doc-formula-expressionsandcheck:lean-entry-closurefirst exited 3 (their prerequisite builds); they ran at exit 0 afterformula/lintandobjectqlwere built.828798a9a, unchanged:check:dual-build-cjs-loadsandcheck:i18n(exit 3, NOT MEASURED, whole-workspace prerequisite builds) andcheck:type-check-debt(not run, per the dispatch).--ran: 92 derived, 89 run, 3 unrun.Ablations (each committed first; restore by
git checkout HEAD --, proven by blob hash equal to HEAD and an emptygit diff HEAD)getReadFilter's principal-less return set back to a spread: the identity pin turns red (1 failed). The existingtoEqual({ ...RLS_DENY_FILTER })pin insecurity-plugin.test.tsstays green, which is the reason pins aretoBe.tenantLayer0FilterOf's deny set back to a spread: 10 red. That is 4 identity pins, plus 6 inno-active-organization-write-refusal.test.ts, becauseisTenantWallDenialnow decides by identity: a future spread at the wall is caught by the existing write-refusal suite.composestops refusing the sentinel: 1 red.ablation-replace(the replacement contained it, so nothing landed and nothing ran). Re-anchored: the composed leaf also carries the uncomposed brand, and 3 go red, among them the door admitting a forged composed leaf.Acceptance notes
Composite denies stay composite.
getReadFilterreturns{ $and: [RLS_DENY_FILTER, …] }when the deny is ANDed with another layer, for example an org-less caller on a private-sharing object. E2's identity check will not see that top level as the sentinel and composes a subquery that matches nothing: fail-safe, but not$in: []. Collapsingdeny AND xto the sentinel is one line atgetReadFilter's return. It is not done here, because it changes the analytics scope's shape and was not asked; the seat's answer (comment 6137605804) keeps composites as they are.plugin-sharinghas its own deny-all shape. It answers{ id: '__deny_all__' }for a userId-less, non-principal-less caller on a private object, andgetReadFiltercan return that unchanged. It is not this sentinel, and E2 will compose it as an ordinary scope (zero rows). Carrier: E2 (ADR-0139 E2: the engine composes theReadableIdsleaf once per parent object, fail closed, refuses it on write verbs and for D5's three classes, and marks the scope'policy'(D2, D5) #22861).Dual-package hazard, measured.
@objectstack/specships CJS and ESM builds, and the two carry two different sentinel objects (esm.RLS_DENY_FILTER === cjs.RLS_DENY_FILTERisfalse; theSymbol.forbrands are shared). A process that loads spec through both entries makes identity miss across them; the miss stays fail-safe, because the copy still composes to a subquery that matches nothing. Carrier: E2 (ADR-0139 E2: the engine composes theReadableIdsleaf once per parent object, fail closed, refuses it on write verbs and for D5's three classes, and marks the scope'policy'(D2, D5) #22861).Stale prose in
explain-engine.ts. Its docblock at:178–:186says producers spread the constant. They no longer do, and its value comparison is still correct.explain-engine.tsis the [epic] ADR-0131 — total organization ownership: no NULL organization_id (v18 line) #15194 epic's territory, so it is not edited here. Carrier: whoever next touches that file.Docs drift check (the bot's comment 6137400414 on this PR). I read the seven hand-written pages it lists. None of them says whether a deny is a copy or the object itself. The
getReadFilterparagraph insharing-rules.mdx(a composed row filter, closed on a resolution failure) is still accurate. The three release-owned pages it names are read-only and are not edited.Labels
This dispatch names no label, and
skip-changesetdoes not apply: the change publishes and carries.changeset/23009-rls-deny-sentinel-composed-readable-ids.md. Zero label writes.Generated by Claude Code