Repository navigation
fix(plugin-approvals): the generic data door serves sys_approval_request rows by the approvals door's visibility (#22559) - #22587
Conversation
…est rows by the approvals door's visibility A read gate on sys_approval_request (find, findOne, count, aggregate) narrows each read to the request ids ApprovalService.visibleRequestIdsFor answers for the caller: the approvals door's own visibleRequestIds, so the participant set, the override actor and the default-off record-reader tier are one rule on both doors. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
… read gate Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
… through a fuse Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…' approvers The generic door now serves a request only to whom the approvals door does, so the guard's fixture readers hold a pending-approver slot on the rows they query; what the file measures stays the guard's answer. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…proval-request-read-gate
📓 Docs Drift CheckThis PR changes 1 package(s): 44 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 df966dc37bf6030365cd91e2452b4533574a1579 && git checkout df966dc37bf6030365cd91e2452b4533574a1579
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 99801d831faa07fa374cb21acc30a469fa6aa6e1 a11d87ccaa9f3e738233185afdb168f99162308e && git checkout -B drift-repro 99801d831faa07fa374cb21acc30a469fa6aa6e1 && git merge --no-ff a11d87ccaa9f3e738233185afdb168f99162308e
node scripts/docs-audit/affected-docs.mjs --json 99801d831faa07fa374cb21acc30a469fa6aa6e1
|
…ibility in-package, the published ApprovalService type unchanged The visibility source moves off the class into a module-scoped WeakMap the constructor fills, reached through requestVisibilitySourceOf, which the package entry does not re-export; the by-id row read becomes a module function. ApprovalService's member list equals origin/main's. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…proval-request-read-gate
…uest's record page makes Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22559
Clause-②: no
What this does
A read of
sys_approval_requestthrough the generic data door (find,findOne,count,aggregateon the engine, soGET /api/v1/data/sys_approval_request, the by-id route,POST .../query, the export route, the MCP data tool and every other engine read) now returns only the requests the approvals door (/api/v1/approvals/*) serves the same caller. Before this, once a deployment granted read on the object, that door served every request of the organization, including the payload snapshots of records the caller cannot open. The default (no grant,403) is unchanged.The rule is #8652's, applied as ruled: participants (submitter, current approver, past actor) and the override actor see a request; a reader of the record it is about sees it read-only, only on an object the deployment opted in (
recordReaderVisibleObjects, default OFF). The declarative opt-in stays #22560's.packages/plugins/plugin-approvals/src/request-read-gate.tssys_approval_requestpackages/plugins/plugin-approvals/src/approval-service.tsvisibleRequestIdsFor(context, target), the approvals door's ownvisibleRequestIdsmade reachable;findRequestRow, the one by-id row readloadRequestand the gate share; the record-reader tier never anchors onsys_approval_requestitselfpackages/plugins/plugin-approvals/src/approvals-plugin.tspackages/plugins/plugin-approvals/src/payload-predicate-guard.tspinnedSubjectObjectgeneralised topinnedEquality(where, column), the one pin reader both seams use; behaviour of the guard unchangedpackages/plugins/plugin-approvals/src/sys-approval-request.object.tspackages/plugins/plugin-approvals/src/request-read-gate.integration.test.tspackages/plugins/plugin-approvals/src/payload-predicate-guard.test.ts.changeset/22559-approval-request-read-gate.mdpatchWhich definition the gate consumes, and why
Hypothesis 1 held. The one definition is
ApprovalService.visibleRequestIds:nullfor the override actor (a system context included), otherwise the participant set with the default-OFF record-reader tier (addRecordReaderVisibleIds) applied strictly on top. It was private, so the service gains one public method,visibleRequestIdsFor(context, target), which derives the organization the waylistRequests/loadRequestdo and calls it. The gate decides nothing itself: it reads which record the query names, asks the service, and ANDs anidconstraint built from the ids the service returned. No second visibility rule exists.Hypothesis 2 held; I used the approvals door's tier, not
resolveReadableParentIds. That function lives inplugin-audit(comment-access-hooks.ts), is not exported from its index, andplugin-approvalsdoes not depend onplugin-audit(the claim also excludes its source). The approvals door's tier already answers the same anchor the same way: one engine read of the record AS THE CALLER, so the record's own CRUD, sharing and RLS decide. Consuming it keeps one definition for both doors. Triage's "not thesys_activityshape" is pinned: switching the tier on for every object (ablation B) reddens exactly the tier-OFF reader pin.Hypothesis 3: measured, and the target is read off the query. The data door's list arrives at the middleware with its filter already lowered into
ast.where: implicit field filters (?object_name=…&record_id=…) and the filter-array spelling both arrive as conjunctive equalities, pinned in the tests through the realObjectStackProtocolImplementation.findData. The gate maps the query to the approvals door's own read shapes:object_nameandrecord_idboth pinned (a record page's related list) islistRequests({ object, recordId });idpinned (GET /data/sys_approval_request/ID) is a request loaded by id, which carries its own anchor (loadRequest); the row is read through the samefindRequestRowloadRequestuses;$or/$notnames nothing, so it reads untargeted: narrower, never wider.Hypothesis 4 held. Every read the service makes of
sys_approval_requestruns as the system, and the shared definition answers a system contextnullbefore any read, so the gate adds nothing to them and never re-enters itself through them. One re-entry path did exist: the tier's anchor read runs as the caller, so with the opt-in namingsys_approval_requestitself it would pass through the gate, which asks about the row that one names, unbounded on a cycle (measured: worker out of memory, see ablation C). The tier now never anchors onsys_approval_request; a request is no business record.Hypothesis 5 held. The override actor gets
null, so Setup → Approvals → Requests for an administrator is unnarrowed (pinned: list, count and by id).How it composes with the attached-on-read
viewerblock (#22479)vieweris attached by the approvals door only (attachViewers, onlistRequests/getRequest). The gate decides which rows the data door serves; it never attachesviewer. So a row served on the data door carries noviewer, every decision action'svisiblepredicate fails closed there, and a record reader the tier admits gets the row read-only on both doors: on the approvals door itsvieweris{ can_act: false, can_override: false, is_submitter: false }. Both halves are pinned. The other seams on this object are unchanged and compose: the snapshot query guard refuses before the gate runs, and the snapshot redaction narrows what a served row'spayload_jsonholds.Fail closed
404 RECORD_NOT_FOUND(the door's real answer, measured throughgetData). No existence oracle.totaland a grouped count are narrowed like its rows.disableAutoHooks(that switch is documented as the record lock's). An engine with no middleware seam gets one loud warning at boot.Tests and ablations
Rig: a real ObjectQL engine over better-sqlite3, the real
ApprovalsServicePlugin.start()(so an unbound gate fails like an unwritten one), requests opened by the realopenNodeRequest, and the real data-door normalizer. The one stand-in is record readability, one middleware on the business object. The subject resolves throughsrc/: the test imports the plugin by relative path, so nodist/sits on its resolution path and the ablations needed no rebuild.Pins (14): a non-participant who cannot read the record sees no request in a list, a record's list, a count or a grouped count; by id, a hidden request answers as a missing one; a reader of the record sees nothing while the tier is off; a participant sees their own requests and no one else's; control: an administrator and the system are unchanged; for every caller, the data door's ids equal the approvals door's, tier off and on; with the tier on, a reader of the record sees its request on a read naming the record (both spellings), read-only, and nothing on an untargeted read or on a record it cannot read; a failed answer denies; a request about a request never re-enters the gate.
Each ablation was committed-first, landed through
scripts/ablation-replace.mjs(anchor hit counted, blob changed), and restored to the HEAD blob withgit diff HEADempty:approvals-plugin.tsexpected { ids: [ …(2) ], total: 2 } to deeply equal { ids: [], total: +0 }a reader of the record who participates in nothing sees nothing while the tier is off,expected { …(2) } to deeply equal { ids: [], total: +0 }. Parity stays green, as it must: both doors loosen togetherexpected 9 to be 1(re-entry depth). Without the fuse, the first run of this pin held the lock 7m56s and ended in a worker out-of-memory crash, which is why the pin reads depth through a fusea visibility answer that fails denies the read, and says soFixture re-judged.
payload-predicate-guard.test.tsqueried the generic door as three readers who participated in no request and asserted they were served rows "as before". After this change they are served nothing, so 7 of its pins went red for a reason outside what that file measures. Its readers are now the requests' current approvers (pending-approver index rows), so the rows they query are rows they may read and the file still measures the snapshot guard alone; its refusal pins were green throughout.Verification (head
2a7dfed452, which mergesorigin/maind6c37919c7)pnpm turbo run build --filter='@objectstack/plugin-approvals...' --concurrency=1: 32 tasks, exit 0.pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2: 65 files, 930 passed, exit 0. The new file alone: 14 passed.pnpm --filter @objectstack/plugin-approvals typecheck: exit 0 (tscbuild program, scripts program, andcheck:test-typecheck: the new test file adds no error; the ledger holds 8 files / 324 errors, unchanged)..tsfiles,--no-inline-config --format json: 7 files, 0 errors, 0 warnings. Both new files are inside the linted population (isPathIgnoredfalse, read fromeslint.config.mjs), and the config enables no type-aware linting (noparserOptions.project/projectService), so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.approval-snapshot-masked-field,approval-snapshot-credential-field,activity-text-predicate,audit-log-admin-search). They resolve this package throughdist/. Read through: their readers ofsys_approval_requestare its approvers (position holders), the platform administrator, or the system, so the gate admits each of them; the snapshot-guard refusals they pin happen before the gate runs.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat2a7dfed452derives 67 commands; all 67 ran and answered exit 0, and--ranreconciles:67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. Two answered3(PREREQUISITE NOT MET) in that run and were re-run once their prerequisite was built:pnpm check:i18n(its build closure, all cached) andpnpm check:dual-build-cjs-loads(a fullturbo run build, 72 tasks). On an earlier headcheck-plugin-teardown-shape.mjs --self-testanswered3because its pinned fixture commit was outside the shallow clone; aftergit fetch --unshallow origin mainit answers0. One went red on an earlier head and was fixed:pnpm check:query-options-erasure(the new test's engine options are now typed, not cast). The system-context census and the tenant-audit census are green with no regeneration: the gate reads no system flag of its own (the shared definition answers a system context), and the by-id row read moved intofindRequestRowrather than being added.Acceptance notes
sys_approval_actionandsys_approval_approverdeclareenable.apiMethods: ['get', 'list']likesys_approval_request, and this package binds read middleware onsys_approval_requestonly. Under an app grant on either, the generic door is not narrowed to the approvals door's visibility (listActionsgates throughloadRequest). Not measured through HTTP here.disableAutoHooks: truealso switches off the two snapshot seams on the generic door (the redaction and the predicate guard). This gate does not follow it. No production caller passes the option (git grep: tests only). Noted, not filed.disableService: trueregisters the five objects and binds no generic-door seam at all. Its one caller ispackages/cli/scripts/check-app-nav-i18n.mjs, which reads navigation only. Noted, not filed.sys_approval_requeston the approvals door either. Only a deployment that names that object in the option sees a difference, and it is narrower.origin/mainmoved after the merge (25be87612d); not merged again, the PR's merge ref covers it.Seat's append: rework round 1 (head
a11d87ccaa)Appended by
domain:servicesseat 1 (session_013j5gkUCpqQiti4GgPqqmnt) at 2026-10-10T03:41Z, from the dev's round-1 report (6093383810on #22559). Where the sentences above namevisibleRequestIdsFor, orfindRequestRowas a service member, this section supersedes them.ApprovalService.visibleRequestIdsForwidened the published class. It is gone.WeakMapthat theApprovalServiceconstructor fills with an answer calling the class's own privatevisibleRequestIds.requestVisibilitySourceOf(service), whichapproval-service.tsexports andsrc/index.tsdoes not re-export (git diff origin/main -- packages/plugins/plugin-approvals/src/index.ts: 0 bytes).findRequestRowis a module function, the one by-id row readloadRequestand the gate share.ApprovalService's source member list equalsorigin/main's (106 members). The builtdist/index.d.tscarries novisibleRequestIdsFor,requestVisibilitySourceOforfindRequestRow(positive control:listRequests(1).approval-service.ts, superseded: the gate's visibility source, in-package only (above);findRequestRow, a module function; the record-reader tier never anchors onsys_approval_requestitself.origin/main99801d831f(merge0522d7256e, clean). That brings feat(plugin-approvals): serve the approval request page through the manifest pages, and declare the thread reply as approval_comment (#22473) #22567 (the slottedsys_approval_requestrecord page) and fix(plugin-approvals): a manager approver whose owner has no manager adds no slot, instead ofmanager:undefined#22583, in regions disjoint from this PR's.getRequestdoes.404 RECORD_NOT_FOUND.sys_approval_action, which this PR does not gate. That is the sibling-tables card plugin-approvals:sys_approval_action(the decision log) andsys_approval_approver(the approver index) are served on the generic data door with no read narrowing, the sibling half of #22559 #22589.--ranreports 67 derived, 67 run, 0 NOT-MEASURED.a11d87ccaa: 31 success, 3 rostered skips.Clause-②: nostands. The surface is unchanged.Generated by Claude Code