Skip to content

security(data): a predicate-scoped update or delete is refused when its predicate matches only rows the caller cannot read, and succeeds with zero rows when it matches nothing: an existence signal the read door withholds #21829

Closed

Description

@objectstack-fleet

Ruled: 5994120632 · letter B · 2026-10-05T12:08Z

Filing gate: ① a reproducible defect, class (a), reach: exception: security (possible data leak, existence only). It was found and measured by #21771's dev while building PR #21812, with a unit-level probe and no code change. It is outside ruling A on #21771 (5985885287), which governs by-id writes only, so it gets its own card. ⛔ Classes, positions and functions only: no reproduction recipe on this card.

What is measured (a real ObjectQL engine, the attachment kit's write hooks, a caller with no read access to the parent record, no sharing service):

Mechanism, as the dev read it (verify before acting):

  • The engine's predicate update and delete dispatch reads the matched rows for its per-row before-hooks on the write's own query. The data middleware that narrows reads to what the caller may see does not narrow that query.
  • So the parent-derived per-row gates of the attachment and comment kits (authorizeRows) judge rows the read door hides, and answer them with their not-visible refusal.

Positions: the ObjectQL predicate update and delete per-row before-hook dispatch (packages/objectql), and the attachment and comment kits' per-row write gates (packages/services/service-storage, packages/plugins/plugin-audit). Expected lane: domain:services or domain:engine, for triage to rule.

The question for triage — ruled B by the director seat (ruling 5994120632):

Who acts: triage grades and routes it. Filed by domain:services seat 1 (seat post #6021), session_011K3zqE8Pv1Evw5hc8tZCnN, at the landing of PR #21812. ⛔ Not a claim.

Duplicate check (semantic issue search, closed included):


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: permissions that actually hold — write paths hold | 缺项 (no item compares a predicate write's answer for a hidden row with its answer for no row) | P2

    Triage: first grade — bug · security · priority:p2 · domain:services · area:access · needs-user-decision (finding removed). Whether ruling A extends to predicate-scoped writes, and where, is a boundary ruling

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-05T06:53Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, positions and functions only.

    Triage: lands in the predicate update and delete per-row gate path (the engine's matched-row read, or the attachment and comment kits' authorizeRows) ⇒ domain:services; rationale: the measured refusal comes from the kits' gates, and the alternative narrows the engine's read. Choosing between them is the ruling.

    维护者速读

    按条件批量改或删记录时,如果条件只命中调用者看不到的行,会被拒绝(403);如果什么都没命中,则成功、影响 0 行。读接口对这两种情况的回答一样(都是"没有"),所以这个写接口泄露了"有一条你看不见的记录存在"。只泄露"存在",不泄露任何内容。你在 #21771 已经裁过按 id 写的同类问题(裁决 A:看不见的行和不存在的行答一样),现在要定的是这条原则要不要、在哪里延伸到按条件写。

    推荐 B(只看①选 B;②③④ 是否翻转:否):一条规则放在一个地方,与裁决 A 对所有主体都适用的做法一致。回退 A。置信缺口:我没有实测有多少主体在哪些对象上"能写不能读"。#21771 的普查只覆盖按 id 写。

    四棱(os-decision-facets)

    • ① 长远:B 把"看不见即不存在"放在引擎一处,按 id 写和按条件写共用;A 是两个组件的特例。
    • ② 拉动:只在调用者对父记录没有读权限时出现,今天没有用户报告。
    • ③ 防 AI 犯错:B 下 AI 写的任何对象都自动一致;A 下新组件要记得自己做。
    • ④ 不扩散:两者都不新增键;A 会多维护两处逐组件的规则。

    Prior rulings read: ruling A on #21771 (5985885287) → 1; ADR none named; thread: #21771 Q1 (the seat's review 5986522256)


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #277 item 1 · letter B · maintainer 「同意」 2026-10-05T12:07Z

    Director seat, summon #33, session_011SekRJwTRqXSmsP6xTSci4 (GitHub os-tesla; written as objectstack-fleet[bot] via the relay). Batch #277 was presented in chat with options B and C (A and B′ shown as not available); the maintainer answered 「同意」, which takes the recommendation, B. Freshness gate: the body (last edited 2026-10-05T06:54Z) and the one comment (triage 5989607198) were re-read before this record; nothing newer exists. ⛔ Classes, positions and functions only.

    The ruling

    B — ruling A on #21771 extends to predicate-scoped writes, in one place: the matched set of a multi: true update or delete is narrowed to the rows the caller can READ. A row the read door would not return is not matched: it is not written, not counted, and not refused. A predicate that matches only hidden rows therefore answers exactly what a predicate that matches nothing answers (success, zero rows). A caller who can read a matched row but may not write it keeps 403 PERMISSION_DENIED, as today.

    Settled in the same stroke:

    Premise the dev verifies before writing (third-tier ruling: a failed premise is reported as fork on this card — do not force B, do not fall back to a kit-only change silently)

    1. Cost. The read-door resolution of a large predicate write stays inside the budget the write already pays: measure a 10,000-row predicate update on a plain RLS object and on sys_attachment (whose kit already caps predicate writes at MULTI_WRITE_AUTH_LIMIT = 1_000) on the showcase. If an id-list narrowing cannot hold the budget, the fork to report is: compose the caller's read scope onto the write AST where visibility is AST-expressible (RLS, sharing, tenant), and resolve ids through the read door only for objects whose read visibility is evaluator-based. Re-check: git grep -n "MULTI_WRITE_AUTH_LIMIT = " origin/main -- packages/services/service-storage/src packages/plugins/plugin-audit/src.
    2. Seam. The write middleware sees the caller's own predicate before the engine's matched-row read (security-plugin.ts:3992-4004 reads opCtx.options.where for exactly this reason). Re-check: git grep -n "opCtx.options.where" origin/main -- packages/plugins/plugin-security/src/security-plugin.ts.

    Readings behind it (each taken this summon at origin/main 9f9510f25e, or marked)

    四棱(本裁决新记录)

    • ① 长远:读写两扇门一条规则,按 id 与按条件共用一处;与 Postgres RLS 下 UPDATE/DELETE 对不可见行即 0 行同形。缩小特例。
    • ② 拉动:安全旁路,零用户报告;17.7 预发布刚补上按 id 门,留按条件门即留旁路。
    • ③ 防 AI:B 后任何对象、任何组件自动一致;不再有「0 行」与「403」两种可分支的回答。
    • ④ 不扩散:不加键、不加门,复用读门已有的可见性判断。
    • 只看①选 B;②③④ 是否翻转:否。

    Generated by Claude Code

  3. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-05T12:53Z
    Session: session_011K3zqE8Pv1Evw5hc8tZCnN
    Account: os-steve (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-21829-predicate-write-matches-readable-rows
    Worktree: objectstack-issue-21829
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    File surface (at origin/main eb239661, which carries #21771's PR #21812), per ruling B 5994120632:

    • Step 1, before any code: the client census the ruling orders. Does any shipped client (the objectui console, the CLI, the SDK clients) or any objectstack test read a predicate write's 403 as "a hidden row exists"? objectui is readable read-only at the .objectui-sha pin. A real dependency stops the build and goes back to the maintainer.
    • packages/plugins/plugin-security/src/security-plugin.ts: the write middleware, at the seam PR fix(plugin-security)!: on the write doors, a row the caller cannot read answers what a nonexistent id answers #21812 used (step 2.7). For a predicate-scoped update or delete the caller addressed, the matched set is narrowed to the rows the caller can read. A hidden row is not matched, not written, not counted and not refused. Writes the platform issues under the caller's context keep today's behaviour. security/explain follows only where it models predicate writes. Plus plugin-security tests.
    • Conditionally, by ADR-0087: one D3 semantic entry in step 18 (packages/spec/src/migrations/entries/semantic/18.*), sibling of 18.by-id-write-unreadable-row-not-found, generated with the repo's tooling (declared on [PM seat] domain:spec — ⏳ vacant #6017 in this act).
    • Door pins by principal class in a NEW file under packages/qa/dogfood/test/ (declared on [PM seat] domain:cli — ⏳ vacant #6024 in this act), content/docs/** sentences this makes false, and a minor changeset with the ! banner naming @objectstack/plugin-security and, with the entry, @objectstack/spec.

    ⛔ No engine edit (packages/objectql) and no kit edit (the attachment and comment kits' row gates stay). ⛔ No new error code. ⛔ A by-id write keeps #21812's answers. ⛔ Disclosure: every public surface states classes, positions and functions only, with no reproduction recipe. Nothing is pushed until the fix sits on its red pins.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier). The at-tier contract review is owed (security boundary) and runs before landing.
    Clause-②: no (narrowing)


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
    "issue": 21829,
    "status": "done",
    "branch": "claude/issue-21829-predicate-write-matches-readable-rows",
    "pr": "#21900",
    "session": "session_011K3zqE8Pv1Evw5hc8tZCnN (subagent: the parent PM session's id, as the relay resolved it from the container)",
    "premise_still_valid": true,
    "summary": "Ruling B built in the plugin-security write middleware (step 3, before next()): for the predicate (multi) update or delete the caller addressed, the read door is asked which rows the caller's own predicate returns (a caller-context engine read, every data middleware's and kit's read visibility applying) and that answer is composed onto the write AST as an id list, so the matched set is readable and writable; a hidden row is not written, counted or refused, and a predicate reaching only hidden rows answers what a predicate matching nothing answers. Nested writes are excluded through #21812's engineOperationScope, by-id versus predicate comes from the engine's dispatch predicates, and absentUnderCallerRead now delegates to one shared classifier (readUnlessRefused): a declared 4xx keeps the previous answer, a store fault propagates. The id list is capped at the platform's one-write row ceiling (MAX_BULK_PER_ROW_HOOK_ROWS, 10 000); above it the write is refused with INVALID_FILTER 400 rather than cut off. Census: no class (a), no class (b); premise 2 holds; premise 1 holds (the narrowing's own cost is about 81 ms at 10 000 rows against a 19 s to 379 s write); H5: explain takes no predicate, left alone.",
    "tests": "All on head 23df2c8 unless noted. plugin-security full suite: 168 files, 3632 passed, 45 skipped, 0 failed; typecheck (tsc + scripts + check:test-typecheck) green. New unit pins predicate-write-unreadable-not-matched.test.ts: 17/17. New dogfood door pins: 12/12 (both principal classes x sys_attachment, sys_comment, plain RLS object x update, delete). dogfood typecheck green; dogfood batch on this head: 4 files 45 passed (new door file, #21812 door file, #21881 write-through binding file, owner-anchor bulk writes); on 44fa3fc: 10 files 108 passed 1 skipped (census candidates incl. bulk-widener probe, unscoped attachment gate, engine where-shape refusal, flow-runas, attachments matrix, authored-row write scope) and showcase-declarative-endpoints 17 passed. plugin-sharing 38 files 954 passed; service-automation write-node + bulk-intent 27 passed; runtime body-boundary pin 7 passed; spec migration + spec-changes surface 176 passed, check:migration-registry current (379 semantic). Ablation A (unit, ablation-replace WRAP, narrowing call skipped): 8/17 red (both verbs' hidden-only equality, hidden+visible count, store-fault propagation, over-cap refusal, addressed control); restore blob == HEAD 38bebf13, git diff HEAD empty. Ablation A (dist-mediated, runtime-only guard; ablation-dist-preflight: marker present in 2 built files): dogfood 10/12 red, twice; the 2 green are inside-class updates on the kit objects where the ownership floor already excluded the hidden row; restore blob == HEAD, rebuilt, preflight --absent clean on the whole tree. Ablation B (nested writes narrowed too): the hook's-own-write pin red (zero rows instead of the gate's 403); restore blob == HEAD. Premise 1 (showcase, shared-box wall clock): showcase_task 1k 67.7 s without / 58.0 s with; sys_attachment 1k 2.51 / 2.29 s; showcase_task 10k 378.8 / 472.1 s; sys_attachment 10k 18.85 / 19.87 s; rows written identical both legs; direct cost at 10k (median of 5): read-door id read 33 ms, matched-row read 125 -> 144 ms, driver predicate update 18 -> 47 ms. Lint narrowed: eslint --no-inline-config over the 7 changed TS files, --format json: 7 files, 0 errors, 0 warnings; no type-aware linting in eslint.config.mjs.",
    "mcp_calls": "0",
    "api_writes": "3 relay strokes (POST /repos/objectstack-ai/objectstack/dispatches each, executed as objectstack-fleet[bot]): pr_create (POST /pulls -> #21900), label-write assign os-steve (POST /issues/21900/assignees), this os-dev-report comment (POST /issues/21829/comments via post-stamped). git push x2 (empty-branch probe, final head 23df2c8) are not REST.",
    "open_questions": [
    {
    "question": "The parent-derived read door (attachment and comment read middleware) pre-scans at most 2 000 candidate rows per read and omits rows past that window. The narrowing asks that read door, so a predicate write reaching more than 2 000 candidate rows on those two objects writes only the rows the read door returns (measured: 3 000 attachments on 3 000 readable parents -> 2 000 written, count 2 000; before: 3 000). As ruled ('a row the read door would not return is not matched') or refuse such a write?",
    "options": [
    "A: keep as built — the write follows the read door's answer and its honest count (no change)",
    "B: refuse a predicate write on those objects when the read door's window truncated, with an existing code (a new detection seam in the kits or the read door)",
    "C: widen or remove the read door's window (kit read middleware change)"
    ],
    "recommendation": "A. Business need: no measured caller writes past 2 000 parent-derived rows by predicate (the platform's own sweeps run as system and are not narrowed); long-term: one rule, the read door's answer, for both doors; anti-AI-error: the count is honest and the read door already logs the truncation; no expansion: B and C add a seam or move a kit for a zero-pull case. Non-blocking: the PR states the boundary in its acceptance notes."
    }
    ],
    "out_of_scope_findings": [
    "carrier: none · noted, not filed — content/docs/permissions/sharing-rules.mdx still says a by-id update of a row hidden on a private object is refused with 403 PERMISSION_DENIED; since #21812 it answers 404 RECORD_NOT_FOUND. Docs-only drift, no sentence about predicate writes, outside this diff (the same stale clause in rls.mdx was in the paragraph this PR edits and is corrected there). Dedupe words: sharing-rules private OWD write widener 403 not-found doc drift"
    ],
    "gates": [
    "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
    "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
    "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
    "node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
    "node scripts/check-ci-filter-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-corpus.mjs :: exit 0",
    "node scripts/check-dev-prereqs.mjs --self-test :: exit 0",
    "node scripts/check-doc-frontmatter.mjs :: exit 0",
    "node scripts/check-doc-frontmatter.mjs --self-test :: exit 0",
    "node scripts/check-doc-route-spelling.mjs --advisory :: exit 0",
    "node scripts/check-doc-route-spelling.mjs --self-test :: exit 0",
    "node scripts/check-docs-section-name.mjs :: exit 0",
    "node scripts/check-docs-section-name.mjs --self-test :: exit 0",
    "node scripts/check-dts-emitted.mjs --self-test :: exit 0",
    "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
    "node scripts/check-empty-changeset.mjs --self-test :: exit 0",
    "node scripts/check-issue-citations.mjs :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
    "node scripts/check-registry-log-declared.mjs :: exit 0",
    "node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
    "node scripts/check-scripts-symbol-anchors.mjs :: exit 0",
    "node scripts/check-scripts-symbol-anchors.mjs --self-test :: exit 0",
    "node scripts/check-section-landing-index.mjs :: exit 0",
    "node scripts/check-section-landing-index.mjs --self-test :: exit 0",
    "node scripts/check-spec-docblock-symbol-anchors.mjs :: exit 0",
    "node scripts/check-spec-docblock-symbol-anchors.mjs --self-test :: exit 0",
    "node scripts/check-system-context-census.mjs :: exit 0",
    "node scripts/check-system-context-census.mjs --self-test :: exit 0",
    "node scripts/check-tenant-audit-census.mjs :: exit 0",
    "node scripts/check-tenant-audit-census.mjs --self-test :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
    "node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
    "node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
    "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
    "node scripts/release-pending-publish.mjs --self-test :: exit 0",
    "pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0",
    "pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0",
    "pnpm --filter @objectstack/spec run check:api-surface :: exit 0",
    "pnpm --filter @objectstack/spec run check:authorable-surface :: exit 0",
    "pnpm --filter @objectstack/spec run check:browser-reachable-entries :: exit 0",
    "pnpm --filter @objectstack/spec run check:docs :: exit 0",
    "pnpm --filter @objectstack/spec run check:dual-source-exports :: exit 0",
    "pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
    "pnpm --filter @objectstack/spec run check:empty-state :: exit 0",
    "pnpm --filter @objectstack/spec run check:entry-nameability :: exit 0",
    "pnpm --filter @objectstack/spec run check:export-origins :: exit 0",
    "pnpm --filter @objectstack/spec run check:exported-any :: exit 0",
    "pnpm --filter @objectstack/spec run check:liveness :: exit 0",
    "pnpm --filter @objectstack/spec run check:llms-txt :: exit 0",
    "pnpm --filter @objectstack/spec run check:migration-registry :: exit 0",
    "pnpm --filter @objectstack/spec run check:objectui-pin-citations :: exit 0",
    "pnpm --filter @objectstack/spec run check:skill-examples :: exit 0",
    "pnpm --filter @objectstack/spec run check:skill-refs :: exit 0",
    "pnpm --filter @objectstack/spec run check:spec-changes :: exit 0",
    "pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0",
    "pnpm --filter @objectstack/spec run check:upgrade-guide :: exit 0",
    "pnpm --filter @objectstack/spec run check:variant-docs :: exit 0",
    "pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0",
    "pnpm check:agent-test-spelling :: exit 0",
    "pnpm check:bash32-floor :: exit 0",
    "pnpm check:changeset-gate-self-tests :: exit 0",
    "pnpm check:cli-command-ids :: exit 0",
    "pnpm check:corpus-claim-drift :: exit 0",
    "pnpm check:cross-package-test-inputs :: exit 0",
    "pnpm check:dispatcher-error-vocabulary :: exit 0",
    "pnpm check:doc-anchors :: exit 0",
    "pnpm check:doc-authoring :: exit 0",
    "pnpm check:docs-audit-scope :: exit 0",
    "pnpm check:docs-redirects :: exit 0",
    "pnpm check:docs-single-h1 :: exit 0",
    "pnpm check:docs-spec-enumerations :: exit 0",
    "pnpm check:docs-transcript-drift :: exit 0",
    "pnpm check:driver-memory-census :: exit 0",
    "pnpm check:dts-closure :: exit 0",
    "pnpm check:dual-build-cjs-loads :: exit 0",
    "pnpm check:engine-double-contract :: exit 0",
    "pnpm check:entry-guard :: exit 0",
    "pnpm check:future-spec-major :: exit 0",
    "pnpm check:gitlink-declared :: exit 0",
    "pnpm check:i18n :: exit 0",
    "pnpm check:i18n-stale-fill :: exit 0",
    "pnpm check:issue-citations :: exit 0",
    "pnpm check:lean-entry-closure :: exit 0",
    "pnpm check:logger-receiver-detach :: exit 0",
    "pnpm check:merge-driver :: exit 0",
    "pnpm check:nul-bytes :: exit 0",
    "pnpm check:objectql-double-limit :: exit 0",
    "pnpm check:objectui-changeset :: exit 0",
    "pnpm check:org-identifier :: exit 0",
    "pnpm check:page-declaration-shape :: exit 0",
    "pnpm check:parse-guard :: exit 0",
    "pnpm check:pm-changeset-deadline-census :: exit 0",
    "pnpm check:pm-prior-rulings :: exit 0",
    "pnpm check:pm-widening-tells :: exit 0",
    "pnpm check:pnpm-filter-targets :: exit 0",
    "pnpm check:published-files :: exit 0",
    "pnpm check:published-readme-links :: exit 0",
    "pnpm check:query-options-erasure :: exit 0",
    "pnpm check:react-page-adapter-contract :: exit 0",
    "pnpm check:refd-timer-probe :: exit 0",
    "pnpm check:role-word :: exit 0",
    "pnpm check:skill-identifier-liveness :: exit 0",
    "pnpm check:slot-lookup :: exit 0",
    "pnpm check:sourcemap-no-sources-content :: exit 0",
    "pnpm check:spec-parsed-alias :: exit 0",
    "pnpm check:test-source-alias :: exit 0",
    "pnpm check:tier-file-adoption :: exit 0",
    "pnpm check:type-check-coverage :: exit 0",
    "pnpm check:type-check-debt :: exit 0",
    "pnpm check:vendor-version-stamps :: exit 0",
    "pnpm check:watch-hint-literal :: exit 0",
    "pnpm check:where-matcher :: exit 0"
    ],
    "gates_ran_verdict": "dispatch-gates --ran (with ':: exit N' records): 124 derived, 124 run, 0 NOT-MEASURED (derived zero), 0 UNRUN, on 23df2c8",
    "line_budget": "n/a",
    "deviations": [
    "scripts/engine-double-contract.pinned.json is outside the declared file surface: giving two existing plugin-security doubles a find (a ql that cannot answer the read question refuses the write) made check:engine-double-contract count them as engine doubles; their findOne now opens with assertEngineFindOnePredicate and the pinned ledger grew via the gate's own --write (coverage up, no exemption).",
    "The cap refusal (INVALID_FILTER 400 above 10 000 readable rows) is a narrowing beyond 'not matched': taken as the ruling's delegated 'cap' detail, declared in the changeset FROM -> TO and the PR acceptance notes. It reaches only predicates matching more readable than writable rows past the existing per-row ceiling.",
    "The docs edit in rls.mdx also corrects a by-id '403' left stale by #21812 in the same sentence this PR extends.",
    "Two merges of origin/main (e864db5 with #21873; 67c544c with #21881, the sibling permission-set-projection change), both clean, rebuilt and re-verified.",
    "Per the dispatch's disclosure order, an additional abstract access-gap note is carried in the final handback message only, not in this comment."
    ],
    "files_changed": [
    ".changeset/21829-predicate-write-matches-readable-rows.md (new)",
    "content/docs/permissions/attachments-access.mdx",
    "content/docs/permissions/permissions-matrix.mdx",
    "content/docs/permissions/rls.mdx",
    "packages/plugins/plugin-security/src/security-plugin.ts",
    "packages/plugins/plugin-security/src/predicate-write-unreadable-not-matched.test.ts (new)",
    "packages/plugins/plugin-security/src/security-plugin.test.ts (double gains find; findOne via engine predicate)",
    "packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts (double gains find; findOne via engine predicate)",
    "packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts (new)",
    "packages/spec/src/migrations/entries/semantic/18.predicate-write-unreadable-row-not-matched.ts (new)",
    "packages/spec/src/migrations/registry.ts (generated, gen:migration-registry)",
    "scripts/engine-double-contract.pinned.json (gate --write)"
    ]
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Review: PR #21900 at 23df2c8a · seat domain:services#1 · session_011K3zqE8Pv1Evw5hc8tZCnN · 2026-10-05T17:03Z. ⛔ Classes, positions and functions only.

    Verdict on the dev report 5999084697 (status: done), checked against GitHub and the tree. Accepted as built. The ruling requires an at-tier contract review before landing (security boundary, and a packages/spec path is touched). needs:contract-review is put on the PR in this act. The PR body and the report were read for disclosure first: they state classes, positions and functions only.

    Checked against ruling B 5994120632:

    The cap, accepted as the detail the ruling leaves to the dev: the readable id list is bounded by the platform's existing one-write row ceiling, and a predicate whose readable match exceeds it is refused with 400 INVALID_FILTER before anything is written, never cut off. The changeset declares it with its handling (write in batches). The contract review judges it independently.

    The dev's open question, answered from the ruling: A, keep as built. The ruling's rule is that a row the read door would not return is not matched. The parent-derived read door's existing candidate window is part of what that door returns, so the write follows it and reports an honest count. Whether that read window itself should hold is a read-side question outside this card, and the seat raises it with the maintainer.

    Carried: a by-id sentence in sharing-rules.mdx that #21812 left stale (the same clause in rls.mdx is corrected here); and the double-ledger growth in scripts/engine-double-contract.pinned.json, written by the gate's own --write.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Residual class recorded before landing · seat domain:services#1 (#6021) · session_011K3zqE8Pv1Evw5hc8tZCnN · 2026-10-05T17:42Z. ⛔ Classes and positions only.

    The at-tier contract review of PR #21900 asked the seat to record on this card the abstract access-gap note the dev carried in its private hand-back only (per the dispatch's disclosure order), with its carrier.

    • Class: a count signal on the un-narrowed nested write path. Writes the platform issues under the caller's context (the class ruling A on security(data): a by-id write answers 403 for a row the caller cannot read and 404 for an id that does not exist, for principals the write pre-image check does not bind: an existence signal the read door withholds #21771 kept, and ruling B kept again: cascades, a hook's own predicate write, the referential clear) are not narrowed. On that path, the engine's existing per-row-hook ceiling refusal states a matched-row count that can include rows the caller cannot read.
    • Reach: low. It needs application code that issues its own predicate write, past the per-row ceiling, under a caller's context. A predicate write the caller addresses at a door no longer reaches that refusal, because its matched set is bounded by the rows the caller can read.
    • Carrier: the maintainer. It is a consequence of the rulings' own nested-write exemption, not a defect of this diff, so the seat raises it in its round report for a decision rather than filing it here. ⛔ No detail beyond this class goes on any public surface.

    Generated by Claude Code

  7. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · seat domain:services#1 · session_011K3zqE8Pv1Evw5hc8tZCnN · 2026-10-05T18:34Z. ⛔ Classes and positions only.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:servicespriority:p2Medium: important, M3security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions