Skip to content

fix(approvals): a dead approval run no longer leaves the record RECORD_LOCKED (#3456) - #3703

Merged
os-zhuang merged 2 commits into
mainfrom
claude/dead-approval-record-locked-enuhpy
Jul 27, 2026
Merged

fix(approvals): a dead approval run no longer leaves the record RECORD_LOCKED (#3456)#3703
os-zhuang merged 2 commits into
mainfrom
claude/dead-approval-record-locked-enuhpy

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #3456 — the #3424 follow-up, expected-behavior item 3. Implements option 3 (both) from the issue: exempt the owning run's writes and release the lock when a run dies anyway.

The gap

The record lock keys on a pending sys_approval_request and cannot tell the run that owns that request from an unrelated user editing the record. So a flow that touched its own target record while its own approval was still pending — a manual resume with no decision, or a node writing the record between opening the approval and the decision — died on its own RECORD_LOCKED, and the record stayed locked behind the dead run. Recovery existed (#3424 lets an admin recall/reject), but nothing made it self-healing.

Only one of the two options would have left a hole, so both are here: option 1 stops the run from dying, option 2 cleans up the runs that die for reasons option 1 can't prevent (a process crash being the clearest).

① Prevention — the owning run may write its own record

The engine stamps flowRunId onto the run context at setup, alongside runAs — same lifetime, same single construction point (resolveRunContext). It rides resolveRunDataContext into every data node's ObjectQL context, through buildSession into ctx.session, and the lock hook exempts a write whose flowRunId matches the pending request's flow_run_id. Because it lives on the run context, it is persisted with a suspended run and survives pause/resume, including a cold resume after a restart.

Keyed on run identity, not elevation — deliberately. Widening the write to isSystem would have been the easy fix and the wrong one: a runAs:'user' run must stay RLS-scoped while it writes. flowRunId is pure provenance — server-constructed like isSystem (this envelope is built from the authenticated session, never from request input), read by no security middleware, and it authorizes exactly one thing: a write to the single record its own run already holds a pending request against. A stray flowRunId against a request with no flow_run_id is not a skeleton key; there is a test for that.

② Recovery — release records held by runs that died anyway

A pending request whose owning run has reached a terminal state can never be decided, so it is finalised as recalled — which releases the lock — and audited under the reserved actor system:dead-run with the run id and its status in the comment, so it is never mistaken for a submitter's withdrawal. recalled is reused because it is already the platform's terminal state for a live request that ended without a decision; the history view lists it, and no new ApprovalStatus value has to enter the spec.

It runs on the existing approvals sweep clock (Promise.allSettled alongside the SLA scan, so neither leg can short-circuit the other), which also gives it the boot catch-up the crash case needs.

Fail-safe by construction. It acts only on an explicit terminal status from a closed set. paused — the normal state of every live approval — plus running, an unrecognised status, an unknown run, a getRun that throws, and a deployment with no automation engine are all read as "still alive". The failure mode is "a dead run's lock survives until an admin recalls it" (today's behaviour), never "a live approval is destroyed". completed sits in the terminal set with the failure states because the approval node only writes a request row on the path where it also suspends, and every in-band transition finalises the request before resuming the run — so a completed run with a pending request means someone resumed it out of band.

A prerequisite bug this surfaced

AutomationEngine.getRun returned the first log entry for a run id. A run that pauses and later finishes records two entries under one id (paused, then the terminal one), so every suspend-then-finish run — every approval, screen and wait flow — reported itself as paused forever. That is user-visible on the Runs observability surface (runtime/src/domains/automation.ts), not just an obstacle to this sweep: without the fix the sweep could never have seen a dead approval run, because those are exactly the runs that pause first. Fixed to take the latest entry.

Verification

plugin-approvals 229, service-automation 365, objectql 1092, lint 390 — all green, and all four changed packages build clean through DTS.

Every new guard was mutation-verified — each fix reverted in turn, confirming the tests go red for the right reason:

reverted tests that go red
the owning-run exemption allows the OWNING run to write its own target record
the closed terminal-status set (treat any status as dead) the three "leaves it alone" cases — paused, unrecognised, running
the getRun latest-entry fix both suspend-then-finish cases

New coverage includes the end-to-end proof — a user edit is refused while the dead run holds the lock and succeeds after the sweep — and an integration test asserting flowRunId reaches the ObjectQL context and equals the engine's own run id, for both runAs modes, plus a check that the stamp never leaks back onto a caller-reused context object.

On the full-workspace pnpm test: touching packages/spec makes turbo rebuild it concurrently with dependents' tests, and those tests transiently fail with Cannot find package '@objectstack/spec/integration' while dist is being rewritten. It is a build/test race, not a regression — the failing package differed run to run, and every affected package passes standalone once spec's dist is stable.

Residual, deliberately not changed

A runAs:'user' run with no trigger user (a schedule) passes no ObjectQL context at all, so it carries no flowRunId and is still subject to the lock. Manufacturing a context just to carry the run id would flip that run from its documented unscoped fail-open (#1888) to baseline-member RLS — a separate and larger behaviour change. The sweep is what recovers that shape. Recorded in a code comment at resolveRunDataContext, in the changeset, and here.


Generated by Claude Code

…D_LOCKED (#3456)

The record lock keys on a pending `sys_approval_request` and could not tell the
run that OWNS that request from an unrelated user editing the record. A flow
that touched its own target record while its own approval was still pending —
a manual `resume` with no decision, or a node writing the record between opening
the approval and the decision — died on its own `RECORD_LOCKED`, leaving the
record locked behind the dead run.

Prevention: the automation engine stamps `flowRunId` onto the run context at
setup, alongside `runAs`, and it travels with every data node's ObjectQL context
into `ctx.session`; the lock hook exempts a write whose `flowRunId` matches the
pending request's `flow_run_id`. Keyed on run identity rather than elevation on
purpose — a `runAs:'user'` run stays RLS-scoped while it writes. The field is
pure provenance: server-constructed like `isSystem`, never client-supplied, read
by no security middleware, and it permits exactly one write — to the record its
own run already holds a pending request against.

Recovery: a sweep on the existing approvals clock finalizes a pending request
whose owning run is terminal (`completed`/`failed`/`cancelled`/`timed_out`) as
`recalled`, releasing the lock, audited under the reserved actor
`system:dead-run`. This covers the case no in-band handler can — a run killed by
a process crash. Fail-safe by construction: it acts only on an explicit terminal
status from a closed set, so `paused` (a live approval), `running`, an unknown
status, an unreadable run and a deployment with no automation engine are all
read as alive.

Also fixes `AutomationEngine.getRun`, which returned the FIRST log entry for a
run id. A run that pauses then finishes records two entries under one id, so
every suspend-then-finish run — every approval, screen and wait flow — reported
itself as `paused` forever, on the Runs surface and to this sweep alike.

Residual, deliberate: a `runAs:'user'` run with no trigger user (a schedule)
passes no ObjectQL context at all, so it carries no `flowRunId` and is still
subject to the lock. Manufacturing a context to carry the run id would flip that
run from its documented unscoped fail-open (#1888) to baseline-member RLS — a
separate, larger change. The sweep recovers that shape.

Tests: plugin-approvals 229, service-automation 365, objectql 1092, lint 390 —
all green. Each new guard was mutation-verified: reverting the exemption, the
closed terminal set, or the getRun fix turns the matching tests red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGXCuBt5mSXbN3Gc8yfbRv
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 27, 2026 3:13pm

Request Review

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data tests tooling labels Jul 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/objectql, @objectstack/plugin-approvals, packages/services, @objectstack/spec.

110 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/plugin-approvals, packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via packages/services, @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/plugin-approvals, packages/services, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/services, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/objectql, @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…the lock's two new behaviours

The `flowRunId` field added to `ExecutionContextSchema` left the GENERATED
`content/docs/references/kernel/execution-context.mdx` stale, failing the spec
`check:docs` gate. Regenerated via the prescribed path (`gen:schema && gen:docs`)
— a one-row addition to the field table.

Also updates the hand-written approvals guide, which described the record lock as
absolute and predated any automatic recovery:

- the lock now exempts the run that opened the request, and the exemption is
  keyed on run identity rather than elevation, so a `runAs:'user'` run stays
  RLS-scoped while it writes;
- a dead run's lock is released by the sweep, with the note that it acts only on
  a positively-confirmed terminal run — a paused run, an unknown run, or an
  unreachable engine all count as alive.

`check:docs` and `check:doc-authoring` both green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGXCuBt5mSXbN3Gc8yfbRv
@os-zhuang
os-zhuang marked this pull request as ready for review July 27, 2026 15:31
@os-zhuang
os-zhuang merged commit b949059 into main Jul 27, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/dead-approval-record-locked-enuhpy branch July 27, 2026 15:32
os-zhuang added a commit that referenced this pull request Jul 27, 2026
…on (#3456 follow-up) (#3719)

Follow-up to #3703. Tests plus one comment reference; no behaviour change.

`releaseDeadRunRequests` recalls a pending request whose owning run is
terminal, on the premise that such a pair can only be an orphan. That premise
held only because every in-band transition moves the request out of `pending`
before handing the run back — a convention spread across four public methods
and seven resume/cancelRun call sites, enforced by nothing. Reverse it anywhere
and the sweep would cancel a live approval.

Pins the invariant itself rather than any one method's call order: at the
instant the run is handed back, no request owned by that run may still be
`pending`. Seven scenarios cover all seven sites — decide approve/reject,
recall, send-back, send-back past the revision budget (ADR-0044 auto-reject),
recall inside the revise window (cancelRun), and resubmit.

Shown load-bearing by a reorder-only mutation of `recall` (end state
identical): exactly one test fails, the new one, with the other 235 green —
which is also the proof no existing test covered the ordering.

Also points the `resolveRunDataContext` residual comment at #3712, which
tracks the schedule-triggered run that still cannot carry `flowRunId`.
os-zhuang added a commit that referenced this pull request Jul 28, 2026
…ord (#3712) (#3749)

Closes #3712. Residual of #3456, left open by #3703.

A run with no principal now passes provenance alone: `resolveRunDataContext`
returns `{ flowRunId }` — no userId, positions, permissions, or `isSystem`.
Every principal gate keys on one of those fields, so the context authorizes
identically to no context at all; the run keeps its documented #1888 unscoped
posture and only becomes attributable.

Provenance moved out of the hook session into `ctx.provenance`, so an
identity-less writer is never forced to present an empty session — which would
have turned "no caller" into "an anonymous caller" and narrowed the #1888
fail-open for attachments alone.

Also relaxes `BaseEngineOptionsSchema.context` to a partial envelope
(`ExecutionContextInput`): parse-time defaults made positions/permissions/
isSystem required on a caller-supplied option, asserting that every
data-engine context carries a principal.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Approval: a dead approval run can leave the record RECORD_LOCKED (#3424 follow-up, expected-behavior 3)

2 participants