Skip to content

fix(plugin-security)!: a write from a caller the read door refuses is refused, whatever rows it addresses - #30219

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/sec-hardening-p3
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/sec-hardening-p3

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Clause-②: no (narrowing) — a predicate update or delete (multi: true, addressed by a where) from a caller whose read of the object is refused is refused with that read refusal before the write runs, where it was admitted before; nothing is widened.
Metadata-protocol: none
Skills-docs: updated here — content/docs/permissions/permissions-matrix.mdx (the write paragraph) and content/docs/permissions/authorization.mdx (the per-operation requiredPermissions bullet) state the rule; skills/** teaches no read-gated, write-open requiredPermissions map, so no skill moves.

维护者速读

  • 改了什么:读权限被拒的调用者,按条件(where)批量更新或删除时,直接收到读被拒的那条拒绝,一条记录都不改;按 id 写早已如此,现在两条写路一致。
  • 为什么改:按裁决 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 B 与维护者第 4 批第 6 项授权——读门拒绝的调用者,对该对象的任何写都得到同一拒绝,不写任何记录。
  • 风险与回滚:收窄。只影响「对象 requiredPermissions 只卡读、不卡改删,且调用者有写授权而无读能力」这一种组合;能读的调用者、系统写、钩子与级联写不变。回滚即回退本 PR。
  • 先看:packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts(读被拒时抛出读拒绝),packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts(第 3 步按条件写的接线),packages/plugins/plugin-security/src/security-plugin.ts(契约注释)。
  • 两分钟验证:pnpm --filter @objectstack/plugin-security exec vitest run predicate-write -t "read door refuses is refused",预期 1 个文件、9 条用例全绿,其余跳过。
  • 需要你做的:无,席位落地。

What changes

The rule: a write from a caller the read door refuses is refused, whatever rows it addresses.

  • @objectstack/plugin-security. The predicate update and delete a caller addresses ask the read door's own question of the caller's predicate before the write runs (readableRowsScopeForPredicateWrite). When that read is refused (a declared 4xx), the refusal is now thrown, so the write answers the read door's refusal and changes nothing. The by-id update and delete refuse the same caller too (step 2.7). With the permission model's own grants, the caller who meets this exists in one shape: an object whose requiredPermissions map gates read and not update/delete (ADR-0066 ⑤), and a caller holding the write grant without the capability the read requires.
  • Unchanged. A caller who reads the object keeps its predicate write, scoped to the records it reads. A caller the object's CRUD grants refuse is refused at the object gate as before. Writes the platform issues under the caller's context (a hook's or a cascade's own write), the referential clear of a deleted record's lookups, and system writes keep the exclusions step 2.7 has. A store fault on the read propagates as before. The by-id doors are not touched.
  • Docs. permissions-matrix.mdx and authorization.mdx state the rule.
  • Changeset. patch for @objectstack/plugin-security, !, ADR-0087 disposition not-required (no-migration-prescription): no metadata an author writes changes and no stored row is rewritten.

Tests

  • @objectstack/plugin-security, the predicate-write suite, new top-level describe "a write from a caller the read door refuses is refused, whatever rows it addresses" (real ObjectQL over a real SQL driver, the real middleware, a single install):
    • update and delete, with row-level policies and without: five predicates (every row, one row, a row the write scope reaches, a field predicate, an id naming no row) each answer exactly the caller's read refusal (code, status, message), and no row changes;
    • the by-id leg gives that caller the same answer as the predicate leg (control, unchanged);
    • a caller holding the capability keeps its write, scoped to the rows it reads (control);
    • a hook's own predicate write under that caller's context, and a system write, keep their answers (control: the exclusions hold).
  • The suite's header item 5 described the earlier answer for a refused read ("keeps the write's previous answer"); it now points at the new rule. No assertion pinned the earlier answer on this head.
  • @objectstack/dogfood, the predicate-write file, new top-level describe at an HTTP door: a runAs: 'user' flow started at POST /automation/:name/trigger whose update_record / delete_record node runs with multi: true over a filter from the start input. The writer's update and delete answer 400 FLOW_FAILED carrying the read refusal as the node's inner refusal, for a filter naming rows and for one naming none, and no row changes; a principal holding the capability keeps its update and delete (control); the by-id PATCH / DELETE give the writer one refusal for its own row and for an id naming none (control, unchanged).

Acceptance notes

  • Shipped metadata. No shipped object declares a requiredPermissions map that gates read and not a write verb: every object-level requiredPermissions in packages/** and examples/** is a string[], which gates every verb. The shape is authorable, so the rule is for customer metadata.
  • The ADR-0090 D12 administration gate is not touched. It refuses a write naming no single row before any row is read, in its own words, so a predicate write to the permission-assignment objects keeps the gate's sentence; readDoorAnswerForGateRefusal keeps answering the by-id write only. Nothing is written either way.
  • The single posture. Both rigs are single installs; the change reads no tenancy input, so the controls hold the same way under every posture.
  • Two doors, two refusals, one rule. Inside the ownership floor's domain the by-id doors answer such a caller the record-level 403 for every id (step 2.7's answer where a write-class row filter applies, unchanged); the predicate door answers the read door's own 403. Both are one answer whatever the write addresses, and both write nothing.
  • test:repo not run locally (declared to CI's Test Core): the diff edits no file of that project.
  • One battery red, from main. See Verification: the full dogfood battery's one failure is a by-id sys_user_permission_set control in update-by-id-refusal-order.dogfood.test.ts that main's 474c030b13 changed the answer for; it stays red with this diff's guard removed.

Verification

Readings on head 2046ac7fdd (this branch merged with origin/main 474c030b13) unless named. 5dea55fda8 changes prose only (comments, docblocks, test headers, the changeset bullet and two docs sentences): 92 derived families run, 0 NOT MEASURED; the predicate-write unit file 26 / 26.

Red before the fix (unit pins on the unfixed source)

The new describe run against the branch point's source with only the tests added: 6 failed / 26 (both verbs, with and without row-level policies, and the by-id comparison). With the fix: 26 / 26.

One-shot ablation (mutate and restore, rebuilt each leg)

The guard line in security-plugin-addressed-writes.ts (if (answer.refused) throw answer.error;) replaced through scripts/ablation-replace.mjs (anchor 1 to 0, blob d14ca8206269 to 8b335291908e); @objectstack/plugin-security rebuilt; ablation-dist-preflight found the marker in 2 built files. Mutated, on head f6401266b7: the unit file 6 failed / 26; the dogfood file 3 failed / 16; red, then restored (the by-id control now reads a row no other case addresses, 84c82fd1c9). Restored by the tool: blob equals HEAD, git diff HEAD empty, whole tree clean; rebuilt; preflight --absent exit 0 over 6 built files; unit 26 / 26, dogfood file 16 / 16 on 84c82fd1c9.

Suites
  • @objectstack/plugin-security full, under the verify lock: 242 / 242 files, 5296 passed, 45 skipped (exit 0).
  • @objectstack/plugin-security typecheck: exit 0. @objectstack/dogfood typecheck: exit 0.
  • The dogfood predicate-write file alone: 16 / 16.
Dogfood battery (full, once)

pnpm --filter @objectstack/dogfood test under the verify lock on 2046ac7fdd: 417 passed / 1 failed / 3 skipped files; 3487 passed / 1 failed / 34 skipped tests (verdict: command-exit 1). The one failure: update-by-id-refusal-order.dogfood.test.ts, "control: an organization administrator, who reads the platform grant object, keeps the administration gate's sentence for a row and for no row" — the id naming no row now answers 404 RECORD_NOT_FOUND where the pin expects the gate's 403. That answer is main's 474c030b13 (readDoorAnswerForGateRefusal answers the read door's not-found for an assignment row the administrator's read does not return). Control leg: the same file with this diff's guard removed (the ablation above, rebuilt, preflight marker present) fails the same case, 1 failed / 19. Not this diff's consequence; reported to the seat.

Derived gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 92 families, each run with its exit code captured before any pipe. Three first refused with exit 3 (check:skill-examples and check:dual-build-cjs-loads on unbuilt packages outside the diff; check:type-check-debt when its closure build could not take the verify lock); after building those packages and re-running under the lock, all three exit 0. --ran reconciliation: 92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). check-adr-0087-registration: one declared-breaking changeset, not-required (no-migration-prescription).

… refused, whatever rows it addresses

The predicate update and delete a caller addresses are answered the read
door's own refusal when that caller's read of the object is refused, before
the write runs, as the by-id update and delete already are. Nothing changes.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
… at the flow trigger door; changeset and docs state the rule

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…by-id control compares a row with an id naming none

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
… no other case addresses

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 3 documentable anchor(s).

5 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/system-context.mdx (via registerAuthzMiddleware (symbol, a top-level function))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

⛔ 2 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/v18/18-0.mdx (via SecurityPlugin (symbol, a top-level class))

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
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 17 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 ff12aed37b35ecf2cc1d8fd9a3f609fa79dfa6e7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0daefc4fb6630d9a88bb7b3c8ca993868397d51d — the merge of head 5dea55fda88256ac633800affcc09f3ab2b9c674 into base ff12aed37b35ecf2cc1d8fd9a3f609fa79dfa6e7, 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 0daefc4fb6630d9a88bb7b3c8ca993868397d51d && git checkout 0daefc4fb6630d9a88bb7b3c8ca993868397d51d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ff12aed37b35ecf2cc1d8fd9a3f609fa79dfa6e7 5dea55fda88256ac633800affcc09f3ab2b9c674 && git checkout -B drift-repro ff12aed37b35ecf2cc1d8fd9a3f609fa79dfa6e7 && git merge --no-ff 5dea55fda88256ac633800affcc09f3ab2b9c674

node scripts/docs-audit/affected-docs.mjs --json ff12aed37b35ecf2cc1d8fd9a3f609fa79dfa6e7

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract-review-claim: session_01QSeKYn5CmmdShCJifm6WYj · head 2046ac7 · 2026-11-05T02:17Z

… refuses is refused, by id or by predicate

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5dea55fda88256ac633800affcc09f3ab2b9c674
Local-runs: none
Scope: packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts, packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/predicate-write-unreadable-not-matched.test.ts, packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts, .changeset/sec-hardening-p3-write-refused-read-door.md, content/docs/permissions/authorization.mdx, content/docs/permissions/permissions-matrix.mdx + none

Delta review by the same reviewer, on top of the FAIL record at 2046ac7fddadb255e49bb56cfa1f8db276978230. Inputs: git diff 2046ac7fdd..5dea55fda8 (one commit, 8 files, 16 / 15, prose only: docblocks, code comments, test headers, the changeset's rule bullet and the two docs sentences — no statement, type or assertion moves), the rewritten PR body read in full, and the check-runs on 5dea55fda8. Read-only: nothing built, run or re-run. Every judgment of the earlier record that the delta does not touch stands as written there.

① Derived judgments

  • The must-fix is cleared. The "What changes" sentence that stated the pre-fix path is gone; the Verification "Red before the fix" and ablation sections now carry counts, case names, the blob pair and "red, then restored" only. Re-read in full against §6: title, body, changeset, the two docs sentences, the six commit messages (the new one: "state that a write from a caller the read door refuses is refused, by id or by predicate") and every test name stay at rule level. The Clause-② line's "where it was admitted before" is the narrowing declaration's own wording, as the first record accepted it on the changeset. No disclosure remains.
  • The delta changes no behaviour. Every hunk is inside a comment, a docblock, a Markdown page or the changeset body; the guard line, the call site, the return types and the tests' assertions are byte-identical to 2046ac7fdd. The first record's ① and ④ judgments carry over unchanged.
  • Check-runs on 5dea55fda8: every required context success — Lint & Repo Gates, TypeScript Type Check, Test Core (all eight shards), Build Core, Governed Surface Queue Guard, Test UI, Live E2E UI; Lint UI, Type Check UI and Build & E2E UI skipped (no ui/ path). Dogfood Regression Gate is success with its shards skipped, as on the PR run before: the new dogfood file is measured by the dev's local battery, not by CI's PR run.

② Semver level

Unchanged from the first record: @objectstack/plugin-security patch with !, Clause-②: no (narrowing), Metadata-protocol: none, ADR-0087 not-required (no-migration-prescription). The changeset's only edit is one rule sentence ("The by-id update and delete are refused too"); it adds no prescription and moves no level. Check Changeset is success.

③ Boundary flags

  • §6: clear on this head (see ①).
  • Should-fix (1) of the first record is closed. permissions-matrix.mdx, authorization.mdx, the readUnlessRefused docblock, the method docblock, the two code comments and both test headers now say the by-id leg "refuses" or "is refused", without naming which refusal; the unit header adds that the two legs give one answer only with no row-level policy, which is what the code does.
  • Should-fix (wording, accepted as noted): two body sentences still equate the legs — "This is the answer the by-id update and delete already give the same caller (callerCannotReadAddressedRow, step 2.7)" in "What changes", and the Tests bullet "the by-id leg gives that caller the same answer as the predicate leg (control, unchanged)". The Tests bullet is true of the rig it describes (no row-level policy); the first sentence is wider than the code inside the ownership floor's domain, where the by-id leg answers the record-level 403, as the body's own acceptance note "Two doors, two refusals, one rule" says. Reword to "is refused too", as the changeset now does. The Verification section still names 2046ac7fdd as its head; the delta is prose only, so the readings hold, and the head's check-runs are the measurement at 5dea55fda8 — say so or leave as noted. Neither changes a contract.
  • Should-fix (2) of the first record (the by-id re-read's catch relabelling a store fault) is a follow-up card through the seat, unchanged by the delta.
  • Open questions (1) and (2) and residual (1): answered in the first record; nothing here reopens them.
  • Governed surfaces: none in the file list.

④ Design judgment

Unchanged from the first record: the narrowing takes no declared capability away at any consumer and makes the by-id leg's rule real on the predicate leg; the break attempts there (readable-field predicate, write policy plus capability, single, system and run-as-system, controlled-by-parent, delegated, cap and store fault) were read in code the delta does not touch. No must-fix.

Implemented-by: claude/sec-hardening-p3
Reviewed-by: session_01QSeKYn5CmmdShCJifm6WYj

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

席位核对

ACCEPT · head 5dea55fda88256ac633800affcc09f3ab2b9c674 · 2026-11-05T03:00Z

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 size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant