Skip to content

fix(approvals): a schedule-triggered run can write its own locked record (#3712) - #3749

Merged
os-zhuang merged 3 commits into
mainfrom
claude/schedule-run-locked-record-write-r50e87
Jul 28, 2026
Merged

fix(approvals): a schedule-triggered run can write its own locked record (#3712)#3749
os-zhuang merged 3 commits into
mainfrom
claude/schedule-run-locked-record-write-r50e87

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

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

The problem

#3703 exempted the run that opened the pending request from the approvals record lock, keyed on flowRunId. It worked for every run that resolves an identity, and missed the one that doesn't: an effective runAs:'user' run with no trigger user — a schedule being the canonical case — passed no ObjectQL context at all, so nothing carried the run id and the run still died on its own RECORD_LOCKED.

The blocker was never the lock. It was that "no identity" and "no context" were the same thing on the wire, so a run could not say who it was without also claiming what it was allowed to do.

The issue framed that as a choice: manufacture a principal (option 1, accepting a flip to baseline-member RLS), or resolve the #1888 fail-open first (option 2). This takes neither — it takes the third door the issue left open: carry flowRunId without presenting an identity the middleware keys on. The fail-open decision stays exactly where it was, unprejudiced.

What changed

A run with no principal passes provenance alone. resolveRunDataContext returns { flowRunId } — no userId, no positions, no permissions, not even isSystem: false. That absence is load-bearing. Every principal gate keys on one of those fields:

gate keys on
elevation short-circuit context.isSystem
ADR-0103 engine-owned write guard context.userId
ADR-0090 D12 delegated-admin gate context.userId (missing context normalized to {} first)
empty-principal fall-open positions / permissions / userId

So the envelope is indistinguishable from passing no context at all. The run keeps its documented #1888 unscoped posture, its loud [runAs] warning, and the flow-schedule-runas-unscoped build-time lint. Nothing about what it may touch changed — only that it can now be attributed.

Provenance moved out of the hook session, into ctx.provenance. session answers who is calling, and is absent when no identity envelope was supplied — a distinction real gates depend on (the attachment access gate skips bare-kernel writes on exactly that test). Folding a run id into session would have forced an identity-less run to present an empty session, silently turning "no caller" into "an anonymous caller" and narrowing the #1888 fail-open for attachments alone. An inconsistent partial tightening is worse than either extreme, so HookContext.provenance.flowRunId now says what produced the write and the lock reads it there. buildSession returns undefined when a context yields nothing session-worthy; every real transport resolves positions, so an anonymous HTTP request still yields a session and stays gated.

BaseEngineOptionsSchema.context relaxed to a partial envelope (ExecutionContextInput). Parse-time defaults on positions/permissions/isSystem made them required on a caller-supplied option — asserting something untrue, that every data-engine context carries a principal. Callers have always passed slices ({ isSystem: true } for a system read); the type now says so.

Testing

pnpm build (71/71) · pnpm test (132/132) · pnpm lint — all green.

The #3703 miss was a hand-off gap, not a logic gap: every hop worked in isolation. So the new integration test (record-lock-schedule-run.integration.test.ts) stubs no hop — real resolveRunDataContext, real ObjectQL engine, real lock hook, in the order a live deployment runs them. It fails on main.

Also pinned:

  • the provenance-only context carries only flowRunId — every principal field asserted absent by name;
  • the two gates that could have been moved by it (assertEngineOwnedWriteAllowed, DelegatedAdminGate) treat it exactly like no context;
  • ctx.session stays undefined for a provenance-only write, and stays defined for an anonymous transport request;
  • the attachment access gate still bypasses a flow run with provenance and no session — the regression this design exists to prevent;
  • the [P0][security] Flow runAs never switches execution identity #1888 fail-open assertions now check "presents no principal" rather than "carries no context", which is the property that actually mattered.

Notes

🤖 Generated with Claude Code

https://claude.ai/code/session_01QFUmz96zRXzfypG4Fa9BXJ


Generated by Claude Code

…ord (#3712)

#3456 let the run that opened a pending approval write its own target record,
keyed on `flowRunId`. It worked for every run that resolves an identity and
missed the one that doesn't: an effective `runAs:'user'` run with no trigger
user — a schedule — passed no ObjectQL context at all, so nothing carried the
run id and the run still died on its own `RECORD_LOCKED`.

The blocker was never the lock. It was that "no identity" and "no context" were
the same thing on the wire, so a run could not say who it was without also
claiming what it was allowed to do.

A run with no principal now passes provenance alone. `resolveRunDataContext`
returns `{ flowRunId }` — no userId, no positions, no permissions, not even
`isSystem: false`. Every principal gate keys on one of those fields (the
elevation short-circuit on `isSystem`, the ADR-0103 engine-owned write guard and
the ADR-0090 D12 delegated-admin gate on `userId`, the empty-principal fall-open
on all three), so this context authorizes identically to no context at all. The
run keeps its documented #1888 unscoped posture, its `[runAs]` warning, and the
`flow-schedule-runas-unscoped` lint. Only attribution changed.

Provenance moved out of the hook session into `ctx.provenance`. `session`
answers who is calling and is absent when no identity envelope was supplied — a
distinction real gates depend on (the attachment access gate skips bare-kernel
writes on exactly that test). Folding a run id into `session` would have forced
an identity-less run to present an empty session, silently turning "no caller"
into "an anonymous caller" and narrowing the #1888 fail-open for attachments
alone.

Also relaxes `BaseEngineOptionsSchema.context` to a partial envelope
(`ExecutionContextInput`). Parse-time defaults on positions/permissions/isSystem
made them required on a caller-supplied option, asserting something untrue: that
every data-engine context carries a principal. Callers have always passed slices.

The #3703 miss was a hand-off gap, not a logic gap — every hop worked alone — so
the new integration test stubs no hop: real `resolveRunDataContext`, real
ObjectQL engine, real lock hook.

Refs #3456, #3703, #1888.

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

vercel Bot commented Jul 28, 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 28, 2026 1:58am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file protocol:data tests tooling size/l labels Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

113 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/plugin-security, @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/access-recipes.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/permissions/explain.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-security, @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/plugin-security, @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/plugin-security, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/plugin-approvals, @objectstack/plugin-security, 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/plugin-security, @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/audience-based-interfaces.mdx (via packages/plugins/plugin-security)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/plugin-security, @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.

claude added 2 commits July 28, 2026 01:48
…e envelope

Follow-up to the same change: `BaseEngineOptionsSchema.context` is now
`ExecutionContextInput`, so the contract doc's interface listings should say so
rather than implying a caller must hand over a full principal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFUmz96zRXzfypG4Fa9BXJ
…PI surface

The public API-surface gate flagged it: 0 breaking, 1 added. Intentional — the
partial execution envelope is a new named type, nothing was removed or narrowed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFUmz96zRXzfypG4Fa9BXJ
@os-zhuang
os-zhuang marked this pull request as ready for review July 28, 2026 02:01
@os-zhuang
os-zhuang merged commit c5ff96d into main Jul 28, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the claude/schedule-run-locked-record-write-r50e87 branch July 28, 2026 02:13
os-zhuang added a commit that referenced this pull request Jul 28, 2026
…) (#3784)

An effective `runAs:'user'` run that resolves no trigger user executed its
data nodes UNSCOPED: it presented no principal, and the data security
middleware skips when there is no principal, so the run read and wrote every
row. `runAs:'user'` is an access-NARROWING declaration, and ADR-0049's standing
rule is that failing to resolve one must never resolve to a grant. It now
throws `UnscopedRunDataAccessError` from `resolveRunDataContext` — the single
place every data node resolves its context.

This was never really about schedules. The docs, the spec, the runtime warning
and the lint all described a schedule-shaped problem, and the lint only matched
that shape, but the runtime predicate is "no user". The commonest way to have
no user is a record-change flow fired by a write that carried none: `isSystem`
does not suppress trigger dispatch — only `skipTriggers` does, and three
first-party paths set it — so plugin/service system writes, the approvals
status mirror, and a `runAs:'system'` flow's own data node all dispatched
record-change flows with `userId: undefined`. Ordinary users reach those writes
routinely, so the fail-open was reachable by unprivileged input.

Deliberately NOT implemented as "inherit the write's posture and run as
isSystem". That reads like a relabel but is an escalation: the middleware's
isSystem short-circuit (security-plugin.ts:722) fires before the
package-managed-row, system-row, audience-anchor and delegated-admin gates
(795/809/817/844), all of which a principal-less context still clears. Such a
run cannot write sys_user_position today; as isSystem it could.

- Lint `flow-schedule-runas-unscoped` -> `flow-runas-unscoped`, now FAILS the
  build and covers time_relative + api (ADR-0073 D5). It documented itself as
  "NEVER fails the build" — a gate that behaved as a comment. It still cannot
  cover record_change, which is undecidable at authoring time.
- Three seed writes (seed-loader pass-2 back-fill, both app-plugin fallbacks)
  inlined a bare `{isSystem:true}` and so seeded with automation live.
- #3712's user-less provenance path is subsumed: such runs are refused before
  the approvals lock is consulted, and a schedule reaches its own record via
  `runAs:'system'`. The flowRunId exemption stays live for runs with a user.
- ADR-0073 amended: its "no untrusted-input path" severity claim is falsified,
  and its rejection of fail-closed expired when the example flows it cited were
  fixed to declare `runAs:'system'`.

Both new gates were verified to go red against the pre-fix behavior, including
the live dogfood stack, which previously pinned the fail-open explicitly.

Refs #1888, #3712, #3749, #3456, ADR-0049, ADR-0073.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file 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 schedule-triggered run still can't write its own locked record — it carries no ObjectQL context to hold flowRunId (#3456 residual)

2 participants