Skip to content

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

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

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

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:组织管理员和委派管理员按 id 修改或删除授权行(sys_user_position、sys_user_permission_set)时,如果这一行自己读不到,或这个 id 根本没有对应的行,现在都回答读取门的 404 RECORD_NOT_FOUND,和读取这个 id、以及其他任何对象按 id 写入时的回答完全一样。委派管理员被拒时,提示和 details 里只出现调用者自己读得到、或自己写进去的值。
  • 为什么改:落实维护者裁决:按 id 写入时,读不到的行按读取门的回答处理;拒绝提示不写出调用者读不到的字段值,判定照旧按存储行做。
  • 风险与回滚:只是同一类拒绝的状态码从 403 换成 404,原来放行的写入一律照旧放行;读得到的行判定和提示不变;插入、租户级管理员、读取被整体拒绝的调用者都不受影响。回滚即撤销本 PR。
  • 先看:security-plugin-authz-middleware.ts 里 readDoorAnswerForGateRefusal 新增的 not-found 分支;delegated-admin-gate.ts 里的 UnreadableAddressedRowRefusal、delegatedTargets 和 mayName;组织管理员分支 organizationAdminFailure 对 update 先问一次读取。
  • 两分钟验证:pnpm --filter @objectstack/plugin-security exec vitest run src/org-admin-assignment-boundary.posture.test.ts,预期 74/74 通过。
  • 需要你做的:确认本 PR 对两条未发布 changeset 的改写(见 Acceptance notes 第一条);check-empty-changeset 在你确认之前按设计保持红。

What changes

  • An assignment write the administrator cannot address answers not-found, as a read does. For the by-id update or delete the caller addressed, when the delegated-administration gate (ADR-0090 D12) refused it because the caller's own read of the row returned none, the security middleware now answers recordNotFoundError for that object and id: the read door's 404 RECORD_NOT_FOUND, which step 2.7 already answers for every other object. This covers the organization administrator's branch and the delegated branch (a delegate holding an adminScope, and an organization administrator who holds one too). A row the caller cannot read and an id naming no row get the same answer, and it is the same answer a GET of that id gets.
    • The gate marks that refusal with its own class, UnreadableAddressedRowRefusal (a PermissionDeniedError). Only readDoorAnswerForGateRefusal turns it into the not-found, and only for the by-id write the caller addressed (addressedByIdWriteId, with step 2.7's exclusions). A nested write, a referential clear and the write-admission preview keep the gate's 403, which names no row and no id.
    • A caller the read door refuses outright still gets that read refusal, unchanged.
    • An organization administrator's edit in place is asked the read question first, so an edit of a row it reads is still refused as an edit (403).
  • A refusal names a value only when the caller can read it. In the delegated walk, each value its guidance names — the position, the sets it distributes, the business unit, the holder, the permission set — is named only when the caller wrote it (an insert's row, a column its update payload carries), or when the caller's own read of the row returned that column at its stored value (mayName). Otherwise the sentence is the same without the value, for example "the assignment's position distributes a permission set which is not in the scope's allowlist", and details leaves the value out. The verdict does not change: it is still taken on the stored row.
    • "Can read" is the read door's own answer, not a second evaluator. The gate already holds the caller's read of the row (readAsCaller, the operation's memoised pre-image read). A column a field rule hides is absent from that read, and a masked column differs from the stored value, so neither is named.
    • The organization administrator's branch already words its refusal of a removal from the caller's own read, so it names nothing that read hides. It is pinned here and not changed.
  • Two unreleased changesets corrected. .changeset/org-admin-removal-caller-read.md and .changeset/delegated-admin-unreadable-row.md said the answer is 403 PERMISSION_DENIED. This change makes that false, so each now names the not-found.

Tests

  • org-admin-assignment-boundary.posture.test.ts (real ObjectQL engine and SQL driver, real SecurityPlugin):
    • Under group and isolated, for both tables, both verbs, and a row the caller cannot read as well as an id naming no row:
      • the organization administrator (both sets), the delegate and the organization administrator who holds a scope each answer the read door's not-found, compared whole (status, code, name, message, every own field) with recordNotFoundError for that id;
      • the tenant-level administrator, whom step 2.7 answers, gets the same whole answer;
      • a delegate the read door refuses gets that read refusal, whole.
    • In every posture, for each of the four columns a refusal could name, a delegate whose field rules hide the column is refused DELEGATION_SCOPE_VIOLATION, and its value appears nowhere in the answer. The delegate who reads the column gets the same verdict, with the value named. A value the caller wrote is named. An organization administrator whose field rules hide the column is refused without the value.
    • A partially masked column (a masking rule on position, not readable: false): the delegate reads the masked value, and neither that value nor the stored one appears anywhere in the serialized refusal. The verdict still comes from the stored row: refused for a stored position off the allowlist, admitted for one inside the scope.
    • The field-hidden-position control from the delegated describe is re-pinned to the verdict and code, plus no hidden value in the answer.
    • Controls kept: in-scope rows the caller reads are admitted; out-of-scope rows it reads keep their guidance with the values; the tenant-level administrator is answered as before; in single, rows the caller reads keep their guidance and an id naming no row answers the not-found.
  • HTTP dogfood (showcase, isolated): org-admin-assignment-removal-unreadable-row and delegated-admin-assignment-write-unreadable-row now pin PATCH and DELETE, of the row and of an id naming none, to equal exactly what a GET of that id answers (404 RECORD_NOT_FOUND, status and body). Each has a measured red leg, below.

Acceptance notes

  • Correcting two pending release notes: please confirm. This PR rewrites the first bullet of .changeset/org-admin-removal-caller-read.md and of .changeset/delegated-admin-unreadable-row.md. Neither has shipped (the plugin's CHANGELOG.md holds neither, and npm has 18.3.0). Both said a row the caller cannot read answers 403 PERMISSION_DENIED, which this change makes false. Each now says the read door's 404 RECORD_NOT_FOUND. check-empty-changeset names both files and stays red by design until a maintainer confirms. Restoring them from the base would republish the false sentence.
  • The Setup screens that remove an assignment (Assigned users on a permission set, Position holders on a position) show the server's sentence for any refused removal, whatever the status, and fall back to their localized "Failed to remove" line. Nothing under ui/ treats a 403 and a 404 from these writes differently. They list only rows the caller reads, so the new answer reaches them only when a row was removed in between. That answer now reads "Record … not found in …" where it used to read as an access refusal.
  • A caller with no administrator authority over these tables is answered as before.

Verification

Readings at da7d298fd2, this body's head: the plugin typecheck, the posture rig, ablation A3 and the derived gates. Readings at f29ee6a49d: the whole suite and the dogfood legs. Since then only the posture rig's test file and one pending changeset's wording have changed. Readings at ddb6927721: ablations A1 and A2. The one commit between ddb6927721 and f29ee6a49d types a test's assertion label as a string. 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 posture rig
pnpm typecheck (tsc + tsconfig.scripts.json + check:test-typecheck)   exit 0
vitest run, whole package, under the shared verify lock               Test Files 242 passed (242) · Tests 5269 passed | 45 skipped (5314)
vitest run src/org-admin-assignment-boundary.posture.test.ts          Tests 74 passed (74)
One-shot ablations of the posture rig (its subject resolves from this package's own source)
A1  the not-found arm of readDoorAnswerForGateRefusal disabled
    -> Tests 5 failed | 68 passed: the organization administrator's not-found test (group, isolated),
       the 'single' control's no-row leg, the delegated not-found test (group, isolated)
A2  mayName answers true for every value
    -> Tests 5 failed | 68 passed: the field-rule test (single, group, isolated) and the delegated
       control's hidden-position leg (group, isolated)
A3  a column counts as read when present at any value, not at its stored value
    -> Tests 1 failed | 73 passed: the partially masked column case
       (the refusal named position 'qa_out_pos')
restore, every file: blob == HEAD and `git diff HEAD` empty (scripts/ablation-replace.mjs)
HTTP dogfood: the two re-pinned files, with a measured red leg
closure build (turbo, @objectstack/dogfood^...)          exit 0 · 69 tasks
RED    not-found arm disabled; @objectstack/plugin-security rebuilt
       ablation-dist-preflight: marker present in dist/index.js and dist/index.mjs
       -> Test Files 2 failed (2) · Tests 2 failed | 4 passed: exactly the two re-pinned tests;
          each received 403 PERMISSION_DENIED where GET of the same id answers 404 RECORD_NOT_FOUND
restore: source blob == HEAD; rebuilt; preflight --absent: marker absent from all 6 built files; tree clean
GREEN  Test Files 2 passed (2) · Tests 6 passed (6)
Derived gate families (dispatch-gates --commands: 64)
62 of the 64 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 two pending changesets (Acceptance notes, first item)
pnpm check:dual-build-cjs-loads :: exit 3
    NOT MEASURED: the gate reads every package's dist/, and this worktree builds only the closures
    it needs; @objectstack/plugin-security's rebuilt dist loads as CommonJS and as ESM (exit 0 each)
--ran: 64 derived, 63 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN

Full dogfood battery: not owed (no ! in the title, no narrowing arm).

…dress answers not-found, as a read does

A by-id update or delete of an assignment row that the organization
administrator or the delegate does not read answers the read door's
not-found for that object and id; a refusal names only values the caller
can read.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…fusal wording for assignment writes

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…t-found answer this change gives

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…umn its field rules hide

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 4, 2026
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/error-catalog.mdx (via user_id (literal, a string literal in assertDirectGrantWrite))
  • content/docs/data-modeling/drivers.mdx (via user_id (literal, a string literal in assertDirectGrantWrite))
  • content/docs/deployment/cli.mdx (via user_id (literal, a string literal in assertDirectGrantWrite))
  • content/docs/deployment/validating-metadata.mdx (via permission_set (literal, a string literal in directGrantSet))
  • content/docs/kernel/runtime-services/audit-service.mdx (via user_id (literal, a string literal in assertDirectGrantWrite))
  • content/docs/permissions/authentication.mdx (via user_id (literal, a string literal in assertDirectGrantWrite))
  • content/docs/permissions/delegated-administration.mdx (via DelegatedAdminGate (symbol, a top-level class), user_id (literal, a string literal in assertDirectGrantWrite))
  • content/docs/permissions/explain.mdx (via permission_set (literal, a string literal in directGrantSet))
  • content/docs/permissions/permission-sets.mdx (via permission_set (literal, a string literal in directGrantSet), user_id (literal, a string literal in assertDirectGrantWrite))
  • content/docs/permissions/permissions-matrix.mdx (via business_unit_id (literal, a string literal in assertAssignmentWrite))
  • content/docs/permissions/positions.mdx (via business_unit_id (literal, a string literal in assertAssignmentWrite), user_id (literal, a string literal in assertDirectGrantWrite))
  • content/docs/permissions/record-view-auditing.mdx (via user_id (literal, a string literal in assertDirectGrantWrite))
  • content/docs/permissions/system-context.mdx (via registerAuthzMiddleware (symbol, a top-level function), permission_set (literal, a string literal in directGrantSet), user_id (literal, a string literal in assertDirectGrantWrite))
  • content/docs/protocol/kernel/config-resolution.mdx (via user_id (literal, a string literal in assertDirectGrantWrite))

⛔ 4 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/v15.mdx (via business_unit_id (literal, a string literal in assertAssignmentWrite))
  • content/docs/releases/v17/17-5.mdx (via user_id (literal, a string literal in assertDirectGrantWrite))
  • content/docs/releases/v18/18-0.mdx (via permission_set (literal, a string literal in directGrantSet))

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
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • 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 eca7cd2008c7aa5174b451428b8b73b29b237299 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 eca7cd2008c7aa5174b451428b8b73b29b237299 → 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 f29ee6a · 2026-11-04T18:25Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f29ee6a49d4089bc97d576d74440ef670a225ceb
Local-runs: none
Scope: packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts, packages/plugins/plugin-security/src/delegated-admin-gate.ts, .changeset/assignment-write-unaddressable-row-not-found.md, .changeset/delegated-admin-unreadable-row.md, .changeset/org-admin-removal-caller-read.md, packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts, packages/qa/dogfood/test/delegated-admin-assignment-write-unreadable-row.dogfood.test.ts, packages/qa/dogfood/test/org-admin-assignment-removal-unreadable-row.dogfood.test.ts + packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts, packages/plugins/plugin-security/src/security-plugin-object-inputs.ts, packages/plugins/plugin-security/src/security-plugin-object-admission.ts, packages/plugins/plugin-security/src/security-plugin-explain-access.ts, packages/plugins/plugin-security/src/grant-permission-set-name.ts, packages/plugins/plugin-security/src/organization-scope.ts, packages/plugins/plugin-security/src/errors.ts, packages/plugins/plugin-security/src/index.ts, packages/core/src/utils/record-not-found.ts, packages/metadata-protocol/src/protocol-record-reads.ts, packages/metadata-core/src/engine-update-dispatch.ts, packages/metadata-core/src/engine-delete-dispatch.ts, ui/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx, ui/packages/app-shell/src/views/metadata-admin/PositionHoldersSection.tsx

Reviewed against rulings B and sub-item B (maintainer, 2026-11-04, batch 4 item 2) and the governing ruling of record 5985885287 on #21771; prior state is #29932 and #29950 (merged). Read-only: the diff from the merge base, the PR body, the check-runs on the head, and the cited code at the head. Nothing built or run.

① Derived judgments

Rule 1 — a by-id assignment write the administrator cannot address answers the read door's not-found. Holds, and holds as one producer.

  • The not-found is built by recordNotFoundError(object, id) from @objectstack/core, which is the one producer the read door (assertRecordExists, protocol-record-reads.ts) and step 2.7 of the middleware already call, with the same two arguments. The middleware gives the id from addressedByIdWriteId, the same reader step 2.7 uses, so status, code, name, message and own fields cannot differ from step 2.7's for that object and id. The posture rig compares the whole answer against both the producer and the tenant-level administrator's step 2.7 answer under group and isolated; the two dogfood pins compare the whole HTTP response of PATCH and DELETE with a GET of the same id, for the row and for an id naming none.
  • Every caller class the ruling names reaches it: the organization administrator without a scope (the organization branch's refusal is marked), the delegate and the organization administrator who also holds a scope (the organization branch's marked refusal is not thrown when scopes are held; the delegated walk then marks its own). single is covered for an id naming no row; a row another organization owns is readable there, so it keeps its guidance — correct, the ruling only equates what the caller cannot read with what is absent.
  • A caller the read door refuses outright still gets that read refusal: the middleware re-asks the read before it looks at the marker, and the refusal wins. Unchanged from fix(plugin-security): a delegated administrator's refused assignment write answers the same for a row it cannot read as for a missing one #29950 and pinned (writeOnlyDelegate).
  • The three step-2.7 exclusions are the same function (addressedByIdWriteId: a nested write, the referential field clear, anything the engine does not route by id), so a nested write and a referential clear keep the gate's own refusal, which names no row and no id. The write-admission preview (canWriteObject) folds every PermissionDeniedError to false; the marked subclass extends it, so the preview answers the same boolean for a hidden row, a missing row and a readable row it may not write. Correct per step 2.7's own exclusions.
  • Nothing is still split by row presence inside the ruling's two verbs: every earlier refusal of the gate (audience anchor, principal-less context, sys_member, no active organization, no scope, filter write) is taken before any row is read and answers the same whatever the id; every later one is taken after the read, which has already answered a hidden or absent row alike. The organization administrator's edit in place is now asked the read question first, so an edit of a readable row is still refused as an edit (403) and an edit of an unreadable one is the not-found; a reader who may not write keeps 403, as the ruling requires.
  • The marked subclass is not on the public surface: index.ts re-exports DelegatedAdminGate, isTenantAdmin and the DelegatedAdminGateDeps type from the gate module and nothing else; the predicate is a module export the middleware imports directly and no published baseline names it.

Rule 2 — a refusal names a stored value only when the caller can read it. Holds for the delegated branch; the organization branch is addressed under ③ (2).

  • "Can read" is the read door's own answer: readAsCaller is the operation's memoised getCallerPreImage, which is the engine's findOne under the caller's context, and that read passes through step 4 of the middleware, which deletes a column the caller's field map denies and replaces a partially masked one with its masked value before the result is returned. columnsReadAsStored then keeps only the columns the caller's read returned at strict equality with the system read, so a hidden column is absent and a masked one differs; neither is named. A read the door refused contributes no column. Sound: the dev's reason (a partially masked column stays in getReadableFields) is right, and the chosen comparison does not depend on that set at all.
  • A masked or partially masked value cannot reach the sentence or details: the four columns a refusal could name (position, business_unit_id, user_id, permission_set) each go through mayName; the grant's column is GRANT_SET_NAME_FIELD = 'permission_set', the column mayName is asked for, so the two readers agree. details carries position or permissionSet only under the same test; the containment sentence takes an unnamed form beside an unnamed position or set. A catalog fact (which set a position distributes) is named only beside a named position. A value the caller wrote (an insert's row, a column its update payload carries) is its own and is named; for a merged update, a column the payload does not carry falls through to the read test.
  • The verdict is still the stored row's in the delegated branch: prev is the system read once the caller's read returned a row or was refused, as fix(plugin-security): a delegated administrator's refused assignment write answers the same for a row it cannot read as for a missing one #29950 left it; only the wording changed. The posture rig pins each of the four columns hidden from the caller: same verdict, no hidden value anywhere in the serialized refusal, and the value named for the delegate who reads it.

§6 (security-findings.md) on the title, body, changesets, commit messages and test names: each states the hardening rule (what a write answers, what a refusal names), none describes a probe or an attack. Commit messages and test names carry the rule. Code comments cite the ruling record, which the rule-provenance convention allows; no runtime string carries a tracker number. No breach.

② Semver level

  • @objectstack/plugin-security at patch, Clause-②: no with no arm, Metadata-protocol: none, on the PR body and all three changesets. Correct.
  • The dev's open question 3, answered from AGENTS.md Post-Task Checklist step 3: "Clause-②: yes|no plus at most one arm from the closed pair (widening)/(narrowing) ((narrowing) is BREAKING, no (widening) malformed)", and the level gate's own reading of the line (check-changeset-no-major.mjs): "Clause-② keeps its MEANING (does this diff widen or narrow a published accept set or public surface)". A 403 to 404 swap on refusals widens nothing and narrows nothing: every write admitted before is admitted, every write refused before is refused, and no export moves. So no arm, and no is right. The same reading carried fix(plugin-security): a delegated administrator's refused assignment write answers the same for a row it cannot read as for a missing one #29950's 404 to 403 swap on Clause-②: no.
  • single posture: an id naming no row now answers 404 where fix(plugin-security): a delegated administrator's refused assignment write answers the same for a row it cannot read as for a missing one #29950 had it answer the gate's 403. Same test, same answer: a refusal's status, not the accept set; the declaration does not change.
  • The two unreleased changesets: neither has shipped (CHANGELOG.md holds neither; the package is at 18.3.0). check-empty-changeset reds on both and its own text names this case as a DELIBERATE CORRECTION whose remedy is "do NOT restore it, say so on the PR and get it confirmed"; the PR does that. Each edit is right where it rewrites the first bullet. One should-fix on the second file: .changeset/org-admin-removal-caller-read.md keeps "An edit in place is refused as before" under Unchanged, and an edit of a row the administrator cannot read is now the not-found; say "an edit in place of a row it reads is refused as before" (the new changeset already says this). Tier S landing waits on the maintainer's confirmation of both corrections and on every check green: at the time of this read, Check Changeset is red by design and Build Core, Type Check (workspace, consumer gates) and the Test Core shards were still in progress; every finished check is green.

③ Boundary flags

  1. UI census, recommendation A (no UI change): accepted. The two Setup removal surfaces (AssignedUsersSection.tsx, PositionHoldersSection.tsx) show the server's sentence from the error body or message and fall back to a localized line; neither branches on the status or the code, and a grep of ui/ for the two codes beside assignment, position or holder handling finds nothing. The only visible change is the sentence a removal of a row removed in between now shows.
  2. The organization branch judges the caller's read; the delegated branch judges the stored row. Already decided by the ruling text, not a maintainer question. Ruling B names both ADR-0090 D12 gate branches, and sub-item B's "The verdict stays on the stored row" carries no branch qualifier. The organization branch takes its verdict from the caller's own read (organizationTargets, from fix(plugin-security): an organization administrator's refused assignment write answers the same for a row it cannot read as for a missing one #29932), so a row inside the boundary whose judged column (position, permission_set, organization_id) a field rule hides from the administrator is refused ("the assignment names no position", "the grant names no permission set", "the row carries no organization") where the stored-row verdict would admit it. Fail-closed: no admission widens, no hidden value is named (the posture rig pins that), and the ruling's answer for an unreadable row is untouched. Should-fix, in this PR or an immediate follow-up: once the caller's read returns a row, take the organization branch's verdict from the stored row and its wording from mayName, as the delegated branch does. Not a FAIL: the deviation is prior state, fail-closed, and discloses nothing.
  3. Covered in ②.

④ Design judgment

Tried to break the rule; no must-fix found.

  • Id from where instead of the payload. The gate's extractSingleId and the engine's dispatch (resolveEngineUpdateDispatch, resolveEngineDeleteDispatch) both read the payload id first, then where.id; the rigs write with where: { id }. They part only on a falsy scalar payload id ('', 0), which the engine does not count as an address but the gate does: the gate then reads that id, and the middleware answers for where.id (the not-found when that row is hidden or absent, the gate's refusal when it is readable). Hidden and absent still answer alike for every shape; a readable row may get a 403 for a degenerate input. Pre-existing; a note, not a finding. A where carrying more than the id with multi: true routes to the predicate path, where the middleware does not convert and the gate's refusal stands for a hidden and an absent id alike.
  • Refused for another reason on a row the caller cannot read. Every refusal of the gate that depends on the row is taken after the read, so the read's answer comes first (no manageAssignments, subtree, allowlist, containment, unresolved set, no target user, no anchor). Every refusal that does not depend on the row is taken before the read and does not vary with the id. The self-delegation path is insert-only. No split by presence.
  • Bulk or multi write. A delegate's write without one scalar id is refused before any read; an organization administrator's filter delete likewise, and its filter update is refused as an edit without a read. An array payload is read as a predicate. Nothing reads a row, so nothing can answer by presence.
  • Explain and admission. explainAccess asks the gate only whether the caller covers the explained user (organizationAdminCoversUser, scopesCoverUser), never a write verdict. canWriteObject passes a synthetic operation with no options, so a delete preview is refused as a filter write before any read and an update preview reads by payload id; its answer is a boolean either way, so a hidden row, an absent row and a readable row the caller may not write answer the same false.
  • Lifecycle verbs (transfer, restore, purge) stay outside the ruling's two verbs: the delegated walk marks its refusal and the middleware does not convert, so a hidden and an absent row both get the gate's 403 for a single caller. No presence split; noted, not a finding.
  • Should-fix (a pin): the posture rig hides columns (readable: false); no case exercises a partially masked column, and the strict-equality reading is what keeps a masked value out of a refusal. One case with a masking rule on position, asserting the masked and the stored value are both absent from the refusal, would pin that rule where it lives.
  • Should-fix (text): the changeset clause named under ②.

Findings: no must-fix. Should-fix: ③ (2) the organization branch's verdict on the stored row; ② the Unchanged clause of .changeset/org-admin-removal-caller-read.md; ④ a pin for a partially masked column.

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

VERDICT: PASS

…r form; the pending note's edit-in-place clause names the readable row

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: da7d298fd20cf6053cd62936c1e2baa829452bac
Local-runs: none
Scope: .changeset/org-admin-removal-caller-read.md, packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts + packages/plugins/plugin-security/src/field-masker.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/security-plugin-object-inputs.ts, packages/plugins/plugin-security/src/delegated-admin-gate.ts

Delta review by the same reviewer, on git diff f29ee6a49d40..da7d298fd20c only: one commit, two files (53 insertions, 4 deletions), no change to the middleware, the gate or the other two changesets. The PASS at f29ee6a49d4089bc97d576d74440ef670a225ceb (comment 6638950952) stands for everything outside this delta; its ① to ④ are not restated. The PR body was rewritten for this head and is read again below. Read-only: nothing built or run.

① Derived judgments

  • No change to the accept set or the public surface. The delta touches a pending release note and a test file; the two rules the earlier record judged (an unaddressable assignment write answers the read door's not-found; a refusal names a stored value only when the caller can read it) are unchanged in code.
  • Should-fix ② of the earlier record, applied. .changeset/org-admin-removal-caller-read.md now reads "An edit in place of a row it reads is refused as before" under Unchanged, which is what the gate does at this head: an edit of a readable row is refused as an edit, an edit of a row the administrator cannot read is the not-found (named in the first bullet and in the new changeset). The sentence is true.
  • Should-fix ④ of the earlier record, applied. The posture rig gains a partially masked column under isolated: a maskingRule (keepHead: 3, keepTail: 0) declared on sys_user_position.position, read by the plugin's own field.maskingRule reader (security-plugin.ts) and served to the delegate masked rather than deleted, as the rig first pins by reading the row as the delegate and comparing with maskFieldValue, the masker's own producer. For both verbs it asserts the refusal is DELEGATION_SCOPE_VIOLATION and that neither the masked value, nor the stored value, nor the set the stored position distributes appears anywhere in the serialized refusal; and, in the other direction, that a row whose stored position is inside the scope is admitted for both verbs although the delegate reads it masked. That is the rule where it lives: a column counts as read only at its stored value, and the verdict is the stored row's either way. The PR body's one-shot ablation A3 (presence counts as read) reds exactly this case, which measures that the strict-equality reading is load-bearing.
  • §6 (security-findings.md) on the rewritten body, the changeset edit, the commit message and the test name: each states the hardening rule. The new Tests bullet and the A3 ablation line name a rig fixture and the pin's own failure, not a probe; the commit message carries the two rules; the test name carries the rule. No breach.

② Semver level

  • Unchanged: @objectstack/plugin-security at patch, Clause-②: no with no arm, Metadata-protocol: none, on the body and all three changesets. The delta publishes no code and moves no export.
  • Check Changeset is red by design on this head too (the same two deliberate corrections, awaiting the maintainer's confirmation); a second run of it and of PR-body guards was in progress at the time of this read, after the body rewrite. Every other check on da7d298fd2 is green: Build Core, Type Check (workspace, source gates, consumer gates, debt ledger), Test Core 1 to 8, the Lint families, the Governed Surface Queue Guard, the Dogfood Regression Gate, Test UI and Live E2E UI. Tier S landing still waits on the maintainer's confirmation of the two corrections and on every check green.

③ Boundary flags

④ Design judgment

  • The new case is one it in the existing describe, booted through the same harness with one variation (boot(posture, { positionMaskingRule })), so it runs against the same engine, driver and plugin as the rest of the rig; the masked precondition is asserted before the writes, so a green run cannot mean the rule never applied. The admitted leg deletes the in-scope row and reads the table back, so it also pins that the masked read does not turn an admission into a refusal.
  • One note, not a finding: the case runs under isolated only. The masking rule and the strict-equality reading are posture-independent (the masker is step 4 of the middleware for every posture), and the hidden-column case already runs under all three postures, so one posture pins the rule.

Findings: no must-fix; no new should-fix. Two of the three earlier should-fix items are applied; the third is deferred to a separate order by the seat.

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

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Red by design · head da7d298fd20cf6053cd62936c1e2baa829452bac · 2026-11-04T19:58Z

Gate: Check Changeset (scripts/check-empty-changeset.mjs).

Cause: this PR edits two pending changesets it did not create, .changeset/org-admin-removal-caller-read.md and .changeset/delegated-admin-unreadable-row.md, which came from PRs #29932 and #29950. This change makes their "answers 403" sentence false. The gate classes that edit as a DELIBERATE CORRECTION and stays red until the correction is confirmed in writing on this PR, which its header calls "the existing human path". Both contract-review records find the edits right: comment 6638950952, and the delta record on this head.

Why the red does not block anything else: pr-automation.yml runs on pull_request only, never on merge_group, and this check is not a required context.

Still owed: the maintainer's written confirmation on this PR. The seat will not land the PR until it is posted.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

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

changeset 改写确认 #30144、#30165 同意

This is the written confirmation that scripts/check-empty-changeset.mjs names as the human path for its DELIBERATE CORRECTION class. The pending changeset edits on this PR stand as they are. 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 da7d298fd20cf6053cd62936c1e2baa829452bac · 2026-11-05T00:50Z

  • Contract review: records 6638950952, 6640676948. The latest record on this head is Served-tier: CONTRACT_REVIEW_TIER, VERDICT PASS, no must-fix.
  • Changeset correction: confirmed in writing by the maintainer on this PR (the comment above, quoted verbatim).
  • Check-runs at this head (latest per name): 1 failure, 15 skipped, 33 success.
    • The only red check is Check Changeset. It is red by design (the DELIBERATE CORRECTION class); the source says so, it does not run on merge_group, and the cause is recorded on this PR. It is not a required context.
    • Every other check is 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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant