Skip to content

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

Queued
objectstack-fleet[bot] wants to merge 12 commits into
mainfrom
claude/issue-23009-rls-deny-sentinel
Queued

objectstack-fleet[bot] wants to merge 12 commits into
mainfrom
claude/issue-23009-rls-deny-sentinel

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #23009
Clause-②: yes (widening: new exported members of the published spec data entry)

ADR-0139 D2: the RLS deny sentinel and the composed ReadableIds leaf are declared once in @objectstack/spec/data, and every place in @objectstack/plugin-security that handed out a copy of the sentinel now returns the object itself. Not every zero-row answer of getReadFilter is 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 the data entry)

  • RLS_DENY_FILTER: the one literal in the tree, frozen, value unchanged (id equality against the UUID-shaped __rls_deny__ string). Its docblock names ADR-0139 D2 step 2 as the reader that recognises it by identity.
  • The composed leaf of D2 step 3: ComposedReadableIds ({ object, scope }), its brand COMPOSED_READABLE_IDS_BRAND (Symbol.for('objectstack.filter.readableIds.composed')), the constructor ReadableIds.compose(leaf, scope) and the recogniser isComposedReadableIds.
    • compose throws a TypeError for anything but a branded ReadableIds.of leaf, for a scope that is neither undefined nor a non-null, non-array object, and for RLS_DENY_FILTER itself, compared by identity: D2 step 2 answers it $in: [], never a composed leaf.
    • Disjoint recognisers (the H5 choice). The composed leaf carries its own brand, not the uncomposed one, so isReadableIds(composed) === false and isComposedReadableIds(leaf) === false. No answer isReadableIds gives today changes. It keeps meaning "an uncomposed leaf", which is what objectql's refuseUncomposedReadableIds and a refusing driver ask. Measured consequence: the comparand-shape door's admitReadableIds keeps admitting the uncomposed leaf only, so a composed leaf handed to the engine from outside is refused INVALID_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: isComposedReadableIds refuses a value with an own uncomposed brand (patch round 2), so it reads as an uncomposed leaf.

plugin-security

  • rls-compiler.ts holds no literal: export const RLS_DENY_FILTER = SPEC_RLS_DENY_FILTER, annotated with the plugin's published Record type: 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 the const binding (H3: check-adr-symbol-anchors accepts a const binding and a named re-export alike; the const keeps the published type). The import block of security-plugin.ts is untouched.
  • Every return reachable from getReadFilter that handed out { ...RLS_DENY_FILTER } now returns the reference:
    • the five returns in getReadFilter itself: principal-less, sharing-scope failure, on-behalf-of, zero permission sets, resolution failure;
    • three layer denies that getReadFilter returns unchanged whenever that layer is the only one that constrains (andComposeLayers hands back the single non-null layer by reference): the tenant wall's deny (tenant-layer.ts tenantLayer0FilterOf), the dropped-policy Layer 1 deny in computeLayeredRlsFilter, and the unresolvable-master deny in computeControlledByParentFilter;
    • the RLS compiler's own deny already returned the frozen object.
  • isTenantWallDenial decides by identity (filter === RLS_DENY_FILTER), as the engine will, and its displaced docblock now sits on it. A stale comment in resolveCbpRelation that described the read leg's answer as a spread copy is corrected.
  • Identity pins: rls-deny-sentinel-identity.test.ts (14 cases, toBe against 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 now toBe.

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, nine matches cases including the tenant-walled subquery and the NULL-safe $not, one refuses-uncomposed case, D3's INTERNAL_ERROR / 500 envelope) and its oracle test were written and are parked on claude/issue-23009-readable-ids-conformance (commit e641ab6d8, 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 with scripts/check-driver-conformance.mjs (the required Lint & Repo Gates job):

  • With the file and no CASE_SETS row: x CLASSIFIED: readable-ids-conformance.ts exports READABLE_IDS_CASES, which no row of CASE_SETS names. (1 problem, exit 1).
  • With the CASE_SETS row (the parked commit): exit 1, five problems, one per driver: x CONSUMED: driver-memory does not run READABLE_IDS_CASES, and the same for driver-mongodb, driver-sql, driver-sqlite-wasm and driver-turso.
  • The gate names two remedies. The one an author may take is a suite in each driver package (ADR-0139 E3/E4, packages/drivers/**, outside this card's surface). The other, a DEBT ledger row per driver, is marked ⛔ MAINTAINER-ONLY in 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."
  • The composed form, its constructor and its recogniser DO land here, which is what E2 (ADR-0139 E2: the engine composes the ReadableIds leaf 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

  • H1. At origin/main 21c86444e the only literal was rls-compiler.ts. Classified: all eight spread sites reach getReadFilter's top-level return. Five are in getReadFilter itself. :8190, :8842 and tenant-layer.ts:175 come up through andComposeLayers when the other layers are null. All eight now return the reference.
  • H2 (what callers do with the object). No caller edits it. Read on this tree:
    • service-analytics plugin.ts bridge, then resolveReadScopes (stores it in a map). The native-SQL face reads it (findCrossFieldComparand, compileScopedFilterToSql). The ObjectQL face runs the assert helpers (assertListComparandShapes reads; normalizeFilterComparandTypes is copy-on-write), calls markFilterSubtreeProvenance (a no-op on a frozen object, which leaves it unmarked and so withheld), then wraps it in a new $and or passes it as the filter.
    • The CRUD middleware puts it in extra, marks it (a no-op), and sets ast.where to it or to a new $and around it. A pin drives this path.
    • computeControlledByParentFilter passes the master filter to a system ql.find where.
    • explain-engine reads it and publishes it as readFilter.
    • A search of packages/objectql/src, packages/core/src/utils, plugin-sharing, plugin-security, service-analytics, driver-memory and driver-sql for in-place writes to a filter node (node.key =, delete, Object.assign, $and.push, Object.defineProperty, ast.where.x =) finds none.
    • The RLS compiler has returned this frozen object by reference on the same paths all along.
    • The one behaviour change: code that DID edit the returned filter now gets a TypeError instead of editing a deny. The changeset says so. The acceptance line holds in its plain reading.
  • H3. See plugin-security above: an aliasing const, no ADR edit.
  • H4. The real hit list of git grep -n "__rls_deny__" -- packages outside tests at 828798a9a:
    • Definition: packages/spec/src/data/readable-ids.ts:154, the only one.
    • Prose: lint/src/validate-rls-predicate-enforceability.ts:27, plugin-security/src/explain-engine.ts:205, :207, :208 and :2405, and spec/src/security/explain.zod.ts:144, :152, :381 and :390.
    • Release-owned changelogs: plugin-security/CHANGELOG.md (3 hits) and spec/CHANGELOG.md (1).
  • H5. Disjoint brands, pinned (see above).

Verification (head 828798a9a)

  • Spec:
    • readable-ids.test.ts 28 passed.
    • With filter-comparand-shape, filter-subtree-provenance and explain-zero-rows-sentinels.pin: 165 passed, 1 todo.
    • pnpm --filter @objectstack/spec typecheck exit 0, test layer included.
    • check:generated exit 0, after a real build; api-surface/data.json and export-origins/data.json regenerated, +4 each.
  • plugin-security:
    • Full suite: 3970 passed, 1 failed (the old not.toBe pin in tenant-layer.test.ts, updated). After the update, the identity, tenant-layer, rls-denial-observability and explain-engine files: 189 passed.
    • typecheck exit 0, test layer included.
  • Consumers (named files, not whole suites):
    • service-analytics: read-scope-sql, read-scope-sql-conformance and dataset-rls-integration, 59 passed.
    • objectql: engine-readable-ids-door, engine-aggregate-having-comparand-shape and engine-comparand-type-door, 181 passed.
    • lint: the four validate-rls-predicate-enforceability files, 1059 passed.
  • Gates: dispatch-gates --commands derived 92 families. 89 ran at exit 0 at 828798a9a. Two exited 3 (PREREQUISITE NOT MET), so they are NOT MEASURED: check:dual-build-cjs-loads needs every package's dist/, and check:i18n needs the built CLI closure. check:type-check-debt is NOT MEASURED by dispatch order (whole-workspace build; CI owns it). --ran reconciles: 92 derived, 89 run, 2 NOT-MEASURED, 1 unrun.
  • Hand check against the built dists (CJS and ESM): require('@objectstack/plugin-security').RLS_DENY_FILTER === require('@objectstack/spec/data').RLS_DENY_FILTER is true, and the ESM pair is true too.
  • NOT MEASURED: the whole @objectstack/spec unit suite. The run was cut at the 585 s foreground cap with no failure printed. CI runs it.

Patch round 1 (head e7221cba7)

  • The diff since 828798a9a is the changeset only. Two sentences are reworded, the headline and the identity bullet, so that each reads true against the composed $and and the sharing layer's __deny_all__. The frontmatter, the bump levels and the Clause-② line are byte-identical to before.
  • Re-derived: dispatch-gates --commands at e7221cba7 gives the same 92 families as at 828798a9a.
  • Re-run at e7221cba7, each exit 0:
    • check-changeset-no-major (--base origin/main and --self-test)
    • check-adr-0087-registration (both halves)
    • check-empty-changeset (both halves)
    • check-closing-keyword-parity (both halves)
    • node scripts/check-issue-citations.mjs and pnpm check:issue-citations
    • pnpm check:doc-authoring, pnpm check:changeset-gate-self-tests, pnpm check:objectui-changeset, pnpm check:pm-changeset-deadline-census and pnpm check:nul-bytes
  • Carried: every other derived line is carried from 828798a9a, unchanged, because the diff touches only the changeset. That includes the two exit-3 NOT MEASURED lines and check:type-check-debt.
  • Body written 2026-10-13T03:43Z, with this PR's comments read through 6137400414.

Patch round 2 (head 2b176bd2e): the contract review's A1 and A2 (comment 6137972237)

  • A1: the disjointness sentence is now true.
    • BEFORE: isComposedReadableIds's docblock said "Disjoint from isReadableIds: no value answers true to both". A hand-built value carrying both Symbol.for brands, with object: 'x' and an own scope: undefined, answered true to both. Measured on e7221cba7: isReadableIds true and isComposedReadableIds true.
    • AFTER: isComposedReadableIds answers false for a value that also carries an own READABLE_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, isReadableIds answers true and the value is refused as uncomposed, whichever recogniser is asked first.
    • isReadableIds is unchanged: its body is byte-identical to origin/main.
  • A1 pin (the disjointness block of readable-ids.test.ts), on the double-branded value: isComposedReadableIds is false; isReadableIds is true; the comparand door, as measured on e7221cba7 and unchanged, ADMITS it for the composing caller (admitReadableIds), as an uncomposed leaf the engine composes from the caller's access, and refuses it INVALID_FILTER / 400 for every other caller.
  • A1 ablation (via scripts/ablation-replace.mjs in wrap mode, under a restore trap): deleting the new guard line (anchor 1 → 0, blob d7c5fcde3a97 → 9219b80d529d) turns the pin red (Tests 1 failed | 28 passed). The restore leg prints blob d7c5fcde3a97 == HEAD and an empty git diff HEAD.
  • A2: "plain" now says what the code checks.
    • BEFORE: "neither undefined nor a plain, non-array object" (compose's @throws), "a plain, non-array object" (isComposedReadableIds's docblock and the isComposableScope comment), and "a scope that is not a plain filter object" (the changeset).
    • AFTER: "a non-null, non-array object" in all four places, and in this body's compose line. The enforcement is unchanged.
    • The remaining "plain" hits in readable-ids.ts were 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".
  • Changeset: only the A2 phrase changed. The frontmatter, the levels and the Clause-② line are byte-identical.
  • Verification at 2b176bd2e:
    • readable-ids.test.ts with filter-comparand-shape, filter-subtree-provenance and explain-zero-rows-sentinels.pin: 166 passed, 1 todo.
    • pnpm --filter @objectstack/spec typecheck exit 0.
    • A real spec build left the tree clean, so no generated artifact changed.
  • Gates at 2b176bd2e:
    • dispatch-gates --commands derives the same 92 families.
    • 89 ran at exit 0. Among them: check:api-surface, check:export-origins, check:docs, check:generated, check-spec-docblock-symbol-anchors (both halves), the changeset family and check-closing-keyword-parity.
    • check:doc-formula-expressions and check:lean-entry-closure first exited 3 (their prerequisite builds); they ran at exit 0 after formula/lint and objectql were built.
    • Carried from 828798a9a, unchanged: check:dual-build-cjs-loads and check:i18n (exit 3, NOT MEASURED, whole-workspace prerequisite builds) and check:type-check-debt (not run, per the dispatch).
    • --ran: 92 derived, 89 run, 3 unrun.
  • Body written 2026-10-13T04:54Z, with this PR's comments read through 6137972237.

Ablations (each committed first; restore by git checkout HEAD --, proven by blob hash equal to HEAD and an empty git diff HEAD)

  • 1. getReadFilter's principal-less return set back to a spread: the identity pin turns red (1 failed). The existing toEqual({ ...RLS_DENY_FILTER }) pin in security-plugin.test.ts stays green, which is the reason pins are toBe.
  • 2. tenantLayer0FilterOf's deny set back to a spread: 10 red. That is 4 identity pins, plus 6 in no-active-organization-write-refusal.test.ts, because isTenantWallDenial now decides by identity: a future spread at the wall is caught by the existing write-refusal suite.
  • 3a. compose stops refusing the sentinel: 1 red.
  • 3b. The first anchor was refused by 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. getReadFilter returns { $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: []. Collapsing deny AND x to the sentinel is one line at getReadFilter'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-sharing has its own deny-all shape. It answers { id: '__deny_all__' } for a userId-less, non-principal-less caller on a private object, and getReadFilter can 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 the ReadableIds leaf 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/spec ships CJS and ESM builds, and the two carry two different sentinel objects (esm.RLS_DENY_FILTER === cjs.RLS_DENY_FILTER is false; the Symbol.for brands 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 the ReadableIds leaf 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–:186 says producers spread the constant. They no longer do, and its value comparison is still correct. explain-engine.ts is 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 getReadFilter paragraph in sharing-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-changeset does not apply: the change publishes and carries .changeset/23009-rls-deny-sentinel-composed-readable-ids.md. Zero label writes.


Generated by Claude Code

claude added 10 commits October 13, 2026 00:24
…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>
… 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>
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-security, @objectstack/spec, touching 15 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/permissions-matrix.mdx (via computeLayeredRlsFilter (symbol, a method of class SecurityPlugin))
  • content/docs/permissions/sharing-rules.mdx (via computeLayeredRlsFilter (symbol, a method of class SecurityPlugin), getReadFilter (symbol, a method of class SecurityPlugin))
  • content/docs/permissions/system-context.mdx (via getReadFilter (symbol, a method of class SecurityPlugin))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v15.mdx (via getReadFilter (symbol, a method of class SecurityPlugin))
  • content/docs/releases/v17/17-6.mdx (via getReadFilter (symbol, a method of class SecurityPlugin))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 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 — 144 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 02bec6ca86e9f1237a3f82a9efff9cb3a53562ef → packageMentionDocs.

Which tree this was computed on

This run read content/docs from dfbc73165a46da066dc89b0aeb46a8fba7628f62 — the merge of head 2b176bd2e614b180ddd7767f850f11a52f34463e into base 02bec6ca86e9f1237a3f82a9efff9cb3a53562ef, 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 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

⚠️ 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 02bec6ca86e9f1237a3f82a9efff9cb3a53562ef → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e7221cba7058cb0e9c4a3f541e56f52d1003d0e8
Local-runs: none

Inputs read: card #23009 (body and all 5 comments: the cross-lane request 6123462901, the claim 6126106500, the two os-dev-reports 6137424762 and 6137777568, the seat's answer 6137605804); PR #23144 body, its 11-file list and the diff against main (merge base 21c86444e, +633/−40, matching the API diff); the 46 check-runs on the head; origin/main at d206d08ca and the head via git show. The seat's own review and answer were treated as claims to test, not evidence.

Check-runs on the head, as read at 2026-10-13T03:50Z: 28 completed/success, 10 in_progress, 8 completed/skipped, 0 failures. Success: Lint & Repo Gates, Lint · repo & tooling gates, Lint · CI, release & engine gates, Lint · docs & code guards, Lint · ESLint, Lint · PM dispatch-gates, Lint · scoped · slot lookup & corpus, Lint · scoped · tooling self-tests, Lint · scoped · query-options erasure, Type Check · source gates, Check Changeset (×2), Governed Surface Queue Guard, Spec property liveness, Dogfood Regression Gate, Flag docs affected by code changes, Check Documentation Links, Check PR Size, Auto Label, filter, and the four card/branch/single-writer guards (×2 each). In progress: Build Core, Test Core (1/6) through (6/6), Type Check · workspace, Type Check · debt ledger, Type Check · consumer gates. Skipped: Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), the sharded Dogfood Regression Gate, Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in), one Auto Label, one Check PR Size. The verdict below rests on the diff; the pending runs are recorded, not relied on.

① Derived judgments

Public-surface changes the diff implies, each judged.

  1. @objectstack/spec/data gains four exports (api-surface/data.json and export-origins/data.json each +4, consistent with the barrel): RLS_DENY_FILTER (const), COMPOSED_READABLE_IDS_BRAND (const), ComposedReadableIds (interface), isComposedReadableIds (function); ReadableIds gains the member compose. Right — a pure widening, every member documented.
  2. RLS_DENY_FILTER (spec, readable-ids.ts:153): frozen, value { id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }, byte-identical to the literal removed from rls-compiler.ts:680 on main. Typed Readonly over { id: string }. Closed against forgery (identity is the test; nothing but the reference matches), against copies (a spread or clone is a fresh object: zero rows, not the sentinel — fail-safe) and against JSON (same). markFilterSubtreeProvenance no-ops on a frozen subtree and unmarked withholds (filter-subtree-provenance.ts:163-165, :183), so the sentinel never carries an 'author' vouch. Right.
  3. @objectstack/plugin-security keeps export const RLS_DENY_FILTER under its published Record type, bound to the spec object (rls-compiler.ts:690); index.ts:12 re-exports it unchanged. ADR-0139 D2 step 2 anchors the sentinel at packages/plugins/plugin-security/src/rls-compiler.ts#RLS_DENY_FILTER; that symbol still exists on the head, so the ADR's anchor resolves (check:adr-symbol-anchors runs inside Lint & Repo Gates, success). One definition in the tree: git grep __rls_deny__ outside tests and CHANGELOGs hits the spec literal plus prose that already exists on main (lint/validate-rls-predicate-enforceability.ts:27, explain-engine.ts:205-208,:2405, explain.zod.ts:144,:152,:381,:390) — none is a second value. Right.
  4. COMPOSED_READABLE_IDS_BRAND is Symbol.for('objectstack.filter.readableIds.composed'), distinct from READABLE_IDS_BRAND; compose defines it non-enumerable, non-writable, non-configurable and freezes the leaf. JSON, structuredClone, spread and Object.assign all drop it (pinned). A global-registry symbol is forgeable by in-process code holding the key — the same posture the E1 leaf chose and documents (a duplicated package instance must resolve the key), and what closes the forge is the door, not the brand: assertListComparandShapes admits only isReadableIds under admitReadableIds (filter-comparand-shape.ts:1089), so a composed leaf, forged or real, handed in from outside is refused INVALID_FILTER / 400 as a non-list comparand (pinned). Right, with the brand's forgeability stated as inherited design, not a new hole.
  5. ReadableIds.compose(leaf, scope) — three refusals, each a TypeError, in this order: leaf not a branded ReadableIds (a composed leaf, a spread copy, { object }, null, a string all refused; pinned); scope not undefined and not a non-null, non-array object (null, [], a string, 0, false refused; pinned); scope === RLS_DENY_FILTER by identity, a copy of it composing as an ordinary scope (pinned, and ablation 3a shows the pin reds without the check). Right in substance; one wording defect, see ③ item A2.
  6. isComposedReadableIds: own brand key holding exactly true, object a non-empty string, an own scope key holding undefined or a non-null non-array object; null, primitives, arrays, prototype-inherited brands, a missing scope key and every copy answer false (pinned, hand-built shapes included). Right as a recogniser of what compose builds. Its TSDoc's universal sentence is not: see ③ item A1.
  7. isReadableIds is byte-identical to main (diffed the function body: IDENTICAL), so it answers exactly as before for every input. A composed leaf built by compose carries only the composed brand, so isReadableIds(composed) is false and isComposedReadableIds(ReadableIds.of(x)) is false (pinned). Right. The H5 choice (own brand, not both) is the safe reading of D2's "still branded": ablation 3b shows both brands would let the door admit a forged composed leaf.

The identity switch in plugin-security — can a deny be lost? No. Every producer whose answer can reach getReadFilter's top-level return now returns the reference, and no copy is made on the way:

  • in getReadFilter itself (security-plugin.ts:5857 principal-less, :5873 sharing-scope throw, :5895 on-behalf-of, :5906 zero permission sets, :5933 resolution throw);
  • Layer 0: tenantLayer0FilterOf deny (tenant-layer.ts:177); Layer 1: the compiler's own deny (rls-compiler.ts:1064, already by reference on main at :1056) and the dropped-policy deny (security-plugin.ts:8197); the unresolvable-master deny (:8850);
  • andComposeLayers (tenant-layer.ts:249) hands back the single non-null layer by reference and builds a fresh $and only when two constrain, so the sentinel surfaces at the top level exactly when it is the only constraining layer — which is what the changeset now says. The CBP chain-cycle, depth-bound and master-sharing-failure answers { fk: { $in: [] } } and plugin-sharing's { id: '__deny_all__' } are zero-row answers that are not the sentinel, as the changeset and the PR body say.
  • isTenantWallDenial (:789, filter === RLS_DENY_FILTER) has one caller, organizationWallRefusal (:8564), whose callerWall and delegatorWall come from computeWriteTenantCheckFilter → computeLayeredRlsFilter(...).layer0 → tenantLayer0FilterOf — the reference, untouched: the verified-owner read bypass (:8268) is guarded by !isWrite and so never rewrites a write's layer0. The no-active-organization write wall therefore fires exactly as before, and a future spread at the wall reds the write-refusal suite (ablation 2: 10 red). explain-engine.ts:187 still decides by value, which stays correct.
  • Within plugin-security all three readers bind through ./rls-compiler.js (security-plugin.ts:88, tenant-layer.ts:10, explain-engine.ts:57): one module graph, so the CJS/ESM dual-copy miss noted by the dev cannot reach isTenantWallDenial. It can reach E2 (objectql loading another build of spec) and is fail-safe there; carried to ADR-0139 E2: the engine composes the ReadableIds leaf 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, rightly.

Consumers that now receive the frozen object where they used to get a copy. The CRUD middleware marks it 'policy' (a no-op on frozen input, which withholds) and sets ast.where to it by reference or wraps it in a fresh $and (:4244-4259); the compiler's frozen deny already took that path on main, so the engine's lowering has tolerated a frozen where before this PR, and spec's lowering is copy-on-write (filter-lowering.ts:60). service-analytics (plugin.ts:672 → resolveReadScopes → compileScopedFilterToSql) reads; computeControlledByParentFilter hands the master filter to a system ql.find where (:8907); explain-engine publishes it. A grep of objectql/src, core/src, service-analytics/src, plugin-security/src and plugin-sharing/src (non-test) for in-place writes ($and.push, Object.assign onto a where, delete on a where, indexed assignment on where/readFilter/scope) finds none. The ISecurityService.getReadFilter contract already forbids holding the returned object across requests and says nothing that promises a mutable copy. The one behaviour change — an editor of the returned deny now gets a TypeError — is stated in the changeset; it holds in strict-mode code (every ESM module, TS-emitted CJS), while a sloppy-mode consumer would see a silent no-op instead. Note only.

Could a test or gate pass while an identity, a refusal or a recogniser is wrong? Identities: every pin is toBe against the spec export, driven through the public method (14 cases), and ablations 1 and 2 show toEqual pins stay green while toBe pins red. Refusals: each of the three has a pin; ablation 3a reds the sentinel one. Recognisers: copies, clones, hand-built shapes and the door's refusal are pinned. One gap: nothing pins a value carrying BOTH brands, which is the case the TSDoc's disjointness sentence is wrong about (③ A1). isReadableIds's unchanged answers rest on an unchanged body, not on a pin; acceptable, since the body is byte-identical.

The case-set. No file at the head names READABLE_IDS_CASES or readable-ids-conformance (grep: zero hits in packages and scripts); nothing shipped imports or presumes the case-set. The only forward references are TSDoc sentences naming D3's driver conformance suite as a future builder of the composed leaf. See ③ C for the closing line.

② Semver level

.changeset/23009-rls-deny-sentinel-composed-readable-ids.md: @objectstack/spec minor, @objectstack/plugin-security patch; .changeset/pre.json is mode: pre, tag: next on both trees. Right. The spec entry widens (five new members, nothing removed or renamed), which is at least minor under the Post-Task Checklist and the no-major gate's pre-mode rule; nothing breaking, so no migration or ADR-0087 marker is owed and none is present (check-adr-0087-registration and check-changeset-no-major pass on the head). plugin-security keeps its export name and published type, and getReadFilter's signature is unchanged; the deny paths now return a frozen singleton the compiler path already returned — a fix, patch. Clause-②: yes (widening: new exported members of the published spec data entry) in the changeset and on PR line 2 matches the published change and the claim's line verbatim. The changeset body read against the head: the headline, the FROM → TO bullet, the identity bullet (its enumeration of sentinel returns matches the producer trace above, and its "not every zero-row answer is the sentinel" sentence is true and deliberately non-exhaustive), the composed-leaf bullet and the isReadableIds bullet are true, except the phrase "a scope that is not a plain filter object" shares the wording defect of ③ A2.

③ Boundary flags

A. Shipped-sentence defects found on the head (the FAIL).

  • A1 — isComposedReadableIds's TSDoc claims a universal disjointness the code does not enforce. The docblock (readable-ids.ts, the recogniser) says "Disjoint from isReadableIds: no value answers true to both." A hand-built object carrying an own READABLE_IDS_BRAND: true and an own COMPOSED_READABLE_IDS_BRAND: true with object: 'x' and an own scope: undefined answers true to both (both recognisers check only their own key, the value true, object, and the composed one scope); both keys are Symbol.for registry keys, so any in-process code can build it. The consequence is fail-closed wherever isReadableIds is asked (objectql's refuseUncomposedReadableIds, a D3 driver refusal), and a driver asking isComposedReadableIds first would compile the forger's scope — no wider than a single-brand forge already could at a driver called directly, so this is not a new leak. It IS a declared property the runtime does not deliver, in text that ships in the .d.ts (Prime Directive 10: keep the claim as narrow as the enforcement), and it is the exact question this review was asked. The module header's "DISJOINT" sentence is qualified ("isReadableIds answers false for a composed leaf") and holds. Remedy, either closes it: (a) make isComposedReadableIds answer false for a value that also carries an own READABLE_IDS_BRAND (one line; a double-branded value then resolves as uncomposed and is refused, fail-closed, whichever recogniser is asked first) and pin that value in the disjointness block of readable-ids.test.ts; or (b) narrow the sentence to what holds: no leaf either factory builds, and no copy of one, answers true to both; a hand-built value carrying both brands is refused as uncomposed wherever isReadableIds is asked. (a) is the better contract, since E2 and the drivers may ask the recognisers in either order.
  • A2 — "plain" overstates two refusals (fix in the same round; not alone a FAIL): compose's @throws ("neither undefined nor a plain, non-array object"), isComposedReadableIds's docblock ("a plain, non-array object") and the changeset ("a scope that is not a plain filter object"). isComposableScope accepts any non-null, non-array typeof 'object' value — a Date, a Map, a class instance pass. The repo's filter.zod.ts:2317 uses "plain object" in this loose sense too, so the intent is clear, but a .d.ts reader cannot see that: either say "a non-null, non-array object" or test the prototype the way objectql's findReadableIds does.

B. The dev's flags (report 6137424762 deviations and out_of_scope_findings, report 6137777568 deviations) and the seat's disposition (6137605804).

  • Case-set not in the PR; PR line 1 Part of → the seat ruled Q1 = B and the patch round made line 1 Fixes #23009. Disposition judged in C below: right.
  • Surface: security-plugin.ts edited at :8190/:8842 (deny returns that reach getReadFilter through andComposeLayers), a docblock moved onto isTenantWallDenial, a paragraph added to getReadFilter's docblock, a stale comment in resolveCbpRelation corrected; the import block untouched; nothing outside the claim's file list. Within the claim's "deny returns and the sentinel recogniser" reading and needed by the acceptance line. Right.
  • H3: an aliasing const under the plugin's published Record type rather than a named re-export, so the plugin's export type does not narrow. Verified at rls-compiler.ts:690; the ADR anchor resolves. Right.
  • The identity test's engine double carries no findOne instead of growing engine-double-contract.pinned.json. Verified in the test (find only, no deny path reads a row). Right.
  • Env-var listing before the overnight rule: names only, values replaced by sed; no value printed. Noted; nothing to dispose.
  • Commit attribution per AGENTS.md's model-free trailer. Not this review's surface.
  • Coverage: the full @objectstack/spec suite NOT MEASURED locally (585 s cap); Test Core 1–6 are in progress on the head. The pins that matter to this contract (readable-ids.test.ts, the identity file, tenant-layer.test.ts) were run and are read here from the diff; the verdict does not depend on the pending runs.
  • Patch-round deviations: the changeset's false engine sentence reworded to the ADR's prescription (true now: E2 is not landed, and the sentence says what D2 step 2 has the engine do); "not every zero-row answer is the sentinel" kept non-exhaustive (correct: the CBP { fk: { $in: [] } } answers exist); PR-body edits beyond the three named; post-stamped ack of the docs-drift comment; labels left as found. All right.
  • Out-of-scope findings: the CJS/ESM dual-copy identity miss and plugin-sharing's __deny_all__ passthrough → carried to ADR-0139 E2: the engine composes the ReadableIds leaf 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: right (fail-safe here, E2's to measure; see ① for why it cannot reach isTenantWallDenial). explain-engine.ts:178-186 docblock still says producers spread the constant → carried to [epic] ADR-0131 — total organization ownership: no NULL organization_id (v18 line) #15194: right (module-private, value test still correct, epic territory; the prose is now stale on the head, so the carrier must not be lost). The prose hits naming the value → "one definition holds": right; every hit pre-exists on main and documents the published value.

C. Both open questions, and the closing line.

  • Q1 (where the case-set lands) = B, the case-set lands with the driver suites (E3/E4), zero ledger rows. Right. The gate's own text makes a classified case-set red until every driver runs it, and its only other remedy is a maintainer-agreed ledger row, so the fold-in's second half could not land green by an author's act; D3's sentence ("the leaf joins the per-driver conformance case-sets") still holds under B. The seat correctly names the deviation from ADR-0139's E2 row, leaves the record unedited, and says the maintainer can overturn it. Two things to keep visible: the pointer 6137585321 on ADR-0139 execution: the engine-internal semi-join leaf across spec, engine, drivers and consumers (E0–E7 coordination) #22788 that carries the re-route is outside this review's inputs and was not read; and the card's original filed scope was the sentinel alone — the case-set entered by the seat's own acceptance of the cross-lane request (6126106500) and left by the seat's own ruling, both on the card, so the card's record is complete.
  • Q2 (collapse deny AND x to the sentinel) = A, composites stay composite. Right for this card: not asked, it changes the shape of a published scope (the analytics raw-SQL scope), and a composite composes to a subquery that matches nothing — fail-safe. Carried to ADR-0139 E2: the engine composes the ReadableIds leaf 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 as a measurable follow-up.
  • The closing line Fixes #23009 fits with the case-set moved: the card's body (one declaration in spec's data entry with D2 step 2 named as its reader, plugin-security importing the object and holding no second literal, Clause-②: yes, one changeset, the identity pin on the compiler's emit) and the accepted amendment (every getReadFilter deny return the spec sentinel by reference, pinned toBe; tenant-layer.ts the same) are delivered; the composed form, its constructor and its recogniser — what E2 imports — are delivered; nothing shipped presumes the case-set. The card's literal acceptance grep ("hits the spec file only") is met in its intent and not in its letter, because of prose hits that predate the PR; the seat's reading is the right one.

D. Engine seat 2's two requests (6123462901): item 2 (by-reference returns, toBe pins) accepted and delivered; item 1 half delivered (composed form) and half re-routed (C above). Right.

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; api-surface and export-origins are unaffected, unless remedy (a) is taken, which changes no export), and a new record at the new head. Everything else in this PR passes this review as it stands.

Implemented-by: claude/issue-23009-rls-deny-sentinel
Reviewed-by: session_012uFegdfR6K6uqvLzt7XsdZ

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2b176bd2e614b180ddd7767f850f11a52f34463e
Local-runs: none

Inputs read: card #23009 (body and all 7 comments: the cross-lane request 6123462901, the claim 6126106500, the three os-dev-reports 6137424762, 6137777568 and 6138557230, the seat's answer 6137605804 and its ACCEPT 6137814300); PR #23144 body, its 11-file list and the API diff against main (53,698 bytes; equal to git diff 21c86444e..2b176bd2e: 11 files, +654/−40); the PR's two issue comments (the docs-drift check 6137400414 and the earlier record 6137972237, VERDICT FAIL on ③ A1 and A2 at e7221cba7); the check-runs on the head; origin/main at 02bec6ca8 (merge base 21c86444e) and the head via git show, plus the PR's merge ref dfbc73165 fetched and grepped as a read. The seat's review, answer and ACCEPT were treated as claims to test. Nothing was built, run or re-run.

What moved since e7221cba7: one commit, 3 files, +30/−9 — packages/spec/src/data/readable-ids.ts (+14/−8: one guard line in isComposedReadableIds, its docblock, the isComposableScope comment, compose's @throws), packages/spec/src/data/readable-ids.test.ts (+15: one pin), the changeset (+1/−1: the A2 phrase). The other 8 files are byte-identical to e7221cba7, so the earlier record's judgments on them are carried by reference below, each said where it is carried. The net diff against the merge base was re-read whole; nothing in it contradicts them.

Check-runs on the head, as read at 2026-10-13T05:03Z: 30 completed/success, 8 in_progress, 8 completed/skipped, 0 failures. Success: Lint & Repo Gates, Lint · repo & tooling gates, Lint · CI, release & engine gates, Lint · docs & code guards, Lint · ESLint, Lint · PM dispatch-gates, Lint · scoped · slot lookup & corpus, Lint · scoped · tooling self-tests, Lint · scoped · query-options erasure, Type Check · source gates, Type Check · debt ledger, Build Core, Check Changeset (×2), Governed Surface Queue Guard, Spec property liveness, Dogfood Regression Gate, Flag docs affected by code changes, Check Documentation Links, Check PR Size, Auto Label, filter, and the four card/branch/single-writer guards (×2 each). In progress: Test Core (1/6) through (6/6), Type Check · workspace, Type Check · consumer gates. Skipped: Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), the sharded Dogfood Regression Gate, Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in), one Auto Label, one Check PR Size. The verdict below rests on the diff; the pending runs are recorded, not relied on.

① Derived judgments

A1 — the disjointness sentence, judged for every value. isComposedReadableIds now answers false when the value carries an own READABLE_IDS_BRAND key (the new fourth line of its body, checked by Object.prototype.hasOwnProperty). isReadableIds answers true only when the value carries an own READABLE_IDS_BRAND key holding true. The two conditions contradict, so no value answers true to both — proven on the two function bodies at the head, not on a pin. The guard tests the key's presence, not its value, so a double-branded value whose uncomposed brand holds anything but true is nobody's leaf (both recognisers false, a plain object to the door, refused as a non-list comparand), which is why the docblock's second paragraph is scoped "when its uncomposed brand is valid"; that scoping is exactly right. The guard refuses no legitimate leaf: compose defines only COMPOSED_READABLE_IDS_BRAND on a fresh { object, scope } literal and freezes it, so nothing either factory builds carries both brands, and the existing pins (a composed leaf answers true, the hand-built declared shape answers true) stay as they are. Right. Note only: an exotic object whose own-key answers change between calls (a Proxy with a stateful getOwnPropertyDescriptor trap) defeats any pair of sequential checks; that is in-process forging code the module's posture already concedes (a Symbol.for brand is forgeable in-process, the door closes the forge), and the most it can do is what a single-brand forge handed straight to a driver could do before this PR. No new hole.

A1 — isReadableIds byte-identical to main. The function body was extracted from origin/main and from the head and compared: IDENTICAL (same sha256). Its docblock is untouched too (no hunk reaches it). So every answer isReadableIds gives on main it gives on the head, the double-branded value's true included, which is what the module header's "the one question isReadableIds asks" needs. Right.

A1 — the pin and the dev's door verdict. The pin (readable-ids.test.ts, disjointness block) builds { object: 'x', scope: undefined } and defines both brands with Object.defineProperty (value only, so non-enumerable, non-writable, non-configurable, the factories' own shape). Expected: isComposedReadableIds false — the guard; isReadableIds true — the unchanged body. Door with { admitReadableIds: true }: assertFieldListComparands reaches $in with a non-array comparand and takes op === '$in' && admitReadableIds && isReadableIds(comparand) → continue → admitted; door without options: admitReadableIds is options?.admitReadableIds === true → false → nonListComparandError → invalidFilterComparandError, the same INVALID_FILTER / 400 path the plain-object pin and the composed-leaf pin already take. The door file (filter-comparand-shape.ts, spec and objectql) is unchanged against main, so "as measured on e7221cba7 and unchanged" is true. Without the guard the value satisfies every other check (own composed brand true, non-empty object, own scope holding undefined), so the pin's first expectation reds — consistent with the reported ablation (1 failed, 28 passed). Pin right, door verdict right. One precision the dev's sentence leaves implicit and the docblock states: the spec door only admits; the engine's seam (objectql assertListComparandShapes) then runs refuseUncomposedReadableIds, whose findReadableIds asks isReadableIds and refuses the value INTERNAL_ERROR / 500 as uncomposed today, E2 not having landed — the objectql pin on main covers every isReadableIds-true value, and isReadableIds is unchanged.

A1 — can admitting such a value ever widen what a caller reads? No. (a) One caller passes admitReadableIds: objectql's engine seam; every other door refuses the value 400. (b) Today that seam refuses every isReadableIds-true value before any driver, so nothing is evaluated. (c) Under E2 as ADR-0139 D2 prescribes, the engine reads only leaf.object and takes scope from getReadFilter(P, caller) — the caller's own access on P; ReadableIds.compose builds a fresh frozen object from leaf.object and the engine's scope and never reads the leaf's own scope key, and neither isReadableIds nor findReadableIds reads it. The forger's scope is dead weight: the value is exactly as powerful as ReadableIds.of('x'), which the door already admits for the composing caller and which composes to the caller's own readable ids. (d) A D3 driver asked directly: isComposedReadableIds false, so it cannot compile the forger's scope; isReadableIds true, so it refuses as uncomposed. (e) A driver that asks neither sees a plain object in a $in, E1's measured answers (a TypeError, a 500, an unchanged emit), none widening. No path reads the forger's scope. Right.

A2 — every "plain", judged against isComposableScope. The predicate is scope === undefined || (scope !== null && typeof scope === 'object' && !Array.isArray(scope)). The four sentences now read "undefined or a non-null, non-array object" (compose's @throws; isComposedReadableIds's docblock, which also states the own-scope-key requirement the body checks with hasOwnProperty('scope'); the isComposableScope comment; the changeset's composed-leaf bullet), and the PR body's compose line says the same. Each is exactly the predicate: a function is typeof 'function', refused, and not "an object" in the sentence's sense; a Date, a Map or a class instance is accepted and IS a non-null, non-array object, so the sentence no longer overstates. The remaining "plain" hits in readable-ids.ts (:23, :39) are the E1 module header describing an unbranded copy as "a plain object in a list slot" — the door's view of a copy, not this check; :144/:145 are "explain". What ships from spec is dist (the .d.ts carries the TSDoc) and src/**/*.zod.ts (files in packages/spec/package.json); readable-ids.ts's source and its test do not ship. Non-shipped residue, read and left: the test title at readable-ids.test.ts:242 ("neither undefined nor a plain filter object") is true of its six cases and loose as a rule; compose's TypeError text ("as a filter object, or undefined") names what to pass, not what is refused. No shipped sentence about the check overstates it now. Right.

The public surface. Unchanged since the earlier record: @objectstack/spec/data gains RLS_DENY_FILTER, COMPOSED_READABLE_IDS_BRAND, ComposedReadableIds, isComposedReadableIds and the member ReadableIds.compose (api-surface/data.json and export-origins/data.json +4 each, the same blobs as at e7221cba7); the A1 guard changes no signature and the patch commit touches neither generated file. Right — carried from record 6137972237 ① items 1 and 4–7, re-read on the head.

The rest of the PR — did anything else change, and does any earlier judgment still hold? The 8 other files are byte-identical to e7221cba7. Carried by reference from record 6137972237 ①: the sentinel's declaration (item 2), the plugin's aliasing const under its published Record type and the ADR-0139 anchor (item 3), the eight by-reference return sites, andComposeLayers' single-layer passthrough, isTenantWallDenial's one caller and the write wall, the three readers binding through one module graph, the consumers of the frozen object, and the absence of the case-set at the head. Two things re-checked on this head because main moved: (i) origin/main has changed security-plugin.ts since the merge base (+85/−308, #22900 and #23114); the PR is reported mergeable and the merge ref dfbc73165 (head into 02bec6ca8) was grepped: no ...RLS_DENY_FILTER spread survives outside prose (explain-engine.ts:183 and readable-ids.ts:136, both docblocks), one definition literal (readable-ids.ts:154), isTenantWallDenial is filter === RLS_DENY_FILTER; (ii) the set of non-test RLS_DENY_FILTER usages on origin/main is identical to the merge base's (line numbers aside), so main added no new producer or copy site the PR misses. Every earlier judgment holds.

② Semver level

.changeset/23009-rls-deny-sentinel-composed-readable-ids.md: @objectstack/spec minor, @objectstack/plugin-security patch; frontmatter and the Clause-②: line byte-identical to e7221cba7; .changeset/pre.json is mode: pre, tag: next. Right. The spec entry widens (five new members, nothing removed or renamed); the A1 guard narrows isComposedReadableIds's accept-set only for a value no factory builds, inside a member this same release introduces, so nothing narrows against any released version — minor and the (widening) arm stand. plugin-security keeps its export name, published type and getReadFilter's signature: patch. Clause-②: yes (widening: new exported members of the published spec data entry) in the changeset and on PR line 2 matches the published change and the claim's line verbatim. The changeset body re-read at the head: the headline, the FROM → TO bullet and the identity bullet are true (carried from the earlier record ②, their files unmoved); the composed-leaf bullet is now true (the A2 phrase); the isReadableIds bullet is true (body byte-identical; the door admits isReadableIds-true values only; the composed leaf refused, pinned). Check Changeset (×2) — the job that runs check-changeset-no-major and check-adr-0087-registration against the merge base (pr-automation.yml) — is green on the head, and so is Lint · CI, release & engine gates, which runs their self-test battery (check:changeset-gate-self-tests). Nothing breaking, so no migration and no ADR-0087 marker is owed, and none is present.

③ Boundary flags

A. The earlier FAIL's two items.

  • A1 — closed. Remedy (a), the one the earlier record preferred: the guard, the pin in the disjointness block, and a docblock that states the condition and what a double-branded value is (never composed; refused as uncomposed when its uncomposed brand is valid). The universal sentence is now TRUE for every value (① proof), isReadableIds is untouched, the pin reds without the guard, and no export or generated artifact moved.
  • A2 — closed. The four check-describing wordings and the PR body's compose line say "non-null, non-array object"; no shipped sentence overstates the check (① A2).

B. Report 6138557230's flags (open_questions and out_of_scope_findings both empty; deviations):

  • Four A2 hits rather than three: the earlier record's A2 named the isComposedReadableIds docblock among them, so the dev's count is the record's own list read whole; all four plus the PR body corrected. Right.
  • +30/−9 rather than a one-line fix: the extra lines are a docblock paragraph and the pin; the only code change is the guard. Right.
  • check:doc-formula-expressions and check:lean-entry-closure first exit 3 in a fresh worktree, then 0 after their prerequisite builds: the dev's local runs, recorded with both readings; CI's Lint jobs are green on the head. Nothing to dispose.
  • The body's stamp-token handling and the worktree's lifecycle: not this review's surface. Noted.

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 security-plugin.ts edits beyond the five returns (within the claim's reading); the H3 aliasing const; the identity test's engine double; the env-var listing; the attribution; the NOT-MEASURED local coverage (Test Core 1–6 still in progress on this head); the dual-package identity miss and plugin-sharing's __deny_all__ passthrough carried to #22861; the stale explain-engine.ts docblock carried to #15194; the prose hits read as documentation of the published value, one definition in the tree. Re-checked on this head: explain-engine.ts:183 still says producers spread the constant (module-private, value test still correct, the #15194 carrier still owed and not lost); the tree holds one definition literal, readable-ids.ts:154. Still holds.

D. The closing line. Fixes #23009 still fits: with the case-set moved off the card by the seat's answer, this head delivers the card's work (one declaration in spec's data entry with D2 step 2 named as its reader; plugin-security importing the object and holding no second literal; Clause-②: yes; one changeset; the identity pin on the compiler's emit) and the accepted amendment (every getReadFilter deny return the spec sentinel by reference, pinned toBe; tenant-layer.ts the same); the composed form, constructor and recogniser E2 imports are delivered. The card this PR closes must claim this branch and Part-of PR must not also close its card are green on the head. Carried from record 6137972237 ③ C.

E. For the lander, not a flag. main has moved on security-plugin.ts since the merge base; the merge ref shows the PR's hunks survive with no spread reintroduced (① above). The claim's own serial note has the later lander merge main. Eight check-runs were still in progress at the time of this read; the seat's landing to-do already waits for every check green.

Implemented-by: claude/issue-23009-rls-deny-sentinel
Reviewed-by: session_012uFegdfR6K6uqvLzt7XsdZ

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 13, 2026 05:24
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 13, 2026 05:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 13, 2026
Any commits made after this event will not be merged.

This branch has not been deployed

No deployments
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

2 participants