Skip to content

fix(plugin-security): a write refused for a caller who cannot read the object answers the same for a hidden row and a missing one - #29845

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/sec-hardening-f
Nov 3, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/sec-hardening-f

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:权限分配表(sys_user_permission_set、sys_user_position)上的 ADR-0090 D12 管理门,拒绝"按 id 更新/删除"时,若调用者连这个对象都读不了,改为回答该调用者读门的拒绝(与他按 id 读这行得到的答复完全相同);读得到这个对象的调用者(含组织管理员)仍看到 D12 原句,平台管理员照常写入。
  • 为什么改:安全加固(细节按披露裁决不公开)。规则:被拒的写,对看不见该行的调用者,隐藏行与不存在的 id 答复相同。
  • 风险与回滚:风险低。只在管理门已经拒绝时多做一次调用者自己的按 id 读;答复仍是拒绝,状态与 code 不变(403 PERMISSION_DENIED),只换句子;读本身故障则原样抛出。回滚 = revert 本 PR。
  • 先看:packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts(readDoorAnswerForGateRefusal 与 D12 调用点的 try/catch);packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts(文件末尾新 describe);packages/qa/dogfood/test/update-by-id-refusal-order.dogfood.test.ts(带令牌整答复比较 + 组织管理员对照)
  • 两分钟验证:pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/by-id-write-unreadable-not-found.test.ts,预期 23 个用例全绿。
  • 需要你做的:无,席位落地

What changes

The rule: a write refused for a caller who cannot read the object answers the same for a hidden row and a missing one — the same status, code and sentence, with a version token (If-Match, expectedVersion) and without one.

  • security-plugin-authz-middleware.ts — the ADR-0090 D12 delegated-administration gate refuses a write to the permission-assignment objects on the caller and the object alone, before any grant is read, in words written for the write. Its call is now wrapped: when it refuses (a declared 4xx) the by-id update or delete the caller addressed (addressedByIdWriteId — not a nested write, not the referential clear, routed by id by the engine; never the record field-access answer's synthetic update, whose verdict is admitted or refused alone), the read door's own question is asked of that id (getCallerPreImage, the caller's by-id read through the engine — no second evaluator):
  • Docs — content/docs/protocol/kernel/http-protocol.mdx, the update refusal section: one sentence saying the permission-assignment writes answer a caller refused reading the object with that read refusal, and a caller who reads it with the administration refusal.
  • Changeset — @objectstack/plugin-security patch.

Caller classes (by-id update/delete on an assignment object):

Caller Before After
reads and writes the object, not an administrator gate's sentence unchanged
reads only gate's sentence unchanged
writes without reading gate's sentence without a token the read refusal, every id, with or without a token
neither reads nor writes gate's sentence without a token the read refusal, every id, with or without a token
create-only insert: gate's sentence insert unchanged (names no row); by-id update/delete: the read refusal
tenant-level administrator passes the gate unchanged
organization administrator (reads the object) gate's sentence ("never edited in place") unchanged
system context bypasses the middleware unchanged
another organization's caller as its read of the object decides the same two arms: refused read → read refusal; readable object → gate's sentence

Clause-② is no, patch — a refusal sentence swapped for another for one caller class (as #28615). Every changed answer was and stays 403 PERMISSION_DENIED; nothing refused is admitted and nothing admitted is refused, so no accept set narrows. Metadata-protocol: none.

Tests

  • Plugin, real engine (by-id-write-unreadable-not-found.test.ts, a new top-level describe in the file that pins "a row the caller cannot read answers what a nonexistent id answers", with its own rig: both assignment objects, a real ObjectQL over SQL, the real SecurityPlugin). Per object × verb, for a caller with no grant and one with write-without-read: the caller's by-id read is refused (precondition), and the write of the row and of an id naming none both equal that refusal — code, status, message and name; the row is untouched. Controls: a caller who reads the object keeps the gate's sentence for the row and for no row; a tenant-level administrator updates a row and gets 404 RECORD_NOT_FOUND for no row; an insert keeps the gate's sentence.
  • Showcase, HTTP door (update-by-id-refusal-order.dogfood.test.ts): the stale-token families now compare status, code and sentence for every family, the two objects the member may neither read nor write included (they compared status and code only). New control: an organization administrator, who reads sys_user_permission_set, keeps the gate's "never edited in place" sentence on PATCH for a row and for no row.
  • Ablation, once (below): the helper made to always keep the gate's refusal → the 8 no-read plugin cases and the dogfood stale-token case go red; restored → green.

Acceptance notes

  • The administration gate's row-independent sentence is kept for every caller who reads the object, so the guidance an administrator or a holder of plain grants sees does not change.
  • Only the by-id update and delete the caller addressed are affected: an insert, a predicate write and a write the platform issues under the caller's context name no row of the caller's and keep the gate's refusal.

Verification

Head 7e516c3dcd on merge base b3d141505 (origin/main); the builds, suites, pins, ablation and dogfood battery ran at 04e42a7b06, and 7e516c3dcd changes only the changeset's title line (git diff --stat 04e42a7b06 7e516c3dcd: .changeset/write-refusal-read-door-parity.md | 2 +-). The gate union ran at 7e516c3dcd. Exit codes captured before any pipe. Locked runs through scripts/pm/os-verify-lock.sh on a shared box.

Before the fix (main at b3d1415): the plugin pin is red

pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/by-id-write-unreadable-not-found.test.ts with the new describe and the unfixed middleware: Tests 8 failed | 15 passed (23) — the 8 failures are exactly the no-read caller cases; the reader, administrator and insert controls pass.

Build, plugin suite, typecheck, pins
  • pnpm --filter @objectstack/plugin-security build → exit 0
  • pnpm --filter @objectstack/plugin-security test → exit 0 · Test Files 241 passed (241) · Tests 5220 passed | 45 skipped (5265)
  • pnpm --filter @objectstack/plugin-security typecheck → exit 0
  • pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/update-by-id-refusal-order.dogfood.test.ts → exit 0 · Tests 19 passed (19)
Ablation (one shot, dist-mediated)

node scripts/ablation-replace.mjs on security-plugin-authz-middleware.ts, anchor if (addressedId === undefined) return refusal; (hits 1 → 0, blob 5b65837cde22 → 20c73d8e1e31) replaced by a comparison against the planted literal ABLATION_SECF_MARK, so the helper always keeps the gate's refusal:

  • rebuild → exit 0; ablation-dist-preflight.mjs @objectstack/plugin-security 'ABLATION_SECF_MARK' → exit 0 (marker in dist)
  • plugin pin → exit 1 · Tests 8 failed | 15 passed (23) (the 8 no-read cases)
  • dogfood pin → exit 1 · Tests 1 failed | 18 passed (19) (the stale-token test)
  • restored by the tool's trap: blob 5b65837cde22 == HEAD, git diff HEAD empty (0 bytes); rebuild → exit 0; --absent preflight → exit 0
Showcase dogfood battery (final head)

pnpm --filter @objectstack/dogfood test (no shard, no path), under the lock: os-verify-lock: VERDICT command-exit 0 · Test Files 404 passed | 3 skipped (407) · Tests 3327 passed | 28 skipped (3355) · no failures.

Derived gates (`dispatch-gates.mjs --commands`, 90 families)

All 90 run at 7e516c3dcd, each exit captured before any pipe: 90 × exit 0. Two (pnpm --filter @objectstack/spec run check:skill-examples, pnpm check:dual-build-cjs-loads) first refused with PREREQUISITE NOT MET (exit 3: seven packages outside the dogfood closure had no dist/); after building those seven under the lock (turbo, 44 cached, 44 total) both re-ran → exit 0 (283 prose examples type-check across 3 surface(s); 115 published require entry point(s) across 70 package(s) load). pnpm check:type-check-debt → exit 0 (0 ledger entr(ies) re-measured … none above its recorded number).

Reconciliation: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD → 90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN.

Not run locally, owned by CI: test:repo (the vitest repo project; no file of it is touched) and the repo-wide lint scan; eslint --no-inline-config over the three changed TypeScript files → exit 0.


Generated by Claude Code

… row answers the same for a hidden row and a missing one

The ADR-0090 D12 administration gate answers the by-id update or delete
of a caller whose read of the assignment object is refused with that
read refusal; a caller who reads the object keeps the gate's own
sentence.

Clause-②: no
Metadata-protocol: none

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

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/permissions/system-context.mdx (via registerAuthzMiddleware (symbol, a top-level function))
What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 42ff86f67723f5cd6b275bc4ddbb10f05e8c4209 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 42ff86f67723f5cd6b275bc4ddbb10f05e8c4209

⚠️ 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 42ff86f67723f5cd6b275bc4ddbb10f05e8c4209 → 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 7e516c3 · 2026-11-03T07:17Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7e516c3dcdf34536b4aa39aa1e25b3b1e604a06f
Local-runs: none
Scope: packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts, packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts, packages/qa/dogfood/test/update-by-id-refusal-order.dogfood.test.ts, content/docs/protocol/kernel/http-protocol.mdx (prose face, the one added sentence), .changeset/write-refusal-read-door-parity.md + packages/plugins/plugin-security/src/delegated-admin-gate.ts (DelegatedAdminGate, every arm), packages/plugins/plugin-security/src/security-plugin.ts (getCallerPreImage, absentUnderCallerRead, readUnlessRefused), packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts (addressedByIdWriteId, readRowById), packages/plugins/plugin-security/src/record-row-write-probe.ts (isRowWriteProbe), packages/plugins/plugin-security/src/system-write-guard.ts (assertEngineOwnedWriteAllowed), packages/plugins/plugin-security/src/errors.ts, packages/types/src/data-error-classification.ts (declaredHttpStatus), packages/metadata-protocol/src/protocol-record-reads.ts (assertVersionOf, route (i), commit f95d0db), packages/metadata-protocol/src/protocol.ts (getData, updateData, deleteData), docs/adr/0090-permission-model-v2-concept-convergence.md (D12 and its two amendments), docs/adr/0103-managedby-write-policy-and-engine-write-guard.md (D1, D2)

Inputs: the PR body, its five-file list, the net diff against main at the merge base b3d1415055, and the head's check-runs. Not the dispatch order, not the dispatching seat's conclusions, not the dev's private report.

① Derived judgments

The change is one try/catch around the ADR-0090 D12 gate's assert in the engine authorization middleware, plus the helper readDoorAnswerForGateRefusal. The helper is entered only from the catch. It hands back the gate's own refusal unchanged unless all of these hold: the refusal declares a 4xx (declaredHttpStatus), the operation is not a row-write probe, and addressedByIdWriteId names an id (a top-level by-id update or delete; a nested write, the referential clear, an insert and a predicate write name none). Then it asks getCallerPreImage for that id, which is the caller's own by-id read through the engine (readRowById → ql.findOne under the caller's context — the same read the HTTP by-id door getData issues, and the same read step 2.7 shares). A read that throws a declared 4xx becomes the answer; a read that answers (a row or null) leaves the gate's refusal as it was; anything else the read throws propagates.

What answers change, by caller class, for a by-id update or delete on a governed object:

  • Reads and writes the object, no administration: the read answers, so the gate's own sentence stands. Unchanged.
  • Reads only: the same. Unchanged.
  • Writes without reading: the gate's sentence becomes the read door's refusal — the same {status, code, message} its by-id read gives for every id — for an existing row, an unknown id, and with or without a version token. With a token the OCC comparison (route (i), commit f95d0db) already answered that read refusal for a mismatched token on an existing row and stands down for an unknown id, whose write now answers the same; the no-token write now matches too. This is the rule's class.
  • Neither reads nor writes: as above.
  • Create-only: an insert names no row and keeps the gate's sentence; its by-id update or delete takes the read refusal. Consistent with the rule.
  • Tenant-level administrator: isTenantLevelAdmin returns before any refusal; the wrapper is never entered. Unchanged (an unknown id answers the door's 404).
  • Organization administrator: holds a read grant on the assignment objects, so the read answers and the gate's own sentence stands, for a row and for no row. Unchanged, as the brief's rule states.
  • Delegate (adminScope holder): if the delegate reads the object, every gate answer is unchanged. If not, every refusal the gate gives it — including the scope arms, which word their refusals after the target row and the set it names — now becomes the read refusal. This class is covered by the rule and is an improvement; it is not pinned by a test (see ③).
  • System context: bypassed before the gate; a flow-run or app-automation system identity is refused earlier by assertFlowSystemIdentityWriteAllowed, which is not wrapped and reads no row. Unchanged.
  • Principal-less user context: the gate refuses, the read is refused on the same ground, so the read refusal answers; row-independent. Consistent.
  • Another organization's caller: its read of the object decides — refused, the read refusal; answered, the gate's sentence. Consistent with the rule.
  • Row-write probe (the record field-access writable verdict): excluded by isRowWriteProbe; the verdict is refused either way.

Clause-②: no is right. Every answer that changes was a refusal and stays one with the same status and code; what moves is the sentence (and the error's name on the delegate arm). Nothing refused is admitted and nothing admitted is refused: the wrapper sits on the catch path only, a read that answers restores the gate's refusal byte for byte, and stampWriter still runs only after authorize returns. No accept set narrows. Precedent #28615 (a refusal swapped for another refusal on an existing door) applies.

② Semver level

patch on @objectstack/plugin-security, Metadata-protocol: none: correct. No spec key, published schema, export or default changes; one package moves; the change is a sentence on a path that was already a refusal. The changeset carries both declaration lines; the PR body carries the same two.

③ Boundary flags

  • The narrowed public wording ("cannot read the object", not "cannot see the row") is exactly what is enforced. The swap keys on getCallerPreImage throwing a declared 4xx — the caller's read of the object refused — and never on a null answer (a row absent from the caller's read). A caller who reads the object keeps the gate's own answer whatever the row. The changeset body ("whose read of the object is refused"), the docs sentence ("a caller refused reading the object … a caller who reads the assignment object gets the administration refusal's own sentence") and the code say the same thing. One incompleteness, not a contradiction: the gate governs three objects (sys_user_position, sys_user_permission_set, sys_member) and the wrapper covers all three identically, while the changeset and the docs sentence name the two assignment objects only. The third object's gate refusal is row-independent before and after, so the prose claim is true of what it names; a follow-up may widen the sentence.
  • The engine-owned write guard (ADR-0103 D2, assertEngineOwnedWriteAllowed) runs before the gate and is not wrapped. Its exclusion holds on a stronger ground than reachability: it judges the schema's managedBy bucket, the operation and the context, and reads no row, so its answer never varies by id. Whether or not the HTTP exposure answer keeps it unreached for a given object, it opens no existence boundary.
  • Suites ran at the parent commit 04e42a7b06: git diff 04e42a7b06 7e516c3dcd is one line, the changeset's title; no code, test or docs byte differs between the two, so the parent's runs describe this head. The head's own check-runs are the gate verdicts (below).
  • Test evidence. Plugin, real engine over SQL with the real SecurityPlugin: per object × verb, a caller with no grant and a caller with write-without-read — the by-id read refused as the precondition, the row's write and an unknown id's write both toEqual that refusal in code, status, message and name, the row untouched; controls: a reader keeps the gate's sentence for a row and for no row, an administrator updates and gets 404 for no row, an insert keeps the gate's sentence. Showcase HTTP door: the stale-token families now compare status, code and sentence for every family, the assignment object included, for a row, for no row and without a token, across If-Match and expectedVersion; a new organization-administrator control pins the gate's sentence on PATCH for a row and for no row. Not pinned: a delegate who cannot read the object, and the third governed object. Both are covered by the same wrapper line, so the pins would guard against a future narrowing of the wrapper, not a gap in this head.
  • Check-runs on 7e516c3dcd at the time of this record: every completed run is success (Lint & Repo Gates, every scoped lint, Type Check source/consumer/debt-ledger, Build Core, Build Docs, Test Core 7/8, Test UI, Live E2E UI, Dogfood Regression Gate, Governed Surface Queue Guard, Check Changeset, PR-body guards, Spec property liveness, Check PR Size, Check Documentation Links). Still running: Test Core shards 1–6 and 8, and Type Check · workspace. No run has failed.
  • reviewFace on the file list decides no face (no published schema, no governed path, Clause-②: no with no arm); the tier is the one the ruling of record owes for the boundary, as the brief states.

④ Design judgment

  • An ALLOW turned into a refusal: none. The wrapper is reachable only after the gate has thrown; the pass path is byte-identical. Non-governed objects and non-governed operations return from assert before authorize and never throw.
  • A read fault turned into a different refusal: no. A read that throws with a declared status of 500 or more, or with no declared status at all, propagates as raised — an outage is neither an answer nor a refusal, the same discipline step 2.7 and readUnlessRefused follow. A gate throw with no declared status or with a 5xx status is handed back untouched. A declared 4xx from the read is, by construction, what the read door answers this caller for that id, so the parity holds for it too.
  • A race between the gate and the caller read: none that changes an outcome. The gate's refusal on the rule's class is decided on the caller and the object; the extra read comes after it and, whichever way it answers, the result is a refusal and nothing is written. A row appearing or vanishing between the two reads cannot turn an object-level read refusal into an answer.
  • Performance: one extra by-id read, on the refused path only, for by-id update and delete on three objects. The pre-image memo it leaves is on an operation context that is then discarded. Negligible, and the read is the same one the admitted path already pays at step 2.7.
  • A path where the gate's answer still differs by row existence for a caller who cannot read the object: none found. Every arm of the gate — the anchor refusal, the principal-less refusal, the membership-object refusal, the organization-level arm, the delegate arms and the plain-grant refusal — throws a declared 4xx, and every one of them is wrapped; for a caller whose read is refused, every one becomes that read refusal, for any id. Callers who read the object are outside the rule by its own statement.
  • Version tokens: the comparison answers before the engine is entered. For the rule's class a mismatched token on an existing row already answered the read refusal (route (i)); on an unknown id it stands down and the write now answers the same refusal; a matching token proceeds to the same write. The dogfood pin compares all three carriers for the whole body.

No must-fix. Two rule-level observations are recorded in ③ (the prose naming two of three governed objects; the two unpinned classes), neither of which changes what this head enforces.

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

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

席位核对

ACCEPT · head 7e516c3dcdf34536b4aa39aa1e25b3b1e604a06f · 2026-11-03T07:34Z

  • Shape: 5 files, +316/−25. Not governed (check-governed-merges.mjs --pr 29845), under 3000. Clause-②: no (one refusal swapped for another; precedent fix(metadata-protocol): an update by id answers the same refusal whether or not the id exists, as delete does #28615); patch; Metadata-protocol: none.
  • Rule held: take a caller whose read of the object is refused. A by-id update or delete refused by the ADR-0090 D12 gate answers the caller's own read refusal: one {status, code, message} for an existing row and for an unknown id, with or without a version token. Readers of the object keep the D12 hint. Tenant admins are unaffected.
  • Contract review: comment 6597914495. Served-tier: CONTRACT_REVIEW_TIER (owed under ruling 5985885287 for the write door's existence boundary). Head-sha equals this head. VERDICT PASS, no must-fix.
  • Accepted deviations: the public wording is narrowed to "cannot read the object", the dictated form being "cannot see the row". The narrower claim is exactly what is enforced; the review verified that the swap keys on a refused caller read, never on an empty one. The engine-owned guard is excluded because it reads no row. Suites ran at 04e42a7b06; the head differs only by one changeset title line.
  • Check-runs at this head: 38 success, 12 skipped, 0 failed, all completed. Skips judged by name (path-filtered UI/build/dogfood jobs, opt-in harnesses, the schedule gate). check-expected-skips.mjs NOT MEASURED (no node_modules in this container). mergeable_state clean.
  • Out of scope: the review's two rule-level observations (prose names two of the three gate-governed objects; a delegate without read, and the third object, are not pinned) go to the seat's follow-up with the residual the narrowed wording leaves out.
  • Landing: ready, then auto-merge.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 3, 2026 07:34
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 3, 2026 07:35
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit 0b740c6 Nov 3, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/sec-hardening-f branch November 3, 2026 07:53
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant