Repository navigation
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
Conversation
…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>
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
|
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk Contract reviewServed-tier: ① Derived judgmentsDoor census (own enumeration, head tree). Service doors that act on one request:
② Semver level
③ Boundary flags
④ Design judgmentAgainst 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 Must-fix: none. Should-fix:
Implemented-by: VERDICT: PASS |
|
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk ACCEPT → head 维护者速读
席位意见:收。本 PR 落实 W3-RT 的私下安全报告。维护者在 PM 会话中批准了推送(「我同意推送,你应该使用pm-dispatch 技能审核和合并pr」),并分别接受了审批人一侧(「一个连带的行为变化,我接受」)和提交人一侧(「同意」)的行为变化。 席位核对
Generated by Claude Code |
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk
Clause-②: no
Metadata-protocol: none
维护者速读
404 REQUEST_NOT_FOUND,且不写任何数据。规则只有一份:从读取路径抽出为requestRowForCaller,读取与动手两边共用。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.
loadRequestintorequestRowForCaller.getRequestserves by it exactly as before.decideNode(and sodecide, theapprove/rejectdoors),recall,resubmit, andloadPendingRow(sendBack,reassign,remind,requestInfo,comment) callrefuseUnreadableRequestright 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 answeredREQUEST_NOT_FOUND: ID— the very error an id that names no request gets, built from the id the caller sent — and nothing is written.issueActionTokens, which mints for a slot on behalf of no reader, reads as the system.403 FORBIDDENfor a reader who may not take the action,409 INVALID_STATEfor a request no longer in the state the action needs.content/docs/automation/approvals.mdxstate the rule.Clause-② is
noand the changeset ispatch: 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
packages/qa/dogfood/test/approval-act-doors-read-visibility.dogfood.test.ts, on a real boot (realSecurityPlugin, 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:approvewith and without decision outputs), on a pending request and on a decided one, and the row and its history are unchanged. Controls: the submitter is403 FORBIDDENon the approver's verbs and409 INVALID_STATEonresubmitof a pending request and on a decided one; the approver is403 FORBIDDENonrecallandremind, and still approves (200); the administrator, on no slate, approves through the override (200) and is told404for an unknown id.approval-service.test.tsgains a block of four: every act door answers a non-participant and another organization's administratorREQUEST_NOT_FOUND: ID, byte for byte the missing-id answer, and writes nothing (pending and decided); a reader keeps the door's ownFORBIDDEN/INVALID_STATEand 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/mainmerged atbdb19ca6d(7fe58dddb, one append-only conflict inapproval-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/.objectstackremoved, the dogfood dependency closure rebuilt; the built@objectstack/plugin-approvalscarries 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)
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 inrefuseUnreadableRequestreplaced by an unconditional return carrying a marker. Anchorif (await this.requestRowForCaller(requestId, context, true)) return;hit 1 → 0; blobbe0dd52a6d60→fa2f535376e2; marker count in the source 1. Rebuilt;ablation-dist-preflightfound the marker in 2 built files.approveno longer answers what the missing id gets);Restore: blob back to the
HEADblobbe0dd52a6d60(checked againstgit rev-parse HEAD:path),git diff HEADempty, whole-treegit status --porcelainempty; rebuilt;ablation-dist-preflight --absent: marker absent from all 6 built files, tree clean; the helper back indist/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.tsexpected the door's own refusal for a bystander the read door already hides the request from (it asserts that404two lines earlier).7f95ac12aupdates that expectation; the file and the new pin are 2/2 on7f95ac12a. The rest of the battery was not re-run:7f95ac12achanges 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: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 theposition-address-readersdogfood file). Where a pin's subject is the door's own refusal — the decision slot test, the submitter-onlyresubmitguard, the localized non-submitterrecallsentence, 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$oras the real engine does. A legacy request row inserted by hand inapproval-list-display-labelsgets 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 them404 REQUEST_NOT_FOUNDwith nothing written, where the decision used to be recorded and the call then failed500 READ_BACK_FAILEDon 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,resubmitandcommentnow answer404 REQUEST_NOT_FOUNDwith 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 ondecide(through the platform administrator's override) and oncomment(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/:idand/actions(already one answer for both ids), the operator verbcontinueRestoredRun(no door, no caller context), the action-link redemption (a system context),issueActionTokens(reads as the system, stated in place).Generated by Claude Code