Repository navigation
feat(plugin-approvals,rest,client): a record-anchored approval lock read, { locked } for every caller who can read the record - #26361
Conversation
…reader of the record
IApprovalService.getRecordLock (optional) answers { locked } to every caller
who can read the record, read as the caller; an unreadable record answers
null exactly as a missing id. The lock judgement is lifted to one function
(nodeConfigLocksRecord) the record-lock hook, the request-row mirror and the
new read all call. REST: GET /approvals/records/:object/:recordId/lock,
ledgered as sdk with client.approvals.getRecordLock.
Claude-Session: https://claude.ai/code/session_01QAsn9ToxaDxbnRkP6HxERw
Co-authored-by: Claude <noreply@anthropic.com>
…at the service, the door and a real boot; docs and changeset Claude-Session: https://claude.ai/code/session_01QAsn9ToxaDxbnRkP6HxERw Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAsn9ToxaDxbnRkP6HxERw Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAsn9ToxaDxbnRkP6HxERw Co-authored-by: Claude <noreply@anthropic.com>
…-rzpauf Claude-Session: https://claude.ai/code/session_01QAsn9ToxaDxbnRkP6HxERw Co-authored-by: Claude <noreply@anthropic.com>
…-rzpauf Claude-Session: https://claude.ai/code/session_01QAsn9ToxaDxbnRkP6HxERw Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 151 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 7927ddd575937a2b86ca405317f07762e455960f && git checkout 7927ddd575937a2b86ca405317f07762e455960f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 222755ebbf20c564b85d3da9e04f1bd9ebcbf280 6ba5f8cc3ea2ea0cb52f8a5c328e043f241380bd && git checkout -B drift-repro 222755ebbf20c564b85d3da9e04f1bd9ebcbf280 && git merge --no-ff 6ba5f8cc3ea2ea0cb52f8a5c328e043f241380bd
node scripts/docs-audit/affected-docs.mjs --json 222755ebbf20c564b85d3da9e04f1bd9ebcbf280
|
Contract reviewServed-tier: Base: merge-base with origin/main is ① Derived judgmentsAccept-set and public-surface changes the diff implies, each judged:
② Semver levelChangeset Clause-②: yes (widening), matched. ③ Boundary flagsDev flags from the PR body (Deviations, Acceptance notes) and the os-dev-report 6429820983 (
Check-runs on the head, read 2026-10-26T23:37:50Z: 46 runs, 27 success, 7 skipped (opt-in and matrix placeholders), 0 failure, 12 in progress (Build Core, Test Core 1/8 to 8/8, Type Check UI, Type Check consumer gates, Type Check workspace). Green at that time includes Check Changeset, Governed Surface Queue Guard, Spec property liveness, every Lint job, the claim and single-writer-path checks, Type Check source gates and debt ledger. The landing rule still requires every check green; this verdict is on the diff and the contract, with those twelve conclusions recorded as not yet read. Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
Requested by Leehom · project thread
Fixes #26314
Clause-②: yes (widening)
Metadata-protocol: none
What changes
#25934 ruling A ("readers"): the record lock gets a carrier of its own, so a member who can read a record but is not on its approval is no longer offered an edit the server then refuses with
409 RECORD_LOCKED.@objectstack/spec: one optional member onIApprovalService,getRecordLock?(object, recordId, context): Promise<{ locked: boolean } | null>, with its doc block. No schema, no exported type.@objectstack/plugin-approvals:ApprovalService.getRecordLockreads the record as the caller (engine.findwith the caller's context,fields: ['id'],limit: 1). An unreadable record answersnull, the same as a missing id; a caller refused the object's read is refused for every id.lockedcomes from the pending-request lookup the hook already uses.nodeConfigLocksRecord(config)(lockRecord !== false) inlifecycle-hooks.tsis now the only spelling. ThebeforeUpdaterecord-lock hook, the request row'slock_recordmirror and the new read all call it; the read goes throughrecordHeldByLockingApproval, which shares the hook's lookup and its bookkeeping-object exemption.@objectstack/rest:GET /api/v1/approvals/records/:object/:recordId/lockanswers{ success: true, data: { locked } }and nothing else. Everynullanswers one404 RECORD_NOT_FOUNDbody, so an unreadable record and a missing one cannot be told apart. The closed query-parameter set is empty (route rule 5). A service without the member answers501. Ledgered assdk.@objectstack/client:client.approvals.getRecordLock(object, recordId).content/docs/automation/approvals.mdxgains a "Reading the lock" paragraph with a curl example.#8652's ruling is untouched: request content stays behind
approvalsVisibleToReaders, and the tests assert the non-participant still gets no request row.Spec change: needs the spec seat's sign-off
The only
packages/spec/**change ispackages/spec/src/contracts/approval-service.ts, new lines 935–972: the doc block plus the optional membergetRecordLock?inserted right aftergetRequest(...)inIApprovalService. Nothing else underpackages/spec/changed. It is optional so existing implementations and the spec's ownminimalServiceliteral still satisfy the contract.Tests
packages/qa/dogfood/test/approval-record-lock-read.dogfood.test.ts(new): the card's four cases through the REST door on a real boot with the realSecurityPlugin, flows and approvals plugin, on twoprivateobjects:{ success: true, data: { locked: true } }, still gets[]from the request list, and theirPATCHis still409 RECORD_LOCKED;{ locked: true };GET /datais 404) gets the same status and body as for a non-existent id (404 RECORD_NOT_FOUND);lockRecord: false, and the held record after approval, both read{ locked: false }and the edit lands.packages/plugins/plugin-approvals/src/record-lock-read.integration.test.ts(new, 4 tests): real ObjectQL over SQLite with the real plugin start; also pinslock_recordand the read agreeing on every request.packages/rest/src/rest-approvals-record-lock-route.test.ts(new, 5 tests): preservation (extra keys from a service are dropped), one 404 body for everynull, the empty closed query set,501, and a403relay.packages/client/src/client.test.ts: URL encoding and envelope unwrapping.packages/rest/src/execctx-consumer-census.test.ts: counts move by one for the new route.Dogfood does not run on PR CI, so it was run locally under the verify lock.
At head
6ba5f8cc: plugin-approvals 99 files / 1280 tests pass, client 52 / 672 pass, the new dogfood file 1 / 1 pass. Rest (324 files / 6483 pass, 326 skipped) and spec (725 files / 21974 pass) ran at99f67d74; the later merges ofmainchanged no file in either package.Gates
dispatch-gates.mjs --commandsderived 114 commands; all 114 ran at6ba5f8ccwith exit 0, reconciled with--ran(114 run, 0 not measured, 0 unrun).mainmoved 13 commits during the run; none touches a file in this diff.typecheckon spec, plugin-approvals, rest and client: exit 0. Speccheck:generated: exit 0.test:repoon core, objectql, rest, runtime, spec and types: exit 0.eslintover the touched files andcheck-nul-bytes: exit 0.Ablations (through the REST door, dogfood)
approval-record-lock-read.dogfood.test.ts:217, expectedlocked: falsefor thelockRecord: falserecord, receivedtrue. Restored, blob equals HEAD.:209, expected404for the caller who cannot read the record, received200. Restored, blob equals HEAD.Each mutation was rebuilt into
distand confirmed on disk byscripts/ablation-dist-preflight.mjsbefore the run, and--absentafter the restore. Ablation 2's declaration build failed (TS6133,contextunused once the mutation dropped its use); the JS build still carried the mutation, which the preflight found in bothindex.jsandindex.mjs, so the run is valid.Deviations from the claim
packages/rest(approvals-routes.ts,rest-route-ledger.ts) andpackages/client/src/index.ts. Agreed with the dispatching seat before the change so the ledger row ships assdk, not as a gap.skills/**is a governed surface and would make the whole PR need maintainer approval. A follow-up card is named in the report.Acceptance notes
pendingRequestForreads withlimit: 1, so with two pending requests on one record the hook judges only the first one it gets. The new read reuses the same lookup, so the two still agree; whether two pending requests on one record can happen is not verified here.approval-service.ts(different regions) andcontent/docs/automation/approvals.mdx(a nearby section); whichever lands second may need a trivial rebase.Generated by Claude Code