Skip to content

fix(runtime): bind action-body ctx.api/ctx.engine to a real execution context (#3914) - #3922

Merged
os-zhuang merged 3 commits into
mainfrom
claude/clever-cannon-l436o0
Jul 29, 2026
Merged

fix(runtime): bind action-body ctx.api/ctx.engine to a real execution context (#3914)#3922
os-zhuang merged 3 commits into
mainfrom
claude/clever-cannon-l436o0

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #3914.

The defect

An action body's ctx.api was never bound. buildSandboxApi walked its whole 3-way fallback — no actionCtx.api, and the raw ObjectQL engine has no .object() (that lives on ScopedContext, reachable only via engine.createContext(), which the action path never called) — and landed on a repo facade that proxied every call to the engine with no context. ctx.engine (buildActionEngineFacade) had the identical hole.

Context-less is not "trusted", it is identity-less, and identity-less is strictly worse than either coherent posture:

  • plugin-sharing's canEdit short-circuits on !context.userId — there is no user to own anything;
  • its bypass needs context.isSystem — which was never set.

So every owner-scoped write from an action body died FORBIDDEN: insufficient privileges to update <object> — as the built-in admin — while the [action-audit] line on the same request announced RLS-bypassing TRUSTED execution. Objects with a public sharing model, no owner field, or a bypass listing passed the gate early, which is why only some actions broke and the defect read as object-dependent flakiness.

The fix

Both dispatch paths — REST /actions/:object/:action and MCP run_action — now bind ctx.api to engine.createContext(...) and thread the same envelope through ctx.engine, matching what hook bodies already get from the engine's buildHookApi.

The envelope (buildActionExecutionContext) is the caller's ExecutionContext elevated with isSystem: true. Of the two coherent options the issue offered, this is the one that matches the documented model: elevation is the posture #2849 already documents and gates for at invoke time (capability gate + ai.exposed), so RLS-scoping instead would silently break bodies that legitimately act beyond the caller.

One refinement on the bare { isSystem: true } that hooks fall back to — the caller's fields are spread first, so writes stay:

  • attributable — userId stamps created_by / updated_by;
  • org-scoped — tenantId stamps the org column and drives driver-level tenant isolation;
  • transactional — an open transaction handle is joined,

instead of the unattributable, org-less rows bare elevation would write.

The sandbox's last-resort repo facade is elevated from the same envelope via ctx.executionContext, so a host predating createContext is not left proxying context-less either. The [action-audit] wording now describes what the body actually gets ("system-elevated context") rather than the false "context-less engine".

Changes

File Change
packages/runtime/src/action-execution.ts New buildActionExecutionContext + buildActionApi; buildActionEngineFacade takes the caller envelope and threads context through every verb; MCP actionContext gains api + executionContext; audit line + stale security-model comments corrected
packages/runtime/src/domains/actions.ts REST path drops its duplicated inline facade for the shared (context-carrying) one, gains api + executionContext, audit line corrected
packages/runtime/src/sandbox/body-runner.ts buildSandboxApi prefers ql.createContext(ctx.executionContext); the last-resort repo facade threads that context through every call
packages/runtime/src/action-body-identity.test.ts New — 15 regression tests
content/docs/ui/actions.mdx The trusted-facade callout named only engine; now covers ctx.api and states the elevation posture

Verification

The regression tests use an engine stub that refuses a write carrying neither userId nor isSystem, the way the real sharing gate does. Reverting just the domain wiring reproduces the reported failure verbatim:

FAIL src/action-body-identity.test.ts > REST /actions dispatch binds ctx.api and ctx.engine
Error: FORBIDDEN: insufficient privileges to update crm_case

With the fix, full monorepo suite green — 132/132 turbo tasks, including runtime (735 tests), objectql (1163), and the dogfood suite (395 passed / 3 skipped). ESLint clean on the changed files.

Out of scope


Generated by Claude Code

… context (#3914)

An action body's `ctx.api` was never bound. `buildSandboxApi` fell through
its 3-way fallback — no `actionCtx.api`, and the raw ObjectQL engine has no
`.object()` (that lives on `ScopedContext`, reachable only via
`engine.createContext()`, which the action path never called) — and landed on
a repo facade that proxied every call to the engine with NO `context`.
`ctx.engine` had the identical hole.

Context-less is not "trusted", it is identity-less, and identity-less is
strictly worse than either coherent posture: plugin-sharing's write gate
short-circuits on `!context.userId` and its bypass needs `context.isSystem`,
so every owner-scoped write from an action body died `FORBIDDEN` — as the
built-in admin — while the `[action-audit]` line on the same request
announced RLS-bypassing TRUSTED execution. Objects with a `public` sharing
model, no owner field, or a bypass listing passed the gate early, which is
why only some actions broke and it read as object-dependent flakiness.

Both dispatch paths (REST `/actions/:object/:action` and MCP `run_action`)
now bind `ctx.api` to `engine.createContext(...)` and thread the same
envelope through `ctx.engine`, matching what hook bodies already get from
`buildHookApi`. The envelope is the caller's ExecutionContext elevated with
`isSystem: true`: elevation is the posture #2849 documents and gates for at
invoke time, and spreading the caller's fields first keeps writes
attributable (`created_by`/`updated_by`), org-scoped (`tenantId`), and joined
to an open transaction — rather than the unattributable, org-less rows a bare
`{ isSystem: true }` would write. The audit line's wording now describes what
the body actually gets.

The sandbox's last-resort repo facade is elevated from the same envelope via
`ctx.executionContext`, so a host that predates `createContext` is not left
proxying context-less.

Regression tests use a sharing-shaped engine stub that denies a context-less
write the way the real gate does; reverting the wiring reproduces the reported
`FORBIDDEN: insufficient privileges to update crm_case`.

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

vercel Bot commented Jul 29, 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 29, 2026 9:43am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests size/l labels Jul 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime.

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

  • content/docs/api/client-sdk.mdx (via packages/runtime)
  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime)

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 29, 2026 09:29
…ntext fix (#3914)

The Check Changeset gate requires every PR to add one; a runtime behavior fix
this visible warrants a real release note rather than an empty declaration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TufbW1LBcSwpta4gfX7Uso
#3919 (REST action-type dispatch, #3915) landed on main and touched the same
handler. Resolved `packages/runtime/src/domains/actions.ts` to keep both:

- main's type dispatch (flow → automation service, url/modal/form/api → 400)
  and its relocation of the `[action-audit]` line into the try block, after
  the flow branch — a flow is not trusted-elevated, so it must not print it;
- this branch's #3914 wiring — `ctx.api`, `ctx.executionContext`, and the
  shared context-carrying `buildActionEngineFacade` on the action context —
  plus the corrected audit wording, now applied to main's relocated line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TufbW1LBcSwpta4gfX7Uso
@os-zhuang
os-zhuang merged commit bc35e00 into main Jul 29, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/clever-cannon-l436o0 branch July 29, 2026 09:56
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 size/l tests tooling

Projects

None yet

2 participants