Skip to content

fix(plugin-approvals): every door that acts on an approval request answers a request the caller may not read as the read door does - #28916

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/harden-approval-decision-visibility
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/harden-approval-decision-visibility

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:审批请求上所有"对请求动手"的入口(批准、驳回、撤回、退回修改、重新提交、转交、催办、要求补充信息、评论)在做任何判断之前,先用"读取请求"入口的同一条可见性规则判断调用者能否看到这条请求;看不到的,一律按"请求不存在"回答 404 REQUEST_NOT_FOUND,且不写任何数据。规则只有一份:从读取路径抽出为 requestRowForCaller,读取与动手两边共用。
  • 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)
  • 风险与回滚:能看到请求的人(提交人、审批人、已处理过的人、开启了"记录读者"开关时的记录读者)行为不变;管理员越权处理(含跨组织的平台管理员)与系统上下文(SLA 升级、邮件操作链接)不经此判断、行为不变。一处可感知变化(维护者已接受):审批人若在"看不到该请求的组织上下文"中直接调接口(读取入口本就对他返回 404),现在动手入口也返回 404、不再落库后报 500,需切换到请求所在组织或由管理员处理。正常审批每次多一次可见性查询。回滚 = revert 本 PR。
  • 先看:① packages/plugins/plugin-approvals/src/approval-service.ts 的 requestRowForCaller 与 refuseUnreadableRequest(风险点:越权判定按请求所在组织豁免,其余一律走读取规则);② 同文件 loadPendingRow、decideNode、recall、resubmit 中紧跟取行之后的那一行调用;③ packages/qa/dogfood/test/approval-act-doors-read-visibility.dogfood.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/dogfood exec vitest run test/approval-act-doors-read-visibility.dogfood.test.ts —— 预期 1/1 通过(九个动手入口对"看不到的已存在请求"与"不存在的 id"回答逐字节相同,另有读者 403/409、审批人可批、管理员越权可批三组对照)。
  • 需要你做的:无,席位落地。

What changes

A hardening of the approval request doors.

  • One read rule, two consumers. The read door's rule for a single request — the row read inside the caller's organization, kept only when the caller is among the requests it may see (participants, the override actor's view, the opt-in record-reader tier) — moves out of loadRequest into requestRowForCaller. getRequest serves by it exactly as before.
  • Every door that acts on a request asks it first. decideNode (and so decide, the approve / reject doors), recall, resubmit, and loadPendingRow (sendBack, reassign, remind, requestInfo, comment) call refuseUnreadableRequest right after the row is found, before any check that depends on the row. A caller the read door would not serve the request to is answered REQUEST_NOT_FOUND: ID — the very error an id that names no request gets, built from the id the caller sent — and nothing is written.
  • Who is not asked. The override actor, judged against the request's organization exactly as each door judges it (a platform administrator's override crosses the tenancy wall the read door keeps), and system contexts (the SLA sweep, the email action link). Both act as before. issueActionTokens, which mints for a slot on behalf of no reader, reads as the system.
  • A caller who can read the request is judged by the door's own rule, unchanged: 403 FORBIDDEN for a reader who may not take the action, 409 INVALID_STATE for a request no longer in the state the action needs.
  • Docs: the decision section and the error-code table of content/docs/automation/approvals.mdx state the rule.

Clause-② is no and the changeset is patch: the change swaps a refusal for the read door's own refusal for callers who cannot read the request, admits no one new and refuses no reader anything new — the same reading as the by-id update refusal-order hardening.

Tests

  • HTTP door (dogfood) — packages/qa/dogfood/test/approval-act-doors-read-visibility.dogfood.test.ts, on a real boot (real SecurityPlugin, a real flow, the approvals plugin). A plain member who is no participant gets the same status and body for an existing request as for an id that names none on all nine act doors (ten bodies: approve with and without decision outputs), on a pending request and on a decided one, and the row and its history are unchanged. Controls: the submitter is 403 FORBIDDEN on the approver's verbs and 409 INVALID_STATE on resubmit of a pending request and on a decided one; the approver is 403 FORBIDDEN on recall and remind, and still approves (200); the administrator, on no slate, approves through the override (200) and is told 404 for an unknown id.
  • Service — approval-service.test.ts gains a block of four: every act door answers a non-participant and another organization's administrator REQUEST_NOT_FOUND: ID, byte for byte the missing-id answer, and writes nothing (pending and decided); a reader keeps the door's own FORBIDDEN / INVALID_STATE and the approver decides; with the record-reader tier on, a tier reader is judged by the door (FORBIDDEN), which shows the doors follow the read rule rather than a copy of it; the platform administrator's cross-organization override and a system context act as before.

Verification

Head 7f95ac12a: the change (100cb412d), its tests (c47eb917a), origin/main merged at bdb19ca6d (7fe58dddb, one append-only conflict in approval-service.test.ts, both blocks kept), and one stale dogfood expectation the full battery named (7f95ac12a). After the merge: pnpm install --frozen-lockfile, packages/runtime/.objectstack removed, the dogfood dependency closure rebuilt; the built @objectstack/plugin-approvals carries the new helper (dist/index.js, 7 occurrences). Every test, build and ablation run below held the shared verify lock; the gates do not take it.

Stock showcase — a throwaway probe (never committed)

The showcase booted with its own demo bootstrap; a seeded persona who is no participant of a pending demo request called the read door, its audit trail and the nine act doors on that request and on an id that names nothing. With this change built, every one of the twelve calls answered both ids with the same status and body, 404 REQUEST_NOT_FOUND. The probe file was deleted after the reading.

Touched packages — tests and typecheck (on 7fe58dd unless named)
pnpm --filter @objectstack/plugin-approvals typecheck                     exit 0
  check:test-typecheck: OK — 8 file(s) / 321 error(s) / 27 pinned signature(s) held in test-typecheck-debt.json
pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2
  Test Files  107 passed (107)   Tests  1380 passed (1380)
pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=1 test/approval-act-doors-read-visibility.dogfood.test.ts
  Test Files  1 passed (1)   Tests  1 passed (1)
pnpm check:type-check-debt                                                exit 0
  check-type-check-coverage --re-measure: OK — 0 ledger entr(ies) re-measured, 0 raw tsc error(s) total
on 7f95ac12a:
pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=1 test/position-address-readers.dogfood.test.ts test/approval-act-doors-read-visibility.dogfood.test.ts
  Test Files  2 passed (2)   Tests  2 passed (2)
pnpm --filter @objectstack/dogfood typecheck                              exit 0
pnpm --filter @objectstack/http-conformance exec vitest run --maxWorkers=1 src/hono-approvals-act.conformance.test.ts
  Test Files  1 passed (1)   Tests  4 passed (4)        (the email action-link door, a system context)
pnpm --filter @objectstack/verify exec vitest run --maxWorkers=1 src/action-door-api-forward-denied-subject.test.ts
  Test Files  1 passed (1)   Tests  3 passed (3)
Ablation of the read-rule call — one-shot, restored (on 7fe58dd)

Mutation through scripts/ablation-replace.mjs, wrapped, under a trap restore with absolute paths: the read-rule call in refuseUnreadableRequest replaced by an unconditional return carrying a marker. Anchor if (await this.requestRowForCaller(requestId, context, true)) return; hit 1 → 0; blob be0dd52a6d60 → fa2f535376e2; marker count in the source 1. Rebuilt; ablation-dist-preflight found the marker in 2 built files.

  • dogfood pin under the mutation: 1 of 1 red (the stranger's approve no longer answers what the missing id gets);
  • the service block under the mutation: 1 of 4 red — the every-door case; the reader, tier and override cases stay green, as they do not depend on the read-rule call.

Restore: blob back to the HEAD blob be0dd52a6d60 (checked against git rev-parse HEAD:path), git diff HEAD empty, whole-tree git status --porcelain empty; rebuilt; ablation-dist-preflight --absent: marker absent from all 6 built files, tree clean; the helper back in dist/index.js (7). Dogfood pin 1/1 and the service block 4/4 green again.

Full dogfood battery — `pnpm --filter @objectstack/dogfood test`, once

On 7fe58dddb: Test Files 1 failed | 382 passed | 3 skipped (386), Tests 1 failed | 3156 passed | 28 skipped (3185), 1404.7 s. The one failure is this change's consequence: position-address-readers.dogfood.test.ts expected the door's own refusal for a bystander the read door already hides the request from (it asserts that 404 two lines earlier). 7f95ac12a updates that expectation; the file and the new pin are 2/2 on 7f95ac12a. The rest of the battery was not re-run: 7f95ac12a changes that one test file only.

Gates — dispatch-gates.mjs --commands on 7f95ac1

93 derived families, run locally, each exit code recorded before any pipe; every one exit 0. dispatch-gates.mjs --ran: 93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN. Among them:

check-nul-bytes: OK (scanned 23654 text file(s) … no raw ASCII control bytes)
check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).
check:skill-examples: 280 prose examples type-check across 3 surface(s)
check:type-check-debt: surplus: none — every entry sits exactly at its measurement

Acceptance notes

  • Fixture triage. A caller who is no participant of the request is asserted REQUEST_NOT_FOUND (admin-exemption, the two name-authority suites, later-step submitter, the admin-override block, the reassign / comment outsider cases, the slot-reader block, and the bystander in the position-address-readers dogfood file). Where a pin's subject is the door's own refusal — the decision slot test, the submitter-only resubmit guard, the localized non-submitter recall sentence, the reassign target that must not be looked up — it now asks a caller who READS the request (the submitter holding another position, a pending approver, the override actor), so that refusal is still the one that answers, and the non-reader is asserted beside it.

  • Doubles. Two engine doubles (approval-revise, stranded-resubmit-second-door) threw on $or, which the participant probe sends; the probe swallows a failed read and keeps what it collected, so a past actor read as a non-participant there. They now compose $or as the real engine does. A legacy request row inserted by hand in approval-list-display-labels gets the approver index the plugin's boot backfill (rebuildApproverIndex) gives such a row.

  • One behaviour change for a legitimate caller — accepted by the maintainer. A pending approver whose active organization does not hold the request (an org-scoped approver on an org-less request) does not read it: the read door already answers them 404. The act doors now answer them 404 REQUEST_NOT_FOUND with nothing written, where the decision used to be recorded and the call then failed 500 READ_BACK_FAILED on the echo. They act from the request's organization, or an administrator's override or the email action link decides it. The maintainer's ruling on it, verbatim:

    一个连带的行为变化,我接受

    The same rule applies to a submitter whose active organization does not hold the request: recall, remind, resubmit and comment now answer 404 REQUEST_NOT_FOUND with nothing written. The mechanism is the same as on the approver arm. The maintainer accepted this arm too in the PM session, when asked about it explicitly, verbatim:

    同意

    Contract review 6568970094, should-fix 1: a pin for this arm follows in the next change on these doors.

    In approval-service.test.ts's org-filtered read-back block the read-back refusal stays pinned on decide (through the platform administrator's override) and on comment (an org-scoped system context), and a new arm pins the before-any-write refusal for the org-scoped approver.

  • Cost. A non-override caller's act pays one by-id read of the request inside their organization and the participant probe the read door already pays. The override actor and system contexts pay nothing.

  • Unchanged on purpose. GET /approvals/requests/:id and /actions (already one answer for both ids), the operator verb continueRestoredRun (no door, no caller context), the action-link redemption (a system context), issueActionTokens (reads as the system, stated in place).


Generated by Claude Code

…le request as the read door does

Every door that acts on one approval request (decide, recall, send back,
resubmit, reassign, remind, request info, comment) now asks the read
door's own visibility rule right after the row is found and answers a
caller who may not read the request exactly as it answers an id that
names no request. The rule is extracted from loadRequest into
requestRowForCaller, so getRequest and the act doors read one
definition. The override actor and system contexts act as before.

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
Fixture triage for the doors that act on a request asking the read
rule first. A caller who is no participant of a request is asserted
REQUEST_NOT_FOUND. Where a pin's subject is a door's own refusal (the
slot test, the submitter-only guards, the localized recall refusal, the
org-filtered read-back) it asks a caller who reads the request, so that
refusal is still the one that answers. Two doubles learn the `$or` the
participant probe sends; a legacy row gets the approver index the boot
backfill gives it. New ids are read through the opened row's type.

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
# Conflicts:
#	packages/plugins/plugin-approvals/src/approval-service.test.ts
…is answered as for a missing id

The full dogfood battery on the merged head named one stale expectation:
the bystander's approve on a request the read door already hides from
them is now REQUEST_NOT_FOUND. The reader controls in the same case (the
submitter's refusal, the holder's and the email reviewer's decisions)
are unchanged.

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l 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 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/client-sdk.mdx (via approvals.getRequest (sdk, the route ledger binds it to GET /api/v1/approvals/requests/:id, selected by route anchor /approvals/requests/:id), getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id))
  • content/docs/api/plugin-endpoints.mdx (via /approvals/requests/:id (route, bridged from symbol requestInfo — its route source's handler names it; bridged from symbol sendBack — its route source's handler names it))
  • content/docs/automation/approvals.mdx (via getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id), /approvals/requests/:id (route, bridged from symbol requestInfo — its route source's handler names it; bridged from symbol sendBack — its route source's handler names it))
  • content/docs/automation/flows.mdx (via ApprovalService (symbol, a top-level class), /approvals/requests/:id (route, bridged from symbol requestInfo — its route source's handler names it; bridged from symbol sendBack — its route source's handler names it))
  • content/docs/kernel/services-checklist.mdx (via /approvals/requests/:id (route, bridged from symbol requestInfo — its route source's handler names it; bridged from symbol sendBack — its route source's handler names it))
  • content/docs/permissions/system-context.mdx (via requestInfo (symbol, a method of class ApprovalService), sendBack (symbol, a method of class ApprovalService))

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

  • content/docs/releases/v16.mdx (via getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id))
  • content/docs/releases/v17/17-0.mdx (via getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id))
  • content/docs/releases/v17/17-6.mdx (via ApprovalService (symbol, a top-level class))
  • content/docs/releases/v18/18-1.mdx (via ApprovalService (symbol, a top-level class), /approvals/requests/:id (route, bridged from symbol requestInfo — its route source's handler names it; bridged from symbol sendBack — its route source's handler names it))

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
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 6 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 9a9823d921a85a523529c25978daeb8229fa4ff9 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9a9823d921a85a523529c25978daeb8229fa4ff9

⚠️ 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 9a9823d921a85a523529c25978daeb8229fa4ff9 → 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

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7f95ac12a480f23750ba3a57394e8c79f3bc3ef3
Local-runs: none
Scope: packages/plugins/plugin-approvals/src/approval-service.ts, .changeset/harden-approval-act-doors-read-visibility.md, content/docs/automation/approvals.mdx, the 9 fixture suites under packages/plugins/plugin-approvals/src, packages/qa/dogfood/test/approval-act-doors-read-visibility.dogfood.test.ts, packages/qa/dogfood/test/position-address-readers.dogfood.test.ts + packages/rest/src/approvals-routes.ts (the nine act routes and handleApprovalError), packages/runtime/src/action-execution.ts (forwardApiAction / apiActionForwardRefusal, the action-door and MCP run_action forward), packages/plugins/plugin-approvals/src/pending-approver-read-grant.ts, request-read-gate.ts, approvals-plugin.ts (boot rebuildApproverIndex), packages/spec/src/contracts/approval-service.ts, packages/spec/src/api/error-code-ledger.zod.ts. reviewFace returns no governed hit on this file list; the review is owed at tier by the security-boundary clause of ruling 5985885287 (#21771), and the dispatching seat named the tier. Read-only: diff, PR text, the ruling, the head's check-runs (at 2026-11-02T04:20Z: core/lint/type-check shards still in progress, none concluded red; queue guard, PR-body guards, changeset-registration, dogfood regression gate green).

① Derived judgments

Door census (own enumeration, head tree). Service doors that act on one request: decideNode (reached by decide, so by the approve / reject routes, redeemActionToken and the SLA sweep's decideAndResume), recall, resubmit, and via loadPendingRow: sendBack (revise route), reassign, remind, requestInfo (request-info), comment. REST: nine POST /approvals/requests/:id/... routes, all through one handleApprovalError. Other reaches: the action-door type: 'api' forward (REST /actions and MCP run_action, one forwardApiAction) hands the approvals route the caller's own credentials and relays the target's status and body whole; the /approvals/act page and the plugin's raw mount decide through redeemActionToken; no other in-package caller of an act door exists (approval-node.ts, approval-revise-node.ts, lifecycle-hooks.ts, approvals-plugin.ts call none). continueRestoredRun and issueActionTokens are no caller door; the latter now reads as the system in place. Census matches the PR's list — correct.

  1. Refusal placement (every side effect). In all eight service doors the read-rule call sits on the line after the row is found and before the status check, the slate test, the submitter test, the resume pre-flight, every sys_approval_action insert, every sys_approval_request update, every notify. What precedes it on each door is resolveActor (reads only) and input validation, both independent of the row and answered identically for a missing id. Nothing is written or emitted for a refused caller — correct; pinned at the service (row and action count unchanged, pending and decided) and at the HTTP door (row and history unchanged).

  2. Same bytes as a missing id, per door. Both branches throw REQUEST_NOT_FOUND: plus the id the caller sent; the REST arm maps both through the same mapping row to 404 {code, error} with the code prefix stripped, same status path, no header differs. Across the forward, apiActionForwardRefusal relays the target's status and {code, error} for both ids alike — the forward's own doc already treats a filtered and a missing subject as one answer to the target, so no oracle opens there. Note only, outside the ruling's per-door claim: the read door's own 404 sentence is worded differently from the act doors' — each door is self-consistent, which is what the ruling asks.

  3. Single definition. requestRowForCaller is the moved body of loadRequest (tenancy wall via findRequestRow, then visibleRequestIds with the row's own anchor); loadRequest and refuseUnreadableRequest both call it; no second predicate was written. The override exemption reuses isOverrideActor, judged against the request's organization as each door already judges it (documented in place). The generic data-door gate keeps calling visibleRequestIds as before. Correct.

  4. Principal classes, accept set before and after:

    • pending approver by user id, by account email, by a held position: address: a reader through the approver index (sys_approval_approver, kept by syncApproverIndex on open, reassign and escalation; boot rebuildApproverIndex for legacy rows) — door's own rule, unchanged. The pending-approver READ GRANT (pending-approver-read-grant.ts) grants the target record, not the request; no approver reads the request only through it, so none is newly refused.
    • OOO delegate: the slot is rewritten to the delegate at open, so the delegate is on the index — unchanged. Reassigned approver: on the index after syncApproverIndex(next) — unchanged.
    • submitter: reader via submitter_id — unchanged. Past actor: reader via the actor_id / acted_as probe — unchanged.
    • record-reader tier (opt-in): reader, held to the door's own refusal — unchanged, pinned (this is also the proof the doors follow the rule rather than a copy).
    • tenant admin: exempt in their own organization and on an org-less request (isOverrideActor with a null request org) — unchanged. Another organization's admin: no override there and behind the wall — refused as a missing id, could not act before either.
    • platform admin across organizations: exempt — unchanged, pinned (decision lands, via_override, echo refuses as before).
    • action-link / email token: {...SYSTEM_CTX, userId} is a system context — exempt, unchanged (hono conformance 4/4 reported). SLA sweep: SYSTEM_CTX — exempt. Flow-engine resume: no door reaches an act door.
    • the one accepted change: an org-scoped caller on an org-less request. The read door already answers them as a missing id; the act doors now do the same before any write, where before the write landed and the echo failed. The PR names the pending approver; by construction the same holds for an org-scoped SUBMITTER on an org-less request on recall, remind, resubmit, comment. Same mechanism, same ruling clause, not pinned and not named — should-fix below.
      No class is newly admitted anywhere: the new call can only refuse.
  5. Doors not touched, on purpose and correctly: GET /approvals/requests/:id and /actions (already one answer), continueRestoredRun, the action-link redemption.

  6. Fixture triage across the 9 files. Every replaced refusal is one of: (a) a non-participant now asserted as a missing id with the state asserted unchanged (admin-exemption, both name-authority suites — their viaName vs viaPlain comparisons still measure the minted name against the plain member, later-step submitter, the override block, reassign and comment outsiders, the slot-reader block); (b) the door's own refusal re-pointed to a reader so that refusal is still what fires: the submitter holding another position (READING_BYSTANDER, READING_HOLDER), the approver put on the slate in the localized-recall suite (with the account row a user approver needs), the override actor for the submitter-only resubmit guard. (c) Two engine doubles learn $or as the real engine composes it — the doubles were wrong, not the probe; a hand-inserted legacy row gets the index the boot backfill gives it. Nothing those tests proved is weakened. The dogfood fix in 7f95ac1 is a correct re-point: that file asserts the bystander's detail read is 404 two lines above, so the door answer must match it; the holder's 200 and the submitter's 403 stay.

  7. Pins non-vacuous. The ablation in the body replaces the one read-rule call with an unconditional return: dogfood pin 1/1 red, service block 1/4 red (the every-door case; the reader, tier and override cases do not depend on that call by design), restore proven by blob and clean tree. Consistent with the code: that call is the only thing the pin can detect.

  8. Disclosure. PR body, changeset, commit messages and test titles name classes, positions and contracts; no earlier answer is stated as a probe recipe. The accepted-change paragraph names a legitimate caller's prior failure, not a probe.

  9. Out-of-scope finding (submitter-less recall / remind admit any reader): the raw.submitter_id && guard on both doors is untouched by this diff and present on main; the new call only narrows ahead of it. Pre-existing, not widened; queued separately.

  10. Client census (the ruling's first step): Console's inbox humanizeError already maps 404 to "request no longer exists" and 403 to "not allowed"; no shipped client reads a by-id act's 403 as "exists but no access"; the app-shell action panels show buttons only when viewer.can_act holds. The SDK's 403 note at client/src/index.ts is about the AI pending-actions queue, not approvals.

② Semver level

patch on @objectstack/plugin-approvals, Clause-②: no, Metadata-protocol: none — consistent. No published type changes (IApprovalService unchanged; requestRowForCaller / refuseUnreadableRequest private). The accept set of every reader is unchanged; the one refusal that moves earlier replaced a declared server-side failure (READ_BACK_FAILED, "the write IS recorded") for a caller the read door already hid the row from, and the maintainer accepted it. no stands; not (narrowing).

③ Boundary flags

  • Author objectstack-fleet[bot] — no login to name.
  • The maintainer's acceptance of the org-scoped-caller change is quoted in the body and relayed by the dispatching seat; there is no card and no GitHub comment carrying it. The dispatching seat holds that acceptance; it should also cover the submitter arm named in ① 4 (should-fix 1).
  • dev open questions: none declared in the body.

④ Design judgment

Against ruling 5985885287 (letter A): "on the write doors, a row the caller cannot read is a row that does not exist", "for every principal class", "a caller who can read the row but may not write it keeps 403". The change implements exactly that on the nine approval act doors, reusing the read door's own visibility (the ruling's ④: "复用读接口已有的可见性判断,不加新门"), with the override and system exemptions the ruling's "every principal class" sentence does not forbid (an override actor is served the row by the read door inside their organization; the cross-organization platform admin is the one exemption the read door does not serve, and that exemption is documented, pinned and acts exactly as before). The docs change in approvals.mdx states the rule and the error table correctly. Holds.

Must-fix: none.

Should-fix:

  1. Name the submitter arm of the accepted change (org-scoped submitter, org-less request: recall, remind, resubmit, comment) in the acceptance note, and pin it beside the approver arm in the org-filtered read-back block — same mechanism, currently unpinned.
  2. approval-act-doors-read-visibility.dogfood.test.ts header names src/act-doors-read-visibility.test.ts as the service-side pin; that file does not exist — the block lives in approval-service.test.ts.
  3. The read door's own 404 sentence and the act doors' differ in wording; each door is self-consistent, so nothing is owed by the ruling, but one sentence for all ten doors would cost nothing.

Implemented-by: claude/harden-approval-decision-visibility
Reviewed-by: session_017xRBcVy4VXmkQjn2MGeaWk

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

ACCEPT → head 7f95ac12a480f23750ba3a57394e8c79f3bc3ef3 · 2026-11-02T10:35Z

维护者速读

  • 改了什么:审批请求的所有操作入口(批准、驳回、撤回、退回、重新提交、转办、催办、要求补充信息、评论)拿到请求行后,先按"查看"入口的可见性规则判断。规则只有一份,即 requestRowForCaller,和 getRequest 共用。看不到这条请求的调用方会得到 REQUEST_NOT_FOUND,和请求不存在时完全一样,系统不写入任何内容。override actor 和系统上下文不受影响;能看到请求的人,原来该拿 403/409 的照旧。
  • 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)。
  • 风险与回滚:只有一类正常调用方的行为会变,维护者已经接受:组织范围内的审批人或提交人,操作一条不属于任何组织的请求时,原来是先写入、再报 500,现在直接回 404,什么都不写。回滚即 revert 本 PR。
  • 先看:packages/plugins/plugin-approvals/src/approval-service.ts,重点是 requestRowForCaller 和 refuseUnreadableRequest。
  • 两分钟验证:pnpm --filter @objectstack/dogfood exec vitest run test/approval-act-doors-read-visibility.dogfood.test.ts,预期 1/1 通过。
  • 需要你做的:无,席位落地。

席位意见:收。本 PR 落实 W3-RT 的私下安全报告。维护者在 PM 会话中批准了推送(「我同意推送,你应该使用pm-dispatch 技能审核和合并pr」),并分别接受了审批人一侧(「一个连带的行为变化,我接受」)和提交人一侧(「同意」)的行为变化。

席位核对

  1. 形态:draft,目标 main。按披露裁决不建卡,正文不写关闭关键词。正文首行是签名;接受行为变化的原文已写进验收说明。
  2. 前提实测:用 stock showcase 做了一次性探针(未提交):修复前,9 个操作入口对"看不到"和"不存在"给出的回答不同;修复后,12 次调用全部是同一个 404。ablation:撤掉修复后,pin 1/1 变红,服务端那组 4 个里有 1 个变红;恢复后 blob 与 HEAD 一致,dist 标记已清除。
  3. 门禁:本地派生 93 项全部 exit 0。plugin-approvals 全套 1380 个测试通过。完整 dogfood 只有 1 处陈旧期望没过,已在 7f95ac12a 改为指向新的 404。本 head CI 共 54 项:40 success、14 skipped、0 失败。
  4. 契约面:没有碰契约四面,Clause-②: no。按裁决 5985885287 做了存在性边界的达档复核 6568970094,结论 PASS:各入口都已覆盖,拒绝发生在任何副作用之前,回答逐字节一致,规则只有一份定义;逐类核对调用方,没有哪一类被新放行。
  5. 范围外去向:should-fix 1 的 pin(提交人一侧)和 should-fix 2(dogfood 文件头的引用路径)并入后续 ④ 一起做(同一块代码)。无提交人请求的撤回/催办守卫也是 ④,按披露裁决私下交给车道。
  6. 落地:不属于受管面,795 行,低于 3000 行上限 → pr_ready + automerge_enable。

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 10:36
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 10:36
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 1a42b37 Nov 2, 2026
59 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/harden-approval-decision-visibility branch November 2, 2026 11:29
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 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