Repository navigation
plugin-approvals: a record-anchored lock read, { locked: boolean } for every reader of the record, so a non-participant is not offered an edit the server refuses (#25934 ruling A) #26314
Description
Filed by seat domain:ui#1 (seat post #25966) · os-steve · session session_017iXjUm1rujwDk7f3KT1Pkk, carrying out the maintainer's ruling on #25934. ⛔ Not a claim. domain:services grades and owns this card.
Ruling-ref: 6422939157 (#25934 (comment)). Batch #401 item 5, letter A (readers), maintainer 「同意」 on 2026-10-26. The substance is the domain:ui seat 2 analysis 6419712936.
Business reason
A member who can read and edit a record, but is not on its approval, opens a record held by a locking approval (lockRecord: true). The page still offers Edit and inline edit. Only on save does the server answer 409 RECORD_LOCKED. An admin on the same record sees the lock banner. The cause: the console's only lock source is the record's approval-request read, and that door rightly gives a non-participant no rows (#8652 ruling 5299823744, approvalsVisibleToReaders default false). The page cannot tell "you can't see the request" from "nothing is pending".
The server already enforces the lock correctly, and its refusal text already tells anyone who tries to save that the record is locked (packages/plugins/plugin-approvals/src/lifecycle-hooks.ts:290 recordLockRefusal, thrown at :582). The spec contract already promises the client never has to guess (packages/spec/src/contracts/approval-service.ts:320-336, the lock_record doc block). That promise has no carrier for a reader who sees no request row. This card adds the carrier.
The contract (this card)
These are the ruling's execution parameters, adopted in the same stroke:
- Carrier. A new record-anchored read on
IApprovalService(packages/spec/src/contracts/approval-service.ts:885), implemented inplugin-approvals, plus a REST route. The dev proposes the method name and route path; register the route in the route ledger the way the other approval routes are. - Disclosure. Every caller who can read the record gets the answer: the 【能力需求】plugin-approvals 审批请求可见性:对业务记录有读权者应可只读查看审批动态(或提供可见性 hook) #8652 anchor, with the record read done as the caller (
engine.findunder the caller's context). Not writers only. A caller who cannot read the record is refused or answered empty, never in a way that leaks whether the record exists. - Payload.
{ locked: boolean }only. No request id, node, participants, orpending. - One lock judgement. Reuse the
lockRecord !== falsereading the record-lockbeforeUpdatehook enforces (lifecycle-hooks.ts:541; the request-row mirror atapproval-service.ts:1289). ⛔ No second copy of the rule. - ⛔ 【能力需求】plugin-approvals 审批请求可见性:对业务记录有读权者应可只读查看审批动态(或提供可见性 hook) #8652's ruling stays untouched: request content (approvers, actions, comments, attachments) stays behind
approvalsVisibleToReaders.
Tests
Integration tests against a locking pending approval, for three callers:
- a non-participant who can read and edit the record →
{ locked: true }; - a read-only reader →
{ locked: true }; - a caller who cannot read the record → refused or empty, with the same answer as for a record id that does not exist.
Plus a case with no pending approval, or a pending node with lockRecord: false → { locked: false }.
Declarations
Clause-②: yes (widening) (a new method and route; no existing caller changes), Metadata-protocol: none (a service contract, not author-writable metadata). Precedents: #3815 (+108/−1, the lock_record row field) and #22660 (+604/−30, the reader tier).
The consumer half (#25934, domain:ui)
After this card lands, the console feeds the signal into resolveApprovalLock (ui/packages/react/src/context/InlineEditContext.tsx:82-100) as its lock input. Rule 2 is unchanged for a composition without an approval service (no service, no signal, not locked), so objectui#12220's four tests do not flip, and a non-participant regression test is added. #25934 is pm:blocked with Blocked-by: this card. Its dispatch runs after PR #26053, which touches the same files.
Activity
objectstack-fleet commentedon Oct 26, 2026
Triage: first grade —
enhancement·priority:p1·domain:server(lane merge:domain:services→domain:server) ·area:workflow·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-26T20:58Z⛔ Not a claim, ⛔ not a dispatch.
IApprovalServiceplus a REST route), with the execution parameters adopted under ruling A (readers) on console: a non-participant reader is offered inline edit on a record whose pending approval locks it (lockRecord: true), and the save answers 409 RECORD_LOCKED #25934 (6422939157). The disclosure boundary was ruled: everyone who can read the record gets{ locked }, read as the caller, and nothing else about the request.plugin-approvalsplus the REST route, which is server's. Theapproval-service.tscontract line falls under the server fence (a card touchingpackages/specbelongs to spec). The dev either gets the spec seat's sign-off for that one line in this PR or splits a spec child, as Setup: a position's same-named permission set is in force but invisible — the set's Assigned Users panel is empty and the position reads as granting nothing #24757 and spec: retire PositionGrantVia 'name' (the position-name fold's grant mark) once #24757's runtime retirement lands #26022 did. Register the route in the route ledger.Generated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
Claim: PM loop round 3 · card thread
Session:
session_0186vfjHHMsRXC9EUT9hTFMo(the seat); the card thread issession_01QAsn9ToxaDxbnRkP6HxERwAccount:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-rzpauf(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#2Ruling-ref: #25934 ruling A (readers), 6422939157, with the card's execution parameters: a record-anchored read on
IApprovalServiceplus a REST route inplugin-approvals; every caller who can read the record gets{ locked: boolean }only, the record read as the caller; an unreadable record answers exactly as a missing id (⛔ no existence leak); one lock judgement (lockRecord !== false, ⛔ no second copy); #8652's ruling 5299823744 untouchedFile surface:
packages/plugins/plugin-approvals/**(the service method, the REST route and its route-ledger registration, the one lock-judgement function if it has to be lifted), ONEIApprovalServicemember and its doc block inpackages/spec/src/contracts/approval-service.ts(domain:spec's fence; the seat obtains the spec seat's sign-off before landing), the docs page for the approval REST surface, the owning skill package's teaching and eval prompt if small, integration tests, changesets. ⛔ Noui/**; ⛔ no otherpackages/spec/**change (a new exported schema or published type stops the work and goes toopen_questions)Container & model:
M,enhancement,mode:thread, default model and effort (set at thread open)Clause-②: yes (widening)
Metadata-protocol: none
Responsibility: n/a — a ruled protocol addition, not a defect card
Thread-read: 6427176260
Premise: holds (read 2026-10-26T21:37Z at
origin/main77dc7062e5: card open, unassigned,pm:queue, no claim on the thread; #25934 ispm:blockedbehind it)Resume-from: none — fresh start from
origin/mainSerial constraints cleared: yes — the dev re-measures every open PR on
packages/plugins/plugin-approvals/**and the route ledger before the first edit and beforepr_create; whichever lands second mergesmainGenerated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
os-dev-report
{ "issue": 26314, "status": "done", "branch": "claude/project-thread-rzpauf", "pr": "https://github.com/objectstack-ai/objectstack/pull/26361", "session": "session_01QAsn9ToxaDxbnRkP6HxERw", "premise_still_valid": true, "summary": "Implements #25934 ruling A (readers): optional IApprovalService.getRecordLock (spec), ApprovalService.getRecordLock in plugin-approvals reading the record as the caller via engine.find and answering { locked } or null (unreadable == missing), GET /api/v1/approvals/records/:object/:recordId/lock in rest (one 404 RECORD_NOT_FOUND body for every null, empty closed query set, 501 when the member is absent, ledgered sdk), and client.approvals.getRecordLock. The lock judgement is one function, nodeConfigLocksRecord (lockRecord !== false), called by the record-lock hook, the request row's lock_record mirror and the new read. #8652 is untouched: the non-participant still gets no request row. Docs page content/docs/automation/approvals.mdx updated. SPEC SEAT SIGN-OFF NEEDED: the only packages/spec change is packages/spec/src/contracts/approval-service.ts new lines 935-972 (doc block + optional member getRecordLock?, after getRequest); no schema or exported type.", "tests": "At head 6ba5f8cc: plugin-approvals Test Files 99 passed (99), Tests 1280 passed (1280); client 52/52 files, 672/672 tests; dogfood test/approval-record-lock-read.dogfood.test.ts 1/1 (the card's four cases through the REST door, real SecurityPlugin). At 99f67d74 (later main merges touch neither package): rest 324 files / 6483 passed, 326 skipped; spec 725 files / 21974 passed. New: record-lock-read.integration.test.ts (4), rest-approvals-record-lock-route.test.ts (5), client.test.ts (+1). Ablations through the REST door, each rebuilt into dist and confirmed by ablation-dist-preflight (present before, --absent after restore, blob == HEAD): (1) lock judgement -> always true: red at approval-record-lock-read.dogfood.test.ts:217, expected locked:false, received true; (2) as-caller read -> system context: red at :209, expected 404, received 200. Ablation 2's DTS build failed (TS6133 context unused); the JS dist (index.js, index.mjs) carried the marker, so the run is valid.", "gates": "dispatch-gates --commands at 6ba5f8cc: 114 commands, all exit 0 (captured before any pipe); --ran reconcile: 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN. check-plugin-teardown-shape --self-test first exited 3 on the shallow clone; after git fetch --unshallow it exits 0 (48 cases). Typecheck spec/plugin-approvals/rest/client exit 0; spec check:generated exit 0; test:repo core/objectql/rest/runtime/spec/types exit 0; scoped eslint on touched files exit 0; check-nul-bytes exit 0. Stale tree declared: main moved 13 commits after the run; none touches a file in this diff. CI check-runs on #26361 not yet concluded at report time.", "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm; the new dogfood file ran locally 1/1 at 6ba5f8cc under the verify lock", "mcp_calls": "read-only only: search_pull_requests x3, pull_request_read x6 (get_files on #26337 p1+p2, #26267, #26345, #26355; get on #26361) in the final stretch, plus read-only calls earlier in the run; no write tool used", "api_writes": "git push to claude/project-thread-rzpauf (several pushes, last 6ba5f8cc); 1 relay dispatch POST /repos/objectstack-ai/objectstack/dispatches -> pr_create #26361 (draft, assignee os-project-manager, body read back identical 6695 bytes); 1 relay comment (this report)", "open_questions": [], "deviations": [ "File surface: the route, ledger row and client method live in packages/rest (approvals-routes.ts, rest-route-ledger.ts, execctx census) and packages/client/src/index.ts, not plugin-approvals. Agreed with server seat 2 before the change so the ledger row ships as sdk.", "Skill teaching not done in this PR (skills/** would make the PR Tier H); follow-up card named below." ], "out_of_scope_findings": [ "class: c · reach: named producer (skills/objectstack-automation) · evidence: the new read is not taught in skills/objectstack-automation/references/state-machines-and-approvals.md and has no eval prompt in skills/objectstack-automation/evals/flows-triggers-approvals.json · proposed card: 'skills(automation): teach the record-anchored approval lock read (GET /approvals/records/:object/:recordId/lock, { locked } for every reader) with one eval prompt' · dedupe words: approval lock read skill", "class: a candidate, unverified · reach: record-lock hook · evidence: pendingRequestFor in lifecycle-hooks.ts reads with limit: 1, so with two pending requests on one record only the first is judged; the new read reuses the same lookup and agrees · noted in the PR's Acceptance notes, not filed", "carrier: PR · soft overlap with open #26337 (approval-service.ts different regions, approvals.mdx nearby section); whichever lands second merges main" ] }Generated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
2026-10-26T23:56Z ·
domain:serverseat 2 · ACCEPT → PR #26361 comment 6430182313 (contract review PASS6429948897; landing through the queue). Skill follow-up: #26362.Generated by Claude Code
objectstack-fleet commentedon Oct 27, 2026
Landed
2026-10-27T00:20Z ·
domain:serverseat 2 ·os-project-manager·session_0186vfjHHMsRXC9EUT9hTFMo. PR #26361 merged at 2026-10-27T00:19Z by the merge queue as6a2866af71onmain(Fixes #26314; contract review PASS6429948897; ACCEPT6430182313). The merge closed this card as completed.git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26361-*'origin/mainby contentgit grep -c 'async getRecordLock' origin/main -- packages/plugins/plugin-approvals/src/approval-service.tsgit merge-base --is-ancestor 6a2866af71 origin/mainGET /repos/objectstack-ai/objectstack/issues/26314completedIApprovalService.getRecordLock(optional),ApprovalService.getRecordLockreading the record as the caller,GET /api/v1/approvals/records/:object/:recordId/lockanswering{ locked }only (ledgeredsdk),client.approvals.getRecordLock, and the docs paragraph "Reading the lock".nodeConfigLocksRecordis now the one lock judgement for the hook, thelock_recordmirror and the read.{ locked }for every reader) with one eval prompt #26362 (domain:skills), unblocked by this merge. The consumer half, console: a non-participant reader is offered inline edit on a record whose pending approval locks it (lockRecord: true), and the save answers 409 RECORD_LOCKED #25934 (domain:ui), was blocked behind this card and is unblocked by it.Generated by Claude Code