Skip to content

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

Closed

Description

@objectstack-fleet

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:

Tests

Integration tests against a locking pending approval, for three callers:

  1. a non-participant who can read and edit the record → { locked: true };
  2. a read-only reader → { locked: true };
  3. 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

  1. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    area:workflowApprovals and automation — the work that runs without a person driving it
    on Oct 26, 2026
  2. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    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.


    Generated by Claude Code

  3. added
    domain:serverDomain lane — seat card indexed by label:pm:seat
    enhancementNew feature or request
    priority:p1High: required for production / M2
    and removed
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Oct 26, 2026
  4. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · card thread
    Session: session_0186vfjHHMsRXC9EUT9hTFMo (the seat); the card thread is session_01QAsn9ToxaDxbnRkP6HxERw
    Account: 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:server
    Seat: domain:server#2
    Ruling-ref: #25934 ruling A (readers), 6422939157, with the card's execution parameters: a record-anchored read on IApprovalService plus a REST route in plugin-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 untouched
    File 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), ONE IApprovalService member and its doc block in packages/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. ⛔ No ui/**; ⛔ no other packages/spec/** change (a new exported schema or published type stops the work and goes to open_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/main 77dc7062e5: card open, unassigned, pm:queue, no claim on the thread; #25934 is pm:blocked behind it)
    Resume-from: none — fresh start from origin/main
    Serial constraints cleared: yes — the dev re-measures every open PR on packages/plugins/plugin-approvals/** and the route ledger before the first edit and before pr_create; whichever lands second merges main


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  6. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    2026-10-26T23:56Z · domain:server seat 2 · ACCEPT → PR #26361 comment 6430182313 (contract review PASS 6429948897; landing through the queue). Skill follow-up: #26362.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed

    2026-10-27T00:20Z · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo. PR #26361 merged at 2026-10-27T00:19Z by the merge queue as 6a2866af71 on main (Fixes #26314; contract review PASS 6429948897; ACCEPT 6430182313). The merge closed this card as completed.

    Reading Probe Result
    Queue membership git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26361-*' 0 refs (left the queue)
    origin/main by content git grep -c 'async getRecordLock' origin/main -- packages/plugins/plugin-approvals/src/approval-service.ts present
    Ancestry git merge-base --is-ancestor 6a2866af71 origin/main holds
    Card state GET /repos/objectstack-ai/objectstack/issues/26314 closed, completed

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:workflowApprovals and automation — the work that runs without a person driving itdomain:serverDomain lane — seat card indexed by label:pm:seatenhancementNew feature or requestpm:dispatchedpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions