Skip to content

fix(plugin-security): an organization administrator's assignment removal is judged on the stored row, and its refusal names only values the caller can read - #30248

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

objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/sec-hardening-r2

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:组织管理员按 id 删除授权行(sys_user_position、sys_user_permission_set)时,只要它自己读得到这一行,委派管理闸门(ADR-0090 D12)的组织分支就按存储行判定,和委派分支一样;拒绝提示只写出调用者自己读得到的值。
  • 为什么改:落实维护者裁决(ruling of record 6649064874)「ADR-0090 D12 授权闸门按存储行判定,拒绝提示只写出调用者读得到的值」,该裁决覆盖两个分支;fix(plugin-security): an assignment write the administrator cannot address answers not-found, as a read does; a refusal names only values the caller can read #30165 的契约复核把组织分支与之不一致记为 should-fix。
  • 风险与回滚:这是放宽:调用者的字段规则或遮罩规则作用在 position / permission_set 上时,组织边界内的删除现在放行(18.2.0 即按存储行判定);边界外照旧拒绝;读不到的行与不存在的 id 仍回答 404;修改、插入、租户级管理员、委派分支都不变。回滚即撤销本 PR。
  • 先看:packages/plugins/plugin-security/src/delegated-admin-gate.ts 的 organizationTargets(先以调用者读取判断有没有这一行,读到后取存储行)与 organizationAdminFailure 里三处 mayName 措辞分支。
  • 两分钟验证:pnpm --filter @objectstack/plugin-security exec vitest run src/org-admin-assignment-boundary.posture.test.ts,预期 85/85 通过。
  • 需要你做的:确认本 PR 对未发布 changeset .changeset/org-admin-removal-caller-read.md 一句话的改写(见 Acceptance notes 第一条);在你确认之前 Check Changeset 按设计保持红。

What changes

  • The organization branch judges a removal on the stored row. An organization administrator (organization_admin / organization_admin_no_bypass) removes an assignment row by id. Whether there is a row is still asked of the caller's own read (callerRead, the read door's answer to this caller). Once that read returns the row, organizationTargets takes the stored row (storedRow) and the organization, position and permission-set checks run on it, as the delegated branch's delegatedTargets does. This follows ruling of record 6649064874 ("the ADR-0090 D12 assignment gate judges on the stored row, and its refusal names only what the caller can read"), which covers both branches. No second reader: the branch reuses callerRead, storedRow, columnsReadAsStored and mayName, and its target type is now DelegatedTarget. No stored read is made for a row the caller's read does not return.
  • One ruling record for the rule, across the package. Every citation of the stored-row verdict and of the rule that a refusal names only what the caller can read, in this branch's code and in the code it builds on, now names ruling of record 6649064874; the citations of 5985885287 for the read door's not-found answer are kept.
  • A refusal names a value only when the caller can read it. The row's organization, its position (and the sets that position carries, which are named only beside it) and its permission set are named only when the caller's read returned that column at its stored value (mayName). Otherwise the sentence is "the row does not belong to the organization you administer", "the assignment's position is not inside the organization boundary" or "the grant's permission set is not inside the organization boundary", and details leaves the value out. A column the caller does read is still named, so the guidance stays specific where it can be.
  • Unchanged. A row the caller cannot read and an id naming no row keep the read door's 404 RECORD_NOT_FOUND. A read refusal or a store fault propagates as raised. Inserts, tenant-level administrators and the delegated branch are as before. The update path is untouched: an edit in place is refused before any row is looked up, as fix(plugin-security): an organization administrator's update by id is refused before the row is looked up, whatever the id names #30200 set it, and nothing here pins it.
  • Clause-② declaration, measured. This admits removals the published 18.3.0 refuses, so it widens; that is declared here and in the changeset. It is Clause-②: no because it is a runtime permission behaviour change, which execution-duties.md places outside Clause ② (its negative boundary: runtime permission and security behaviour is the human-floor security/permission class), and no published contract surface moves: no spec schema, no export (the package's public API is unchanged), no error code. For history: 18.2.0's organization branch judged the stored row (a system read), and 18.3.0's judged the caller's read of it; this keeps 18.3.0's not-found for a row the caller cannot read and takes the verdict from the stored row again.
  • Changeset: @objectstack/plugin-security patch, plus a one-sentence correction of a pending note (Acceptance notes, first item).

Tests

  • org-admin-assignment-boundary.posture.test.ts (real ObjectQL engine, SQL driver and SecurityPlugin), new describe "an organization administrator's removal is judged on the stored row, and its refusal names only values the caller can read", 11 tests:
    • under group and isolated, for a position or permission set hidden by a field rule and for one partially masked: the caller reads the row without the stored value (precondition, off the read door); the removal of an in-boundary row is admitted and lands; the removal of an out-of-boundary row is refused 403 PERMISSION_DENIED by the boundary, and neither the stored values (the position, the sets behind it, the permission set) nor the masked value appear anywhere in the serialized answer;
    • under group and isolated, for the row's organization hidden by a field rule and partially masked: the plugin's own D12 gate, asked as the write door asks it, admits the in-boundary removal on the stored organization, and refuses the out-of-boundary one naming the values the caller reads (the set) and not the organization, through the gate and through the door;
    • control, group and isolated: callers whose field rules hide a judged column still get the read door's not-found, whole, when they remove a row they cannot read or an id naming no row;
    • control, single: a caller whose field rules hide the organization removes its own organization's in-boundary row end to end, and is refused another organization's row without that organization being named.
  • Re-pinned fix(plugin-security): an assignment write the administrator cannot address answers not-found, as a read does; a refusal names only values the caller can read #30165's organization-branch pin in "an administrator's refused assignment write names a value only when the caller reads it, or wrote it" (all three postures). That pin asserted only that the refusal named no value the caller does not read. It now also asserts the boundary's refusal on the stored row ("is not inside the organization boundary") and adds the same caller's in-boundary removal, admitted.
  • delegated-admin-gate.test.ts, new top-level describe on the existing organization harness, 20 tests: for both administrator sets, each of position, permission_set and organization_id (both tables) served hidden and masked, in-boundary removals resolve and out-of-boundary ones are refused with the rule's sentence and without the stored or masked value; a column the caller reads keeps its value in the guidance; a row the caller's read does not return and an id naming no row are the unreadable refusal, with no stored read of the addressed row.
  • Test names state the rule. The posture rig grows from 74 to 85 tests; its new describe adds about 1.4 s of test time on a loaded shared box, inside the file's declared 20 s budget.

Acceptance notes

  • Correcting a pending release note: please confirm. .changeset/org-admin-removal-caller-read.md has not shipped as a note (the plugin's CHANGELOG.md at 18.3.0 holds none of it). Its sentence "The delegated-administration gate (ADR-0090 D12) now judges that row as the administrator itself reads it, in its own context." is false after this change, so it now reads "now asks the administrator's own read, in its own context, whether that row is there to remove." Two of its bullets are corrected too: the not-found bullet now says "the removal of a row the administrator cannot read" (an edit in place is refused before any row is looked up, so the 404 is a removal's answer), and "A row the administrator can read keeps the answer it had, including the guidance…" now reads "A row the administrator can read is judged on the stored row. A refusal names the organization boundary, and names the row's organization, position or permission set only when the administrator's own read returns that value as stored." Its title, its first paragraph and its "Unchanged" bullet are true of the tree as written. check-empty-changeset names the file and stays red by design until a maintainer confirms; restoring the sentence from the base would publish a false one.
  • The row's organization column. This gate judges it on the stored row too. The rig pins that at the gate under group and isolated, and end to end under single. Under the walled postures a write also answers the write door's own row-level check, which this PR does not change; how that check treats a caller who does not read the organization column is a separate change the seat has ordered.
  • fix(plugin-security): an organization administrator's update by id is refused before the row is looked up, whatever the id names #30200 is merged in. It reorders the organization branch's update refusal and edits the same docblock as this PR; the merge conflicted in that docblock only, and the resolution keeps both texts (its update-order sentences replace this branch's earlier sentence about edits). This PR's new pins exercise removals only, and update-by-id-refusal-order.dogfood.test.ts passes on this head (Verification).

Verification

Readings at d33de915d2, this body's head: the plugin typecheck, the whole suite, the two edited files, the three HTTP dogfood files and the derived gates. The ablations ran at 5fd3195845; since then the gate file changed only by the merge of #30200 (its docblock and its update ordering). CI's families are this PR's own check-runs; test:repo and the whole-tree lint are CI's.

plugin-security: typecheck, the whole suite, the two edited files
pnpm --filter @objectstack/plugin-security typecheck                  exit 0 (check:test-typecheck OK, 0 errors)
vitest run, whole package, under the shared verify lock               Test Files 242 passed (242) · Tests 5318 passed | 45 skipped (5363)
vitest run src/org-admin-assignment-boundary.posture.test.ts          Tests 85 passed (85)
vitest run src/delegated-admin-gate.test.ts                           Tests 157 passed (157)
HTTP dogfood: the update-by-id refusal order and the two unreadable-row files
closure build (pnpm --filter '@objectstack/dogfood^...' build)        exit 0
ablation-dist-preflight, this change's wording                        present in plugin-security dist/index.js and dist/index.mjs
update-by-id-refusal-order, org-admin-assignment-removal-unreadable-row,
delegated-admin-assignment-write-unreadable-row                       Test Files 3 passed (3) · Tests 25 passed (25)
One-shot ablations of the two edited files (their subject resolves from this package's own source)
A1  organizationTargets judges the caller's read again (the stored read replaced by the caller's row)
    -> Tests 30 failed | 212 passed: every new test except the four not-found controls,
       and the re-pinned field-rule test in all three postures
A2  the position refusal names the position whatever the caller reads
    -> Tests 11 failed | 231 passed: the position cases of both new describes
       (rig: hidden and masked, group and isolated; unit: both sets, hidden and masked),
       and the re-pinned field-rule test in all three postures
restore, both: blob == HEAD (fbfc61a0) and `git diff HEAD` empty (scripts/ablation-replace.mjs)
Derived gate families (dispatch-gates --commands: 60)
59 of the 60 commands :: exit 0, each run once at this head with its exit code captured before any pipe
node scripts/check-empty-changeset.mjs --base origin/main :: exit 1
    the deliberate correction of one pending changeset (Acceptance notes, first item)
dispatch-gates --ran: 60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN
Contract-review findings commits 0cc6f17 and 0b0d0e2 (comments, describe names and the corrected pending note)
pnpm --filter @objectstack/plugin-security typecheck                  exit 0 (check:test-typecheck OK, 0 errors)
vitest run src/org-admin-assignment-boundary.posture.test.ts          Tests 85 passed (85)
vitest run src/delegated-admin-gate.test.ts                           Tests 157 passed (157)
node scripts/check-empty-changeset.mjs --base origin/main             exit 1 (only .changeset/org-admin-removal-caller-read.md, the declared correction)
check-adr-0087-registration, check-changeset-no-major, check:issue-citations,
check:doc-authoring, check:nul-bytes, check:closed-ceremony-text      exit 0 each

Generated by Claude Code

claude added 10 commits November 5, 2026 01:24
…val is judged on the stored row, and its refusal names only values the caller can read

The organization administrator's branch of the ADR-0090 D12 gate now takes
a removal's verdict from the stored row once the caller's own read returns
the row, as the delegated branch does (ruling of record 5985885287,
sub-item B), and words its refusal through mayName. A row the caller
cannot read, and an id naming no row, keep the read door's not-found.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…ged on the stored row, and its refusal names only values the caller can read

The posture rig pins it on a real engine under 'group' and 'isolated' for a
position or permission set hidden by a field rule or partially masked, the
row's organization (the gate's own verdict), the read door's not-found for
these callers, and a 'single' control. The field-rule test's organization
block is re-pinned to the stored-row verdict. The gate's unit file pins the
same rule, and that no stored read is made for a row the caller cannot read.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…is judged on the stored row

Adds the plugin-security patch changeset, and corrects one sentence of the
pending note .changeset/org-admin-removal-caller-read.md that this change
makes false: the administrator's own read now answers whether there is a
row to remove, not the verdict on it.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…ns removals only

An edit in place is no part of the organization administrator's right, and
where its refusal falls is not this describe's subject, so its control
asks the removal alone.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…enrolled organization administrator with no field rules

Keeps the caller's context type mutable, as the engine's context expects.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…t state the rule only

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…e columns it holds for end to end

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
# Conflicts:
#	packages/plugins/plugin-security/src/delegated-admin-gate.ts
@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 5 documentable anchor(s).

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

  • content/docs/permissions/delegated-administration.mdx (via DelegatedAdminGate (symbol, a top-level class))

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

  • content/docs/releases/v13.mdx (via DelegatedAdminGate (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
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 38 pages)
  • 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 771b2db20ac08d78b7fec5c2bd1ee30de9aa26dc → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 771b2db20ac08d78b7fec5c2bd1ee30de9aa26dc

⚠️ 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 771b2db20ac08d78b7fec5c2bd1ee30de9aa26dc → 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

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d33de915d2443d420720cf532d47ac5d7b741dfb
Local-runs: none
Scope: packages/plugins/plugin-security/src/delegated-admin-gate.ts, packages/plugins/plugin-security/src/delegated-admin-gate.test.ts, packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts, .changeset/org-admin-removal-caller-read.md, .changeset/org-admin-removal-stored-row-verdict.md + packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts (readDoorAnswerForGateRefusal, the gate call site, step 2.7, the query guard), packages/plugins/plugin-security/src/security-plugin.ts (getCallerPreImage, readRowById, callerCannotReadAddressedRow), packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts, packages/plugins/plugin-security/src/grant-permission-set-name.ts, packages/plugins/plugin-security/src/organization-scope.ts, packages/plugins/plugin-security/src/index.ts, packages/plugins/plugin-security/CHANGELOG.md, the gate at tag @objectstack/plugin-security@18.3.0, docs/adr/0090-permission-model-v2-concept-convergence.md (D12 and its 2026-10-17 note), scripts/check-empty-changeset.mjs, ruling comment 5985885287 and the rest of the #21771 thread, the #30165 thread (records 6638950952 and 6640676948), the #30200 body, the PR body, file list, net diff and the check-runs on this head.

Review order: an at-tier review on a security hardening PR (the ruling's own landing condition). Read-only: the diff against origin/main, the PR body, the cited code at this head and the check-runs. Nothing built, run or re-run. Mechanism, where a judgment needed one, is in the reviewer's local security-note and nowhere in this record (security-findings.md section 6).

① Derived judgments

Accept-set and public-surface changes the diff implies, each judged:

  1. The organization branch now judges a by-id removal on the stored row once the caller's own read returns the row. organizationTargets asks callerRead whether there is a row, and only then reads the stored row; organizationAdminFailure runs the organization, position and permission-set checks on that row. Right. ADR-0090 D12 (2026-10-17 note) defines the organization administrator's right over "every permission set the row distributes", a fact of the stored row, so judging on a read that a field rule or masking rule has thinned judges less than the row. It is also the rule the delegated branch has held all along (delegatedTargets). Consequence, declared in the body and the changeset: a removal of an in-boundary row whose judged column the administrator does not read at its stored value is now admitted where published 18.3.0 refused it; an out-of-boundary one stays refused. A widening of admitted writes, not of the published contract (see ②).
  2. A refusal names a value only when the caller can read it at its stored value (or wrote it). Three new unnamed sentences (organization, position, permission set), each behind mayName, each with meta: {}; the position's carried set is named only beside a named position; the grant's column asked of mayName is GRANT_SET_NAME_FIELD, the same column grantSetNameOf reads. Right. columnsReadAsStored is strict equality against the system read, so a hidden column (absent) and a masked column (different string) are both unnamed; an object-valued column compares unequal and is left unnamed, which is the safe direction.
  3. A row the caller cannot read and an id naming no row keep the read door's not-found. prev is null when callerRead returns null, the unreadable refusal is unchanged, and readDoorAnswerForGateRefusal still maps it to recordNotFoundError for the addressed id. Right, and no stored read is made for such a row (the stored read is conditional on the caller's read; pinned in the unit file).
  4. Inserts unchanged. The insert target now carries wrote: null, which makes mayName true for every column of a payload row, so every insert sentence names what it named. Right.
  5. Update unchanged. The update refusal is taken before any lookup (fix(plugin-security): an organization administrator's update by id is refused before the row is looked up, whatever the id names #30200's order); the merge kept both docblock texts and the code path. Right.
  6. A read refusal or a store fault still propagates from either read; the middleware answers a declared 4xx read refusal with that refusal and lets a 5xx through. Unchanged from the base.
  7. Public surface unchanged. DelegatedTarget is a module-private interface; GRANT_SET_NAME_FIELD is an internal import; index.ts still re-exports DelegatedAdminGate, isTenantAdmin and the DelegatedAdminGateDeps type from the gate module and nothing more; no error code, no spec schema, no metadata key moves. Right.
  8. One extra system read per admitted-or-refused removal of a readable row. A cost, not a surface change; the unit file pins that it is not issued for an unreadable row.

Disclosure rule (security-findings.md section 6) on the PR title, body, commit messages, both changesets, test names and code comments: each states the hardening rule (what the verdict is taken on, what a refusal names, what answers not-found); none describes a probe or how the pre-fix door could be made to misbehave. The ablation lines name the pins they red, not a recipe. No breach.

② Semver level

  • @objectstack/plugin-security at patch; Clause-②: no with no arm; Metadata-protocol: none, on the body and on both changesets. Right. The brief's question, answered from the repo's rules: this change admits removals the published release refused, but Clause ② reads only the published contract surface (execution-duties.md: "条款②只指已发布契约面"; contract-review.md: the package's exports map, the schema, the error-code ledger), and its negative boundary places a runtime permission or security behaviour change outside Clause ② and on the human floor's security/permission class. No export, schema or error code moves, so the declaration is no; no (widening) is malformed under AGENTS.md's closed pair, and (narrowing) is false. The widening is declared in prose, which is where it belongs, and the compensating control the rules name for that class is this at-tier review before landing. The level follows the kind: none takes patch.
  • The corrected pending changeset (.changeset/org-admin-removal-caller-read.md): the rewritten lead sentence, "now asks the administrator's own read, in its own context, whether that row is there to remove", is true of this tree. One note: the gate at tag 18.3.0 already read the row under the caller's context, so the sentence's "now" overstates the delta from the published release; what moved since 18.3.0 is the answer, which the first bullet states. Not a finding. Should-fix (text): the second bullet, "A row the administrator can read keeps the answer it had, including the guidance that names the organization boundary", is no longer true of this tree for a row whose judged column the administrator does not read at its stored value (the new changeset in this same PR says the opposite, correctly). The body's "Its bullets stay true" is wrong on that bullet. The file is already under this PR's deliberate correction, so the fix rides the same confirmation.
  • Check Changeset is red on this head by the gate's own design: the foreign-changeset rule (finding: random changeset filenames collide silently across parallel agents — a round overwrote a sibling PR's minor changeset and every gate stayed green #17712) names .changeset/org-admin-removal-caller-read.md as present on the merge base and changed by this PR, and its text classes the case as a DELIBERATE CORRECTION whose remedy is to say so on the PR and get it confirmed. The PR does. The maintainer's confirmation on record (fix(plugin-security): an assignment write the administrator cannot address answers not-found, as a read does; a refusal names only values the caller can read #30165's thread, 2026-11-05T00:47Z) names fix(plugin-auth): a verification-email resend from a visitor who is not signed in answers the same whatever the send does #30144 and fix(plugin-security): an assignment write the administrator cannot address answers not-found, as a read does; a refusal names only values the caller can read #30165 only; a confirmation for this PR's edit is still owed before a Tier S landing, with a PR comment recording the gate and the cause (SKILL.md's three conditions for carrying a by-design red).

③ Boundary flags

  1. "Correcting a pending release note: please confirm." Judged under ②: the sentence is true of the tree; the second bullet is not, should-fix. Confirmation for this PR owed to the maintainer.
  2. "The row's organization column ... how the write door's own row-level check treats a caller who does not read the organization column is with the seat." Read, not pinned by me: under the walled postures the write door's own row-level re-read is asked under the caller's context with a predicate on that column, and the door's query guard refuses a caller-context read whose predicate names a column the caller cannot read, so such a caller's by-id removal is refused by the door whatever the gate decided, before and after this PR. Pre-existing, outside this PR's files, fail-closed, names nothing. The rig's choice to pin the gate's verdict under group and isolated and the door end to end under single is the honest shape. Escalated to the seat as the body asks; the seat decides whether a false refusal in that configuration is a finding to file. Not this PR's defect.
  3. "fix(plugin-security): an organization administrator's update by id is refused before the row is looked up, whatever the id names #30200 is merged in ... the merge conflicted in that docblock only." Read at this head: the organizationAdminFailure docblock carries both texts coherently (read question first, stored-row verdict, mayName, then the update-order sentences), and the code keeps fix(plugin-security): an organization administrator's update by id is refused before the row is looked up, whatever the id names #30200's update refusal before any lookup. Resolved.
  4. Verification claims (85/85, 157/157, the whole suite, ablations A1 and A2, 60 derived gates). Not reproduced here (read-only). CI at the time of this read: every finished check green except Check Changeset (by design, above), including Lint & Repo Gates, TypeScript Type Check, Build Core, Governed Surface Queue Guard, Test UI, Live E2E UI, the Dogfood Regression Gate and Test Core shards 1, 2, 3, 5, 6, 7 and 8 of 8; Test Core 4/8 still in progress. Their conclusions are the gate verdicts; the record names what it read.
  5. Provenance of the rule. The body, the new docblock lines, two new code comments and both new describe names cite "ruling of record 5985885287, sub-item B ('the verdict stays on the stored row')". Comment 5985885287 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 is ruling letter A and carries no sub-item B and no such sentence; its only "B" is the option not taken. The stored-row and wording rule is the 2026-11-04 batch-4 item-2 maintainer ruling that the fix(plugin-security): an assignment write the administrator cannot address answers not-found, as a read does; a refusal names only values the caller can read #30165 contract-review record (6638950952) names, and I found no tracker record carrying it. The citation pre-exists on main from fix(plugin-security): an assignment write the administrator cannot address answers not-found, as a read does; a refusal names only values the caller can read #30165 at four sites; this PR extends it at five more. Should-fix: cite the record that decided the rule, and if that ruling has no written record of record, the seat writes one (as 5985885287 was written) and the code points at it. The design does not depend on the citation (①.1), so this is provenance, not a FAIL.
  6. Author is objectstack-fleet[bot]; no off-relay login to name.
  7. The Implemented-by branch and the adopting seat share one session id; ④ below was run by an isolated subagent of that seat, fed the inputs the brief names and not the seat's conclusions, as the independence rule requires.

④ Design judgment

For packages/plugins/plugin-security/src/delegated-admin-gate.ts, against the ruling it implements (5985885287: a row the caller cannot read answers as a nonexistent id; a reader who may not write keeps 403 and sees nothing new), ADR-0090 D12 as amended 2026-10-17, and the code it cites. Tried to break it:

  • A caller who cannot read the row. callerRead returns null, no stored read, the unreadable refusal, the middleware's not-found for the addressed id; a caller who also holds an adminScope falls to the delegated walk, which reads as the caller again, gets null and refuses the same way without a stored read. Holds, on both tables, in every posture the rig boots.
  • A missing or non-scalar id. Refused as a filter delete before any read; an insert has no id. Holds.
  • A row that vanishes between the two reads. The stored read returns null, the branch answers the unreadable refusal, the middleware re-asks the caller's read and maps to not-found. Holds.
  • Masked versus hidden. Both fall outside reads under strict equality; neither reaches the sentence or details. A mask that is a fixed point of its value counts as read and may be named, which discloses nothing the caller does not already see. Holds; pinned at the gate (both administrator sets, both tables, each of the three judged columns, hidden and masked) and through the door (position and permission set under group and isolated, hidden and masked; organization under single).
  • The three tenancy postures. Under the walled postures the caller's read is tenant-scoped, so another organization's row is unreadable and answers not-found; under single it is readable and is refused on the stored organization without naming it when the caller cannot read that column. Holds; the single control pins it end to end.
  • The delegated branch. Untouched in code; DelegatedTarget is shared without changing its shape. Holds.
  • Refusal wording that could name a value the caller cannot read. Every value a sentence or details can carry on the removal path (organization, position, the set a position carries, permission set) is behind mayName; the business-unit sentence is reachable only from an insert payload, which the caller wrote. Holds. A catalog fact (which sets a position carries) is named only beside a position the caller reads.
  • An extra read for a row the caller cannot read. None: the stored read is conditional on the caller's read returning a row, and the unit file pins the absence of a system read of the addressed object for both an unreadable row and an id naming no row.
  • Explain and the write-admission preview. explainAccess asks the gate only whether the caller covers the user; canWriteObject folds every refusal to a boolean. Neither can split by presence or by column visibility.

Findings: no must-fix. Should-fix: ② the second bullet of .changeset/org-admin-removal-caller-read.md is now false and should be corrected under the same confirmation; ③.5 the "sub-item B" citations name a record that does not carry that sub-item, so the rule's provenance should point at the record that decided it (and that ruling should have one). Escalated, not a finding of this PR: ③.2, the door's own refusal of a by-id removal by a caller whose field rules hide the organization column under the walled postures, which the seat should classify. Owed before a Tier S landing: the maintainer's confirmation of this PR's changeset correction, the by-design-red comment on this PR, and green on the three Test Core shards still running.

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

VERDICT: PASS

…ct cites its governing ruling record, and the corrected pending note is true of the tree

- Every stored-row-verdict citation this branch added now names ruling of
  record 6649064874 (the ADR-0090 D12 assignment gate judges on the stored
  row, and its refusal names only what the caller can read): the gate's
  docblocks and comment, and the two new describe names. The citation of
  5985885287 for the read door's not-found answer is kept.
- .changeset/org-admin-removal-caller-read.md (the declared deliberate
  correction): a row the administrator reads is judged on the stored row and
  its refusal names a value only when the administrator's own read returns it
  as stored; the not-found bullet is scoped to a removal.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…ion in the package names ruling of record 6649064874

Comments and one describe name only. The citations of the stored-row verdict
and of the rule that a refusal names only what the caller can read now name
ruling of record 6649064874: the columnsReadAsStored and mayName docblocks,
the two delegated-walk comments, the delegatedTargets docblock (whose guidance
sentence now says what mayName does), and the posture rig's field-rule
describe. The citations of 5985885287 for the read door's not-found answer
are kept.

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: 0b0d0e216f41f563de1d94a86e9e985d7f673c3f
Local-runs: none
Scope: packages/plugins/plugin-security/src/delegated-admin-gate.ts, packages/plugins/plugin-security/src/delegated-admin-gate.test.ts, packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts, .changeset/org-admin-removal-caller-read.md (the delta d33de915d2..0b0d0e216f, commits 0cc6f17 and 0b0d0e2) + the rewritten PR body, ruling comment 6649064874 on #21771, every ruling citation left in packages/plugins/plugin-security/src and .changeset at this head, the check-runs on this head, and the earlier record on d33de915d2 (comment 6649074186) this delta builds on.

Delta review by the same reviewer: the two findings-only commits since the PASS record on d33de915d2. Read-only, nothing built or run.

① Derived judgments

  • No accept-set or public-surface change. Every hunk of the delta is inside a docblock, a line comment or a describe string: columnsReadAsStored and mayName docblocks, the organizationAdminFailure, organizationTargets and delegatedTargets docblocks, two delegated-walk comments, two describe names in the posture rig and one in the unit file, and three lines of one pending changeset. No executable line moves, no test assertion moves, no export, schema or error code moves. The behaviour judged on d33de915d2 stands unchanged at this head.
  • Should-fix (provenance) closed. Every citation of the stored-row verdict and of the naming rule in the package now names ruling of record 6649064874, which exists 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 as a written record (letter B, maintainer 「其他同意」 2026-11-04, written 2026-11-05T03:41Z, stated to add to 5985885287 and not replace it): the two helper docblocks, the organization branch's docblock and comment, the two delegated-walk comments, the delegatedTargets docblock, and the three describe names. The citations of 5985885287 are kept where they belong (the read door's not-found answer: callerRead, UnreadableAddressedRowRefusal, organizationTargets's first paragraph, delegatedTargets's first paragraph, the middleware's readDoorAnswerForGateRefusal, the rig's not-found describes). No "sub-item B" remains anywhere in the package or in .changeset. The ruling record's text matches what the code does: the verdict on the stored row in both branches once the caller's own read returns the row; a value named only when the caller reads that column at its stored value; the unreadable row and the missing id unchanged.
  • The delegatedTargets docblock's last sentence now says the guidance "names only what the caller may be told (mayName)" where it said the row "keeps its verdict and its guidance". True of the code since fix(plugin-security): an assignment write the administrator cannot address answers not-found, as a read does; a refusal names only values the caller can read #30165; a comment correction, no behaviour.

§6 (security-findings.md) on the new public text: both commit messages, the rewritten body (the new "One ruling record for the rule" bullet, the rewritten Acceptance note, the findings-commit Verification block), the corrected changeset and the new ruling record each state the rule (what is judged on, what a refusal names, what answers not-found); none describes a probe or how a pre-fix door could be made to misbehave. No breach.

② Semver level

  • Unchanged: @objectstack/plugin-security at patch, Clause-②: no with no arm, Metadata-protocol: none, on the body and both changesets. The delta publishes no code.
  • Should-fix (text) closed — every sentence of the corrected pending changeset is true of the tree. Title: a refused write answers the same for a row it cannot read as for a missing one — true (the not-found for both). Lead sentence: the gate asks the administrator's own read whether the row is there to remove — true (organizationTargets → callerRead); the "now" overstates the delta from 18.3.0, which already read under the caller's context, a nit noted before and not a finding. Bullet 1, now scoped to a removal: the removal of an unreadable row answers as the removal of a missing id, the read door's 404 RECORD_NOT_FOUND for that object and id — true (unreadable refusal → recordNotFoundError; an edit in place is refused before any lookup, so the scoping is right). Bullet 2: a readable row is judged on the stored row; a refusal names the organization boundary, and names the organization, position or permission set only when the administrator's own read returns that value as stored — true (storedRow, mayName on organization_id, position, GRANT_SET_NAME_FIELD; every organization-branch refusal is thrown with the "(ADR-0090 D12, the organization boundary)" tail). Bullet 3: an edit in place of a readable row is refused as before, every removal admitted before is still admitted, a tenant-level administrator is judged as before — true (the stored-row verdict admits a superset of the caller-read verdict; isTenantLevelAdmin returns before the branch). Read together with the new changeset org-admin-removal-stored-row-verdict.md, the two notes no longer contradict each other.
  • Check Changeset will stay red on this head by the foreign-changeset rule, as designed; the maintainer's confirmation of this PR's correction is still the owed human step, now for the three corrected lines rather than one, and the body asks for it.

③ Boundary flags

  1. The changeset confirmation request is restated in the body for the sentence and the two bullets; the Acceptance note's account of what each line now says matches the file byte for byte in substance. Owed to the maintainer; not this record's to give.
  2. The organization-column question is now worded as "a separate change the seat has ordered". Consistent with the earlier record's reading (pre-existing, fail-closed, outside this PR's files); nothing in the delta touches it.
  3. Verification claims for the findings commits (typecheck, 85/85, 157/157, the changeset gate reading only the declared correction, six text gates green) are not reproduced here. CI on this head at the time of this read: finished checks green (Governed Surface Queue Guard, PR-body guards, Test UI, Check PR Size, Auto Label, Flag docs, two Lint families); Build Core, Check Changeset, the Dogfood Regression Gate, the remaining Lint families, Live E2E UI, the Test Core planner and the Type Check families still in progress. Their conclusions are the gate verdicts; the Tier S landing waits on them.
  4. Head moved by findings-only commits: the earlier record's ① to ④ on d33de915d2 carry; this delta re-reads only what moved and finds no behaviour moved.
  5. Author objectstack-fleet[bot]; the Implemented-by branch and the adopting seat share a session id, and this delta was again run by an isolated subagent of that seat on the inputs the brief names.

④ Design judgment

No hits file changed in behaviour, so ④ is the earlier record's, re-checked against the delta: the three docblock rewrites describe the code as it is (the stored read conditional on the caller's read; reads by strict equality; wording through mayName; the read-refusal and fault paths unchanged), and the new ruling record's four bullets are each implemented at the sites that now cite it. Nothing in the delta can change a verdict, a sentence or a read. No finding.

Findings: no must-fix; both earlier should-fixes closed; no new should-fix. Owed before a Tier S landing: the maintainer's written confirmation of this PR's changeset correction, the by-design-red comment on this PR for Check Changeset, and green on the gate-carrying checks still running on this head.

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

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Changeset correction confirmed by the maintainer, 2026-11-05T04:23Z. The confirmation was given in session session_01QSeKYn5CmmdShCJifm6WYj (seat security#1); verbatim:

changeset 改写确认 #30248 同意,并立卡修改 skills,changeset 都同意

This is the written confirmation that scripts/check-empty-changeset.mjs names as the human path for its DELIBERATE CORRECTION class. It covers the three corrected lines of .changeset/org-admin-removal-caller-read.md on this PR. Check Changeset stays red by its own design, and the merge goes over it: the check is not a required context and does not run on merge_group.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

席位核对

ACCEPT · head 0b0d0e216f41f563de1d94a86e9e985d7f673c3f · 2026-11-05T04:23Z

  • Shape: 5 files. Not governed. Patch for plugin-security, Clause-②: no, Metadata-protocol: none. The widening against 18.3.0 is declared in the body and the changeset.
  • Rule held (ruling of record 6649064874 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, with 5985885287 for the not-found answer): an organization administrator's assignment removal is judged on the stored row once the caller's own read returns the row. A refusal names a value only when the caller reads it at its stored value. A row the caller cannot read and a missing id keep the read door's not-found.
  • Contract review at CONTRACT_REVIEW_TIER:
    • The full record at d33de915d2 (comment 6649074186) was PASS with two should-fixes: one changeset sentence untrue of the tree, and a citation naming the wrong ruling.
    • Both were closed in findings-only commits 0cc6f17e95 and 0b0d0e216f; the seat rewrote the body to match.
    • The delta record by the same reviewer on this head (comment 6649362306) is PASS.
  • Open questions:
    • Clause-② stays no: execution-duties places runtime permission behaviour outside Clause ②, and no published surface moves (the review agrees).
    • The write door's own row re-read for a caller who does not read the organization column is ordered separately (seat order R3).
  • Check-runs at this head: 33 success, 15 skipped. The only red is Check Changeset, by design: the foreign-changeset rule names .changeset/org-admin-removal-caller-read.md, a declared DELIBERATE CORRECTION. The maintainer's written confirmation is on this PR. It is not a required context.
  • Gates (dev): 60/60 run at d33de915d2; the plugin-security suite has 5318 passed; posture rig 85/85; gate 157/157; ablations red, then restored. Findings commits: typecheck plus both test files re-run green.
  • Landing: ready, then auto-merge.

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