You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
plugin-approvals: the approver a step waits on gets no read of the record it is about — "无法打开此记录" on the request they must decide #26180
Filing class: ① product defect — reach: public door, measured in a browser on 18.1.0 with objectstack-ai/hotclm main (pnpm demo:zh, personas made the README way). Needs a maintainer reading because it widens who reads a record. Reader: the approvals lane (plugin-approvals + the security read path), then objectui's request page.
Business reason
An approver is asked to decide on a record. Unless the app happens to grant them read of that exact row, they decide from the request's snapshot fields only — for a contract that means approving without opening the document, the clauses or the legal review. Every app with approvals pays this, and each would otherwise hand-write sharing rules that mirror the approval routing (a second copy of the slate the approvals service already resolves).
Measured (hotclm)
Business Requester 2 launches a 250,000 order; the ladder opens rung 1 = their manager (Executive, holds only the default member set), rung 2 = Finance Controller.
Executive: 待我处理 lists it; the request page shows the snapshot and the record card reads 「无法打开此记录」; /apps/clm/clm_contract/record/<id> answers 「未找到记录」. Executive approves anyway.
Finance Controller: the same while the contract is in_approval; after approval the record opens (the app's sharing rule starts at approved).
The rung-1 case is structural on the open edition: the app's own_and_reports read scope fails closed to owner-only without the hierarchy resolver.
Why
The approvals service resolves the concrete slate but grants nothing; the Console hides the link when the approver cannot read the record (objectui#11878) rather than letting them read it.
Expected (for the maintainer to confirm)
While a step is pending, each approver on its slate can READ the record it is about (no edit, no children beyond what the record page reads for a reader), revoked when the step closes — or a declared flow/step switch that turns this on per approval process. The app then declares nothing.
For the maintainer: it widens who reads a record, a security boundary on the human floor. Today an approver decides from the request's snapshot alone unless the app happens to grant them read of that row.
Options:
A, scoped read for the pending approver (triage leaning). While a request is pending and the user is on the current step's slate (or holds the override), they get read of that one record through the approval, with no write. It ends when the step resolves, and explain shows it as "via approval request N". Workload: M (security read path, approvals), plus a contract review.
B, keep it the app's job. Document that an approver needs read. Zero code, but every app with approvals keeps paying.
C, a richer snapshot. The request carries more fields. That is no read widening, but it is a copy of the data that drifts, and attachments are still out of reach.
Lean: A. It is the narrowest grant that lets an approver see what they approve, bounded by the slate and the step.
Director seat, summon #51 continuation (ledger objectstack#12708). Presented to the maintainer in batch #402 as item 1 of 2 (the two approvals cards filed from the hotclm browser regression thread). Seat increment (re-verified on origin/maincdd4eea9; the card and the triage leaning 6420598906 / 6420611091 are not restated):
The approvals service already asks one readability question, but a different one: filterApproversWhoCanRead (approver-org-scope.ts:221, ADR-0105 D9) drops approvers who could not read the request row across organizations; nothing grants or checks read of the record the request is about. The card's premise stands: the slate is resolved, the record stays behind the app's own sharing, and the console hides the link rather than opening it (RecordPreviewCard.tsx:59–73, objectui#11878).
git grep -iE 'approver.{0,60}read' on AGENTS.md + docs/adr → ADR-0105 D9 (request reads, membership union) and ADR-0142 (slate as a recipient kind); no ADR grants a record read through an approval. 【能力需求】plugin-approvals 审批请求可见性:对业务记录有读权者应可只读查看审批动态(或提供可见性 hook) #8652 (readers see requests when approvalsVisibleToReaders is on) is the inverse direction and untouched by A.
Half-state read, not written: needs-user-decision + pm:awaiting-maintainer together; the second leaves in the ruling's stroke.
Facets (the card has no block; seat's reading): ① A is the platform mechanism (the slate the service already resolves becomes a read scope, bounded by step and time), where B makes every app hand-write sharing rules that mirror routing and C copies data into the request; ② measured on hotclm by two personas; structural for every approver outside the record's sharing, and on the open edition for every own_and_reports manager (fails closed without the hierarchy resolver); ③ A is one grant computed by the server and shown in explain as "via approval request N"; B's app-side mirror drifts silently from the slate; ④ A adds no key — the grant is derived from state that exists — and no gate; attachments reach the approver only as the record page's reader reads them (ADR-0104 download authz inherits the parent read).
The seat agrees with A with its bounds stated for the ruling: read only, no edit, no children beyond what the record page reads for a reader; only while the step is pending and only for the resolved persons on the current slate (or the override holder); revoked when the step resolves; explain names the request. Workload (card column): M in the security read path + approvals, contract-review tier; Clause-②: no unless the grant is exposed as a declarable switch (then yes (widening), Metadata-protocol: added).
Prior rulings read: filterApproversWhoCanRead · ADR-0105 D9 · #8652 · objectui#11878 → the request-read filter, the readers-see-requests ruling, the console's hidden link; thread: 6420598906.
Ruling: batch #402 item 1 · letter A · maintainer 「同意」 2026-10-26T22:59Z
Recorded by the director seat (summon #51 continuation, session_01KrKXQVvgYk2AJ4VkqMobyb, ledger objectstack#12708). The maintainer answered batch #402 with 「同意」 — both seat letters. Brief 6423115398. Letter A, a scoped read for the pending approver, with its bounds settled in this stroke: while an approval request is pending, each resolved person on the current step's slate (and the override holder) may read the one record the request is about — no write, no children beyond what the record page reads for a reader (attachments only as ADR-0104's download authorization inherits the parent read); the grant exists only while the step is pending and ends when the step resolves; explain names it ("via approval request N"). The grant is derived from the slate the approvals service already resolves — no new metadata key, no app declaration; #8652 (readers see requests when approvalsVisibleToReaders is on) is the inverse direction and untouched. Not B (every app hand-writes sharing rules that mirror routing and drift from it silently) and not C (a copy of the data in the request, attachments still out of reach).
Premises the dev verifies first (re-check in the card and the brief): filterApproversWhoCanRead (approver-org-scope.ts:221) filters on request-row readability across organizations and grants nothing on the record; the console hides the record link when the read fails (RecordPreviewCard.tsx:59–73, objectui#11878); no approval-based read exists in plugin-sharing / plugin-security today. A premise failing ⇒ a fork, ⛔ no forcing.
Workload of the chosen letter (card column): domain:server M — the security read path consults the approvals service for the caller's pending-slate records (one read scope, time-bounded), the explain layer, integration tests for a slate member / a non-member / a member after the step resolved, and a contract review (security boundary). Clause-②: no (no authorable key; the grant is derived), Metadata-protocol: none; if the dev finds a declarable switch necessary, it comes back as a fork, not as a silent key.
Execution: needs-user-decision → pm:queue in this act (domain:server, p2, security, area:workflow); pm:awaiting-maintainer removed in the same stroke. After it lands the console's hidden-link rule needs no change (the read now succeeds); a ui sub-card only if the request page needs one. objectstack-ai/hotclm#134 (its option B) unblocks on a published release. Ruling-ref: names this comment.
Claim: PM loop round 8 · card thread
Session: session_01R1q2xdtYgU6FKrZ7LgjPCC (the seat); the card thread is session_016NZtJpJqVWVJA1XDU9dbnz
Account: os-project-manager (the seat's linked user; the card's assignee)
Branch: claude/project-thread-crp41o (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
Worktree: the card thread's own container checkout
Domain: domain:server
Seat: domain:server#1
Ruling-ref: 6429330267 (batch #402 item 1, letter A, maintainer 「同意」 2026-10-26T22:59Z): while an approval request is pending, each resolved person on the current step's slate (and the override holder) may read the one record the request is about. It is read only, ends when the step resolves, never crosses an organization, and explain names it ("via approval request N"). The grant is derived from the resolved slate: no new metadata key. Security card on the maintainer's word: no advisory, no new issue.
File surface: packages/plugins/plugin-approvals/**, the read-grant seam in packages/plugins/plugin-security/** or packages/plugins/plugin-sharing/** that the dev measures as the right home, integration tests, and a patch changeset. ⛔ No ui/** (a request-page change goes to a ui sub-card the seat files). ⛔ No packages/spec/** and no new authorable key (a needed switch is a needs_decision fork).
Container & model: S, mode:thread, default model and effort (set at thread open)
Clause-②: no — no authorable key; the grant is derived from the slate. The dev measures it on the diff. The seat runs a CONTRACT_REVIEW_TIER review before landing (security boundary).
Metadata-protocol: none
Responsibility: an approver on a pending step cannot open the record they are asked to approve unless they already read it (triage 6420598906)
Thread-read: 6429330267
Premise: holds (read 2026-10-27T00:46Z at origin/main6390fe52ce: card open, unassigned, pm:queue, no claim; the order lists three code premises the dev verifies before any edit)
Serial constraints cleared: #26181 (seat 1, same round) also works in packages/plugins/plugin-approvals/. Each dev re-measures open PRs on the files it touches and runs git merge-tree against any overlap before pr_create.
os-dev-report
{
"issue": 26180,
"status": "done",
"branch": "claude/project-thread-crp41o",
"pr": "#26397",
"session": "session_016NZtJpJqVWVJA1XDU9dbnz",
"premise_still_valid": true,
"summary": "Ruling 6429330267 letter A. plugin-approvals derives, from the pending-approver index it already keeps, the records a caller may read because a pending step waits on them: persons on the current slate (user id or account email via actingAddresses, no position/team literals) and the override holder (isOverrideActor with the active org). Both probes are fenced to the caller organization, re-read status pending, and are memoized per execution context and engine write epoch (one sys_user + one sys_approval_approver read per request, one sys_approval_request read per object, never per row; only settled answers held). plugin-sharing ORs the ids into the read filter beside record shares and fails closed (no service, throw, rejected read, on-behalf-of principal: nothing); write gates never read it. plugin-security explain attributes it as record_share via "approval request N" (admits on read, neutral on writes). No metadata key, no app declaration, no ui/**, #8652 untouched.",
"tests": "Red on main: five source files reverted to base 6390fe5 under a HEAD-restore trap, three plugins rebuilt, ablation-dist-preflight --absent confirmed; dogfood approval-pending-approver-record-read failed at line 183 expected 404 to be 200; restore proved by empty git diff HEAD, dist rebuilt with presence preflight. Green on e8fb0d3: dogfood file 1 passed (member 200 + list, stranger 404, PATCH 403 nothing landed, explain names approval request N, overrider 200, advance revokes first and grants second, final approve and reject revoke). plugin-approvals Test Files 100/100, Tests 1286/1286 (lock VERDICT command-exit 0), incl. new pending-approver-read-grant.integration.test.ts (person by id/email, position literal none, override holder, decided requests none on same context, org wall incl platform admin and org-less caller, read-cost pin, rejected read not memoized). plugin-sharing 1240/1240 and plugin-security 4962 passed + 45 skipped at 1639ac9 (unchanged since). Typecheck of the three plugins exit 0. Repo-level census projects core 83, objectql 5, rest 280+18 skipped, runtime 947, spec 1279, types 11, all exit 0. eslint --no-inline-config on the three plugins + dogfood file exit 0.",
"gates": "Local subset on e8fb0d3: 74 commands from dispatch-gates --commands --repo objectstack-ai/objectstack, each timeout 900, exit captured before any pipe: 73 exit 0; pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (no dist for untouched packages studio, client-react, embedder-openai, knowledge-memory and others) = NOT MEASURED, CI builds them. --ran reconciliation exit 0: 74 derived, 73 run, 1 NOT-MEASURED, 0 UNRUN. Earlier heads tripped check-system-context-census (isSystem read), check-tenant-audit-census (Map.delete read as an engine write) and approver-address-readers (unclassified slot site); all fixed in-diff. CI check-runs on PR 26397: in_progress at report time.",
"dogfood_battery": "n/a: no ! in the PR title and no (narrowing) arm; the new dogfood file was run on the final head (1 passed). Dogfood Regression Gate does not run on PRs.",
"mcp_calls": "1: mcp__github__search_pull_requests (read, open-PR overlap census). No MCP writes.",
"api_writes": "2 relay strokes beside git push: fleet-write pr_create (POST /repos/objectstack-ai/objectstack/pulls + POST /issues/26397/assignees, run 40435451999, body read back identical 8822 bytes) and this report comment (post-stamped.mjs, POST /issues/26180/comments). Each relay stroke is one POST /repos/objectstack-ai/objectstack/dispatches. git push: 5 commits on the harness branch.",
"open_questions": [],
"out_of_scope_findings": [
"overlap, not a finding: open PR 26337 (ownership queues, ADR-0142) edits the same insertion points in plugin-sharing sharing-service.ts and sharing-plugin.ts and plugin-security explain-engine.ts; git merge-tree HEAD vs its head reports content conflicts in those three files, all adjacent inserts. Whichever lands second keeps both (queue disjunct plus approval ids in the id $in arm). carrier: seat 1 · noted, not filed",
"explain uses kind record_share for the approval grant because a dedicated kind is a spec enum + ui label edit outside this card. carrier: none · noted, not filed"
]
}
Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e8fb0d3bb12fbb202af1ba9789227e8585cb055c
Local-runs: none
Scope: packages/plugins/plugin-approvals/src/pending-approver-read-grant.ts, packages/plugins/plugin-approvals/src/approval-service.ts, packages/plugins/plugin-sharing/src/sharing-service.ts, packages/plugins/plugin-sharing/src/sharing-plugin.ts, packages/plugins/plugin-security/src/explain-engine.ts, packages/plugins/plugin-security/src/security-plugin-explain-access.ts, packages/plugins/plugin-approvals/src/pending-approver-read-grant.integration.test.ts, packages/plugins/plugin-approvals/src/approver-address-readers.test.ts, packages/plugins/plugin-sharing/src/sharing-service.test.ts, packages/plugins/plugin-security/src/explain-engine.test.ts, packages/qa/dogfood/test/approval-pending-approver-record-read.dogfood.test.ts, .changeset/26180-pending-approver-record-read.md + packages/plugins/plugin-approvals/src/approver-address.ts (actingAddresses, slateNamesAPerson, heldSlot), packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts, packages/plugins/plugin-approvals/src/approval-service.ts at the head (isOverrideActor, callerAccountEmail, syncApproverIndex, findRequestRow, REQUEST_VISIBILITY), packages/plugins/plugin-sharing/src/sharing-plugin.ts (buildSharingMiddleware, the read and write legs), packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts (resolveSharingReadFilter), packages/plugins/plugin-security/src/security-plugin.ts (computeControlledByParentFilter), packages/plugins/plugin-approvals/src/approver-org-scope.ts (filterApproversWhoCanRead, the premise), docs/adr/0105 D9, docs/adr/0090 D10
Judged as a security-boundary change against ruling 6429330267 (letter A) and its bounds, on the diff origin/main...pr-26397 (12 files, +1049/−2) and the cited code at the head. Gate verdicts on this head as read at review time: 20 check-runs concluded success (Build/Lint/Type Check source gates, ESLint, Check Changeset, Check PR Size, Governed Surface Queue Guard, the three claim guards, Spec property liveness, Dogfood Regression Gate, Flag docs, Auto Label, filter); 20 still in_progress (Test Core 1–8, Build Core, Type Check workspace/consumer/debt, Lint CI/engine, query-options erasure, slot lookup, docs guards, repo gates); none failed; 11 skipped (UI, docs, opt-in harnesses). The verdict below is on the diff; the seat re-reads the conclusions before landing.
① Derived judgments
Accept-set changes the diff implies, each named right or wrong against the ruling:
Record read (findOne), list (find), count, aggregate — RIGHT.buildSharingMiddleware composes SharingService.buildReadFilter for exactly these four verbs (sharing-plugin.ts, the READS leg); the filter ORs { id: { $in: approvalIds } } into the existing record-share arm beside the owner-match. The ids are the record_id of sys_approval_request rows with object_name = object, status = 'pending', the organization fence, and (for a slate member) id IN the requests whose sys_approval_approver row names the caller. So the list door widens to the one record per pending request and nothing else; the dogfood test pins list membership (listIds(first) equals the two pending records) and stranger []. Any other read door that composes the sharing filter (export, query, the explain's own sharing probe) serves the same ids; a door that bypasses the sharing filter is pre-existing and unchanged by this diff.
No write path consults the grant — RIGHT. In the whole diff, pendingApproverReadGrants (sharing-side) is called from exactly two places: buildReadFilter and listApprovalReadGrants. canEdit, canDelete, buildWriteFilter and the middleware's write leg are untouched; sharing-service.test.ts pins canEdit/canDelete false and buildWriteFilter owner-only for a slate member who owns nothing; dogfood pins PATCH 403 with nothing landed. Explain attributes the rule effect: 'neutral' on an update and the layer outcome stays excluded.
Ends when the step resolves — RIGHT. Three independent bounds, each sufficient: (a) syncApproverIndex rewrites the request's sys_approval_approver rows on every slate change and clears them with [] on every path that leaves pending (decide, final approve, reject, recall, cancel, send-back, escalation rewrite); (b) the request row is re-read with status: 'pending'; (c) the memo is a WeakMap keyed on the execution-context object and invalidated by engine.writeEpoch.current, which ObjectQL bumps on every write, a decision among them. Dogfood pins approve-and-advance (first approver 404, second 200), final approve (second and overrider 404), reject (404); the integration test pins a decided request on the same context object answering {}.
Explain names it — RIGHT.explain-engine.ts pushes a record_share rule with via: "approval request N", grants: 'read', admits on read, neutral otherwise; the detail sentence names the request and the step-bound; security-plugin-explain-access.ts binds listApprovalReadGrants with the explained principal's context, so explain reads the same answer the filter ORed in (sharing-service.ts comment: "the two cannot disagree"). A throwing lookup attributes nothing (the call is wrapped in a catch that answers an empty list), pinned in explain-engine.test.ts.
Derived from the slate the service already resolves, no new metadata key — RIGHT. The source reads only sys_approval_approver (the index the inbox already queries) and sys_approval_request; no spec file, no object field, no flow/step key, no app setting, nothing under packages/spec/** or ui/**. The slate is the open-time, OOO-substituted pending_approvers CSV mirrored into the index, so the delegate on the slate is the "resolved person" and reads; the absent delegator is not on the slate and does not.
Override holder included — RIGHT, and bounded.isOverrideActor(context, callerOrg) (platform admin by posture/admin_full_access; tenant admin by posture/holdsTenantAdminRung) answers slate = null, and the request probe then drops the id predicate: every pending request of that object inside the caller's active organization. Dogfood pins organization_admin_no_bypass reading while pending and 404 after; the integration test pins the tenant admin's sweep.
Never across an organization (ADR-0105) — RIGHT, stricter than the neighbouring reader. Both probes carry organization_id: org or, for an org-less caller, { $null: true } ($null is a where operator the engine lowers; relation-filter-lowering.ts, no-operator-object-door.ts cite it). activeOrganization reads organizationId ?? tenantId, the same read isOverrideActor and REQUEST_VISIBILITY make. The platform admin's override crosses the wall to decide and not to read: pinned. By contrast findRequestRow with a null tenant imposes no fence at all, so the new path is the narrower of the two. ADR-0105 D9's cross-org-targeted approver holds membership in the request's organization (else dropped), and reads only while acting in that organization: consistent.
position:* / team:* / e-mail addresses on the slate — RIGHT.actingAddresses({ userId, email: accountEmail(userId), positions: [] }) yields only the caller's user id and the e-mail their own sys_user row carries (server-resolved, never client-supplied). A position:/team: literal matches no person, so a position holder who could DECIDE via heldSlot gets no read through it: narrower than the decide power, inside "each resolved person". An e-mail slot admits only the account carrying that e-mail. The new comparison site is classified reader in approver-address-readers.test.ts, so the equivalence gate sees it.
Delegation / on-behalf-of — RIGHT.SharingService.pendingApproverReadGrants answers none when context.onBehalfOf?.userId is set; the middleware's ADR-0090 D10 delegator leg does compute the delegator's grants, but it is ANDed with the agent leg (which has none), so an agent never reaches a row through the delegator's approval. OOO substitution is judged under item 5.
Other objects — RIGHT. The request probe is keyed object_name = object; a pending request on contract_sheet grants nothing on memo_sheet (pinned).
Children and attachments — RIGHT, within "what the record page reads for a reader". The diff touches no child, file or attachment code. A controlled_by_parent detail derives its scope from the master's own getReadFilter (ADR-0139 leaf, computeControlledByParentFilter), which composes the sharing filter, so a detail under an approval-granted master is readable exactly as under a record share, no more. ADR-0104's download inheritance is not re-read here; the diff adds no file door.
Fail closed — RIGHT. No approvals service, a getService throw, a probe with no member, a null answer, or a rejected read all answer NO_APPROVAL_GRANTS; a rejected read logs one warn and the read goes on owner-match plus shares; the read is never errored. The source side rethrows a failed read and never memoizes it (pinned). The probe is asked only after the OWD/depth/owner-field returns, so a null or deny-all filter never asks it (pinned).
One bounded query per read, never per row — RIGHT. Per context-and-epoch: one sys_user read, one indexed sys_approval_approver read (the approver, organization_id index), then one sys_approval_request read per object; a caller on no slate stops after the index read for every object. Both reads are capped at 10,000 with a warn when the window is hit (narrowing direction: missing grants, never extra). The cost pin counts the engine's find calls. Note: resolveSharingReadFilter spreads a fresh context object per call, so the memo misses there and the cost repeats per call; still bounded, never per row.
No caching that outlives a step — RIGHT, with one flag (③.3).WeakMap on the per-request context object plus the engine write epoch: a decision anywhere on the same engine retires every entry; nothing is held across requests.
Public surface — RIGHT, additive only.ApprovalService.pendingApproverReadGrants(object, context) (the module itself is not exported from the barrel), SharingService.listApprovalReadGrants, SharingServiceOptions.approvals? and the exported SharingApprovalReadProbe type, ExplainEngineDeps.listApprovalReadGrants? optional. Structural late-binding, no package dependency added, no spec interface changed. The premise (filterApproversWhoCanRead filters request-row readability and grants nothing on the record; no approval-based read existed in plugin-sharing/plugin-security) holds on origin/main.
Nothing wrong found; no widening beyond the ruling's bounds on any door.
② Semver level
.changeset/26180-pending-approver-record-read.md declares @objectstack/plugin-approvals, @objectstack/plugin-sharing, @objectstack/plugin-security at patch, with Clause-②: no and Metadata-protocol: none line-initial; the PR body carries the same two lines. The diff publishes additive runtime members only (item 15), no authorable key, no spec change, no breaking removal: none takes patch, matches. Check Changeset on this head: success. Clause-②: no is right: the grant is derived, not declared.
③ Boundary flags
Report 6431829080 open_questions: [] — nothing to answer.
Dev flag, overlap with feat!: ownership queues and assignment rules (ADR-0142 phase 2) #26337 (adjacent inserts in sharing-service.ts, sharing-plugin.ts, explain-engine.ts; git merge-tree content conflicts): not a finding against this diff; whichever lands second rebases and keeps both the queue disjunct and the approval ids. Seat watches the landing order.
Dev flag, explain reuses kind record_share with via: "approval request N": accepted; a dedicated kind is a spec enum plus a ui label, outside this card's file surface (⛔ no packages/spec/**). Escalated as a note for the seat, not a fork.
Reviewer flag, memo lifetime on a long-lived context: the write-epoch invalidation is per engine instance; a context object reused across many operations on a node whose engine saw no write would hold a grant that a step resolved on another node — the permissionSetMemo precedent carries the same property, HTTP contexts are per request, and the status re-read bounds any fresh computation. Noted, no action owed on this card.
Reviewer flag, product observation: a unanimous/quorum voter who has voted leaves the index while the step stays pending for others, so loses the read until the step closes — the literal reading of "each resolved person on the current step's slate", narrower than it could be. Acceptance note states it; the seat may raise it with the maintainer if hotclm reports it.
Org wall pinned at the service level only (dogfood boot is single): accepted, the service test covers member, platform admin and org-less caller.
CI partial at review time (20 in progress, 0 failed): the seat confirms every required check green before landing; this record does not stand in for a red.
Activity
objectstack-fleet commentedon Oct 26, 2026
Triage: first grade —
bug·security·priority:p2·domain:server·area:workflow·needs-user-decision·pm:awaiting-maintainer· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-26T15:55Z⛔ Not a claim, ⛔ not a dispatch.
explainshows it as "via approval request N". Workload: M (security read path, approvals), plus a contract review.Generated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
Brief-claimed-by: session_01KrKXQVvgYk2AJ4VkqMobyb · batch #402 · 2026-10-26T17:39Z
Director seat, summon #51 continuation (ledger objectstack#12708). Presented to the maintainer in batch #402 as item 1 of 2 (the two approvals cards filed from the hotclm browser regression thread). Seat increment (re-verified on
origin/maincdd4eea9; the card and the triage leaning 6420598906 / 6420611091 are not restated):filterApproversWhoCanRead(approver-org-scope.ts:221, ADR-0105 D9) drops approvers who could not read the request row across organizations; nothing grants or checks read of the record the request is about. The card's premise stands: the slate is resolved, the record stays behind the app's own sharing, and the console hides the link rather than opening it (RecordPreviewCard.tsx:59–73, objectui#11878).git grep -iE 'approver.{0,60}read'on AGENTS.md + docs/adr → ADR-0105 D9 (request reads, membership union) and ADR-0142 (slate as a recipient kind); no ADR grants a record read through an approval. 【能力需求】plugin-approvals 审批请求可见性:对业务记录有读权者应可只读查看审批动态(或提供可见性 hook) #8652 (readers see requests whenapprovalsVisibleToReadersis on) is the inverse direction and untouched by A.needs-user-decision+pm:awaiting-maintainertogether; the second leaves in the ruling's stroke.own_and_reportsmanager (fails closed without the hierarchy resolver); ③ A is one grant computed by the server and shown inexplainas "via approval request N"; B's app-side mirror drifts silently from the slate; ④ A adds no key — the grant is derived from state that exists — and no gate; attachments reach the approver only as the record page's reader reads them (ADR-0104 download authz inherits the parent read).explainnames the request. Workload (card column): M in the security read path + approvals, contract-review tier;Clause-②: nounless the grant is exposed as a declarable switch (thenyes (widening),Metadata-protocol: added).Prior rulings read:
filterApproversWhoCanRead·ADR-0105 D9·#8652·objectui#11878→ the request-read filter, the readers-see-requests ruling, the console's hidden link; thread: 6420598906.The finance approver can open the contract it is asked to approve (#153)
objectstack-fleet commentedon Oct 26, 2026
Ruling: batch #402 item 1 · letter A · maintainer 「同意」 2026-10-26T22:59Z
Recorded by the director seat (summon #51 continuation,
session_01KrKXQVvgYk2AJ4VkqMobyb, ledger objectstack#12708). The maintainer answered batch #402 with 「同意」 — both seat letters. Brief 6423115398. Letter A, a scoped read for the pending approver, with its bounds settled in this stroke: while an approval request is pending, each resolved person on the current step's slate (and the override holder) may read the one record the request is about — no write, no children beyond what the record page reads for a reader (attachments only as ADR-0104's download authorization inherits the parent read); the grant exists only while the step is pending and ends when the step resolves;explainnames it ("via approval request N"). The grant is derived from the slate the approvals service already resolves — no new metadata key, no app declaration; #8652 (readers see requests whenapprovalsVisibleToReadersis on) is the inverse direction and untouched. Not B (every app hand-writes sharing rules that mirror routing and drift from it silently) and not C (a copy of the data in the request, attachments still out of reach).Premises the dev verifies first (re-check in the card and the brief):
filterApproversWhoCanRead(approver-org-scope.ts:221) filters on request-row readability across organizations and grants nothing on the record; the console hides the record link when the read fails (RecordPreviewCard.tsx:59–73, objectui#11878); no approval-based read exists inplugin-sharing/plugin-securitytoday. A premise failing ⇒ a fork, ⛔ no forcing.Workload of the chosen letter (card column):
domain:serverM — the security read path consults the approvals service for the caller's pending-slate records (one read scope, time-bounded), the explain layer, integration tests for a slate member / a non-member / a member after the step resolved, and a contract review (security boundary).Clause-②: no(no authorable key; the grant is derived),Metadata-protocol: none; if the dev finds a declarable switch necessary, it comes back as a fork, not as a silent key.Execution:
needs-user-decision→pm:queuein this act (domain:server, p2,security,area:workflow);pm:awaiting-maintainerremoved in the same stroke. After it lands the console's hidden-link rule needs no change (the read now succeeds); a ui sub-card only if the request page needs one. objectstack-ai/hotclm#134 (its option B) unblocks on a published release.Ruling-ref:names this comment.objectstack-fleet commentedon Oct 27, 2026
Claim: PM loop round 8 · card thread
Session:
session_01R1q2xdtYgU6FKrZ7LgjPCC(the seat); the card thread issession_016NZtJpJqVWVJA1XDU9dbnzAccount:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-crp41o(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#1Ruling-ref:
6429330267(batch #402 item 1, letter A, maintainer 「同意」 2026-10-26T22:59Z): while an approval request is pending, each resolved person on the current step's slate (and the override holder) may read the one record the request is about. It is read only, ends when the step resolves, never crosses an organization, andexplainnames it ("via approval request N"). The grant is derived from the resolved slate: no new metadata key. Security card on the maintainer's word: no advisory, no new issue.File surface:
packages/plugins/plugin-approvals/**, the read-grant seam inpackages/plugins/plugin-security/**orpackages/plugins/plugin-sharing/**that the dev measures as the right home, integration tests, and apatchchangeset. ⛔ Noui/**(a request-page change goes to a ui sub-card the seat files). ⛔ Nopackages/spec/**and no new authorable key (a needed switch is aneeds_decisionfork).Container & model:
S,mode:thread, default model and effort (set at thread open)Clause-②: no — no authorable key; the grant is derived from the slate. The dev measures it on the diff. The seat runs a
CONTRACT_REVIEW_TIERreview before landing (security boundary).Metadata-protocol: none
Responsibility: an approver on a pending step cannot open the record they are asked to approve unless they already read it (triage
6420598906)Thread-read: 6429330267
Premise: holds (read 2026-10-27T00:46Z at
origin/main6390fe52ce: card open, unassigned,pm:queue, no claim; the order lists three code premises the dev verifies before any edit)Serial constraints cleared: #26181 (seat 1, same round) also works in
packages/plugins/plugin-approvals/. Each dev re-measures open PRs on the files it touches and runsgit merge-treeagainst any overlap beforepr_create.objectstack-fleet commentedon Oct 27, 2026
os-dev-report
{
"issue": 26180,
"status": "done",
"branch": "claude/project-thread-crp41o",
"pr": "#26397",
"session": "session_016NZtJpJqVWVJA1XDU9dbnz",
"premise_still_valid": true,
"summary": "Ruling 6429330267 letter A. plugin-approvals derives, from the pending-approver index it already keeps, the records a caller may read because a pending step waits on them: persons on the current slate (user id or account email via actingAddresses, no position/team literals) and the override holder (isOverrideActor with the active org). Both probes are fenced to the caller organization, re-read status pending, and are memoized per execution context and engine write epoch (one sys_user + one sys_approval_approver read per request, one sys_approval_request read per object, never per row; only settled answers held). plugin-sharing ORs the ids into the read filter beside record shares and fails closed (no service, throw, rejected read, on-behalf-of principal: nothing); write gates never read it. plugin-security explain attributes it as record_share via "approval request N" (admits on read, neutral on writes). No metadata key, no app declaration, no ui/**, #8652 untouched.",
"tests": "Red on main: five source files reverted to base 6390fe5 under a HEAD-restore trap, three plugins rebuilt, ablation-dist-preflight --absent confirmed; dogfood approval-pending-approver-record-read failed at line 183 expected 404 to be 200; restore proved by empty git diff HEAD, dist rebuilt with presence preflight. Green on e8fb0d3: dogfood file 1 passed (member 200 + list, stranger 404, PATCH 403 nothing landed, explain names approval request N, overrider 200, advance revokes first and grants second, final approve and reject revoke). plugin-approvals Test Files 100/100, Tests 1286/1286 (lock VERDICT command-exit 0), incl. new pending-approver-read-grant.integration.test.ts (person by id/email, position literal none, override holder, decided requests none on same context, org wall incl platform admin and org-less caller, read-cost pin, rejected read not memoized). plugin-sharing 1240/1240 and plugin-security 4962 passed + 45 skipped at 1639ac9 (unchanged since). Typecheck of the three plugins exit 0. Repo-level census projects core 83, objectql 5, rest 280+18 skipped, runtime 947, spec 1279, types 11, all exit 0. eslint --no-inline-config on the three plugins + dogfood file exit 0.",
"gates": "Local subset on e8fb0d3: 74 commands from dispatch-gates --commands --repo objectstack-ai/objectstack, each timeout 900, exit captured before any pipe: 73 exit 0; pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (no dist for untouched packages studio, client-react, embedder-openai, knowledge-memory and others) = NOT MEASURED, CI builds them. --ran reconciliation exit 0: 74 derived, 73 run, 1 NOT-MEASURED, 0 UNRUN. Earlier heads tripped check-system-context-census (isSystem read), check-tenant-audit-census (Map.delete read as an engine write) and approver-address-readers (unclassified slot site); all fixed in-diff. CI check-runs on PR 26397: in_progress at report time.",
"dogfood_battery": "n/a: no ! in the PR title and no (narrowing) arm; the new dogfood file was run on the final head (1 passed). Dogfood Regression Gate does not run on PRs.",
"mcp_calls": "1: mcp__github__search_pull_requests (read, open-PR overlap census). No MCP writes.",
"api_writes": "2 relay strokes beside git push: fleet-write pr_create (POST /repos/objectstack-ai/objectstack/pulls + POST /issues/26397/assignees, run 40435451999, body read back identical 8822 bytes) and this report comment (post-stamped.mjs, POST /issues/26180/comments). Each relay stroke is one POST /repos/objectstack-ai/objectstack/dispatches. git push: 5 commits on the harness branch.",
"open_questions": [],
"out_of_scope_findings": [
"overlap, not a finding: open PR 26337 (ownership queues, ADR-0142) edits the same insertion points in plugin-sharing sharing-service.ts and sharing-plugin.ts and plugin-security explain-engine.ts; git merge-tree HEAD vs its head reports content conflicts in those three files, all adjacent inserts. Whichever lands second keeps both (queue disjunct plus approval ids in the id $in arm). carrier: seat 1 · noted, not filed",
"explain uses kind record_share for the approval grant because a dedicated kind is a spec enum + ui label edit outside this card. carrier: none · noted, not filed"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 27, 2026
Contract review
Served-tier:
CONTRACT_REVIEW_TIERHead-sha:
e8fb0d3bb12fbb202af1ba9789227e8585cb055cLocal-runs: none
Scope: packages/plugins/plugin-approvals/src/pending-approver-read-grant.ts, packages/plugins/plugin-approvals/src/approval-service.ts, packages/plugins/plugin-sharing/src/sharing-service.ts, packages/plugins/plugin-sharing/src/sharing-plugin.ts, packages/plugins/plugin-security/src/explain-engine.ts, packages/plugins/plugin-security/src/security-plugin-explain-access.ts, packages/plugins/plugin-approvals/src/pending-approver-read-grant.integration.test.ts, packages/plugins/plugin-approvals/src/approver-address-readers.test.ts, packages/plugins/plugin-sharing/src/sharing-service.test.ts, packages/plugins/plugin-security/src/explain-engine.test.ts, packages/qa/dogfood/test/approval-pending-approver-record-read.dogfood.test.ts, .changeset/26180-pending-approver-record-read.md + packages/plugins/plugin-approvals/src/approver-address.ts (
actingAddresses,slateNamesAPerson,heldSlot), packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts, packages/plugins/plugin-approvals/src/approval-service.ts at the head (isOverrideActor,callerAccountEmail,syncApproverIndex,findRequestRow,REQUEST_VISIBILITY), packages/plugins/plugin-sharing/src/sharing-plugin.ts (buildSharingMiddleware, the read and write legs), packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts (resolveSharingReadFilter), packages/plugins/plugin-security/src/security-plugin.ts (computeControlledByParentFilter), packages/plugins/plugin-approvals/src/approver-org-scope.ts (filterApproversWhoCanRead, the premise), docs/adr/0105 D9, docs/adr/0090 D10Judged as a security-boundary change against ruling 6429330267 (letter A) and its bounds, on the diff
origin/main...pr-26397(12 files, +1049/−2) and the cited code at the head. Gate verdicts on this head as read at review time: 20 check-runs concludedsuccess(Build/Lint/Type Check source gates, ESLint, Check Changeset, Check PR Size, Governed Surface Queue Guard, the three claim guards, Spec property liveness, Dogfood Regression Gate, Flag docs, Auto Label, filter); 20 stillin_progress(Test Core 1–8, Build Core, Type Check workspace/consumer/debt, Lint CI/engine, query-options erasure, slot lookup, docs guards, repo gates); none failed; 11 skipped (UI, docs, opt-in harnesses). The verdict below is on the diff; the seat re-reads the conclusions before landing.① Derived judgments
Accept-set changes the diff implies, each named right or wrong against the ruling:
buildSharingMiddlewarecomposesSharingService.buildReadFilterfor exactly these four verbs (sharing-plugin.ts, the READS leg); the filter ORs{ id: { $in: approvalIds } }into the existing record-share arm beside the owner-match. The ids are therecord_idofsys_approval_requestrows withobject_name = object,status = 'pending', the organization fence, and (for a slate member)id INthe requests whosesys_approval_approverrow names the caller. So the list door widens to the one record per pending request and nothing else; the dogfood test pins list membership (listIds(first)equals the two pending records) and stranger[]. Any other read door that composes the sharing filter (export, query, the explain's own sharing probe) serves the same ids; a door that bypasses the sharing filter is pre-existing and unchanged by this diff.pendingApproverReadGrants(sharing-side) is called from exactly two places:buildReadFilterandlistApprovalReadGrants.canEdit,canDelete,buildWriteFilterand the middleware's write leg are untouched;sharing-service.test.tspinscanEdit/canDeletefalse andbuildWriteFilterowner-only for a slate member who owns nothing; dogfood pins PATCH 403 with nothing landed. Explain attributes the ruleeffect: 'neutral'on an update and the layer outcome staysexcluded.syncApproverIndexrewrites the request'ssys_approval_approverrows on every slate change and clears them with[]on every path that leavespending(decide, final approve, reject, recall, cancel, send-back, escalation rewrite); (b) the request row is re-read withstatus: 'pending'; (c) the memo is aWeakMapkeyed on the execution-context object and invalidated byengine.writeEpoch.current, whichObjectQLbumps on every write, a decision among them. Dogfood pins approve-and-advance (first approver 404, second 200), final approve (second and overrider 404), reject (404); the integration test pins a decided request on the same context object answering{}.explain-engine.tspushes arecord_sharerule withvia: "approval request N",grants: 'read',admitson read,neutralotherwise; the detail sentence names the request and the step-bound;security-plugin-explain-access.tsbindslistApprovalReadGrantswith the explained principal's context, so explain reads the same answer the filter ORed in (sharing-service.tscomment: "the two cannot disagree"). A throwing lookup attributes nothing (the call is wrapped in a catch that answers an empty list), pinned in explain-engine.test.ts.sys_approval_approver(the index the inbox already queries) andsys_approval_request; no spec file, no object field, no flow/step key, no app setting, nothing underpackages/spec/**orui/**. The slate is the open-time, OOO-substitutedpending_approversCSV mirrored into the index, so the delegate on the slate is the "resolved person" and reads; the absent delegator is not on the slate and does not.isOverrideActor(context, callerOrg)(platform admin by posture/admin_full_access; tenant admin by posture/holdsTenantAdminRung) answersslate = null, and the request probe then drops theidpredicate: every pending request of that object inside the caller's active organization. Dogfood pinsorganization_admin_no_bypassreading while pending and 404 after; the integration test pins the tenant admin's sweep.organization_id: orgor, for an org-less caller,{ $null: true }($nullis awhereoperator the engine lowers;relation-filter-lowering.ts,no-operator-object-door.tscite it).activeOrganizationreadsorganizationId ?? tenantId, the same readisOverrideActorandREQUEST_VISIBILITYmake. The platform admin's override crosses the wall to decide and not to read: pinned. By contrastfindRequestRowwith a null tenant imposes no fence at all, so the new path is the narrower of the two. ADR-0105 D9's cross-org-targeted approver holds membership in the request's organization (else dropped), and reads only while acting in that organization: consistent.position:*/team:*/ e-mail addresses on the slate — RIGHT.actingAddresses({ userId, email: accountEmail(userId), positions: [] })yields only the caller's user id and the e-mail their ownsys_userrow carries (server-resolved, never client-supplied). Aposition:/team:literal matches no person, so a position holder who could DECIDE viaheldSlotgets no read through it: narrower than the decide power, inside "each resolved person". An e-mail slot admits only the account carrying that e-mail. The new comparison site is classifiedreaderinapprover-address-readers.test.ts, so the equivalence gate sees it.SharingService.pendingApproverReadGrantsanswers none whencontext.onBehalfOf?.userIdis set; the middleware's ADR-0090 D10 delegator leg does compute the delegator's grants, but it is ANDed with the agent leg (which has none), so an agent never reaches a row through the delegator's approval. OOO substitution is judged under item 5.object_name = object; a pending request oncontract_sheetgrants nothing onmemo_sheet(pinned).controlled_by_parentdetail derives its scope from the master's owngetReadFilter(ADR-0139 leaf,computeControlledByParentFilter), which composes the sharing filter, so a detail under an approval-granted master is readable exactly as under a record share, no more. ADR-0104's download inheritance is not re-read here; the diff adds no file door.approvalsservice, agetServicethrow, a probe with no member, anullanswer, or a rejected read all answerNO_APPROVAL_GRANTS; a rejected read logs one warn and the read goes on owner-match plus shares; the read is never errored. The source side rethrows a failed read and never memoizes it (pinned). The probe is asked only after the OWD/depth/owner-field returns, so anullor deny-all filter never asks it (pinned).sys_userread, one indexedsys_approval_approverread (theapprover, organization_idindex), then onesys_approval_requestread per object; a caller on no slate stops after the index read for every object. Both reads are capped at 10,000 with a warn when the window is hit (narrowing direction: missing grants, never extra). The cost pin counts the engine'sfindcalls. Note:resolveSharingReadFilterspreads a fresh context object per call, so the memo misses there and the cost repeats per call; still bounded, never per row.WeakMapon the per-request context object plus the engine write epoch: a decision anywhere on the same engine retires every entry; nothing is held across requests.ApprovalService.pendingApproverReadGrants(object, context)(the module itself is not exported from the barrel),SharingService.listApprovalReadGrants,SharingServiceOptions.approvals?and the exportedSharingApprovalReadProbetype,ExplainEngineDeps.listApprovalReadGrants?optional. Structural late-binding, no package dependency added, no spec interface changed. The premise (filterApproversWhoCanReadfilters request-row readability and grants nothing on the record; no approval-based read existed in plugin-sharing/plugin-security) holds onorigin/main.Nothing wrong found; no widening beyond the ruling's bounds on any door.
② Semver level
.changeset/26180-pending-approver-record-read.mddeclares@objectstack/plugin-approvals,@objectstack/plugin-sharing,@objectstack/plugin-securityatpatch, withClause-②: noandMetadata-protocol: noneline-initial; the PR body carries the same two lines. The diff publishes additive runtime members only (item 15), no authorable key, no spec change, no breaking removal:nonetakespatch, matches.Check Changeseton this head: success.Clause-②: nois right: the grant is derived, not declared.③ Boundary flags
open_questions:[]— nothing to answer.git merge-treecontent conflicts): not a finding against this diff; whichever lands second rebases and keeps both the queue disjunct and the approval ids. Seat watches the landing order.record_sharewithvia: "approval request N": accepted; a dedicated kind is a spec enum plus a ui label, outside this card's file surface (⛔ nopackages/spec/**). Escalated as a note for the seat, not a fork.permissionSetMemoprecedent carries the same property, HTTP contexts are per request, and the status re-read bounds any fresh computation. Noted, no action owed on this card.single): accepted, the service test covers member, platform admin and org-less caller.Implemented-by:
claude/project-thread-crp41oReviewed-by:
session_01R1q2xdtYgU6FKrZ7LgjPCCVERDICT: PASS
objectstack-fleet commentedon Oct 27, 2026
Landed
PR #26397 merged at 2026-10-27T02:45Z by the merge queue as
b7b04e5d8conmain(Fixes #26180; ACCEPT6432091174).git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26397-*'origin/mainby contentgit cat-file -e origin/main:packages/plugins/plugin-approvals/src/pending-approver-read-grant.tsgit merge-base --is-ancestor b7b04e5d8c origin/mainState: this card closed by
Fixes(completed);pm:dispatchedremoved.Generated by Claude Code
The direct manager can open the contract they are asked to approve (#164