Skip to content

Commit 1ee1d1b

Browse files
feat(runtime,cli): a hook body reads ctx.provenance, so a flow-vs-user guard ships as metadata (#25952)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DJpRLxH24N6TCGQQWmVvX3L)_ Fixes #25905 Clause-②: yes (widening) Metadata-protocol: none ## What changed A sandboxed hook body now reads the declared `HookContext.provenance`, the same server-stamped write provenance an in-process handler reads. A guard that tells a flow data node's write from a user's own write (hotcrm#2285's account-approval guard) can ship as a metadata-only body instead of being bundled. - **Runtime, `buildSandboxContext`** (`packages/runtime/src/sandbox/body-runner.ts`, hook face only): projects `provenance` member by member: `flowRunId`, `attributedUserId`, `performedByClientId`. Each member is copied only as a string. The whole key is absent when none survives, which is what the engine face carries on a plain REST write. The source is objectql's `buildProvenance()` output on the hook context, never the payload. The action face (`buildActionSandboxContext`) is untouched. - **Runtime, `installCtx`** (`quickjs-runner.ts`): grafts `ctx.provenance` frozen and non-writable, the same way `ctx.dispatch` is grafted, so a body cannot drift its own copy. It is installed only when the host side carried one. - **Runtime, `ScriptContext`** (`script-runner.ts`): the `provenance` member is typed and documented. - **CLI, `extract-hook-body`**: the `ctx.provenance` forbidden pattern is removed. A handler reading it lowers, and `os lint` no longer reports `hook-body/bundled-fallback` for it. - **Docs**: `content/docs/automation/hook-bodies.mdx` gets a sandbox-surface row, a "Write provenance" section (members, absence semantics, a guard example, the two guarantees), and drops provenance from the forbidden list. - **Changesets**: two patch entries. `@objectstack/runtime` declares `Clause-②: yes (widening)`: `ScriptContext` (a barrel export) gains the optional `provenance?` member; `dist/index.d.ts` goes 338119 → 339467 bytes, its export list is byte-identical (184 names), and that member is the only declaration that changed. `@objectstack/cli` declares `Clause-②: no`: its three `dist/*.d.ts` are byte-identical. - **No `packages/spec` change.** The key was already declared on `HookContextSchema`. This PR makes the sandbox honour it. ## Tests - New `packages/runtime/src/sandbox/hook-provenance-signal.integration.test.ts` runs on real ObjectQL, real SqlDriver (better-sqlite3), real QuickJS and `hookBodyRunnerFactory`. Under the same caller identity it checks three things: - the flow-shaped write (execution context carries `flowRunId`) reads `ctx.provenance.flowRunId === 'run_25905'`, with keys `['flowRunId']`, after the body tried to drift the member and replace the key; - the REST write reads `typeof ctx.provenance === 'undefined'`; - a REST write whose payload carries a field `provenance` holding a forged JSON value shows it on `ctx.input.provenance` only, while `ctx.provenance` stays absent. - `packages/cli/test/lowered-body-face-enumeration.test.ts`: `provenance` leaves the absent set (it now reads 4 refusals and 1 exception). Both faces are pinned to the three declared members. - `packages/cli/test/hook-body-parameter-rebind.test.ts`: the three `ctx.provenance` spellings move from "refused" to "lowers". - `packages/cli/src/lint/hook-body-lowering.test.ts`: a guard on `ctx.provenance?.flowRunId` gets no lint issue, and the build lowers it (`bodyExtracted: 1`, no warnings). ### Ablations (two, both via `scripts/ablation-replace.mjs`, restore proven by blob == HEAD and an empty `git diff HEAD`) 1. **Negative (forgery) pin.** `buildSandboxContext` was mutated to fall back to `JSON.parse(engineCtx.input.provenance)`, i.e. a consumer reading the payload. The pin went red at `hook-provenance-signal.integration.test.ts:165` (`expected 'object' to be 'undefined'`). Anchor 1 to 0, blob `58df4905a51e` to `81882a7eb7e0`, restored to `58df4905a51e`. 2. **Freeze.** The graft's `Object.freeze(p)` / `writable: false` was mutated to a plain writable value. The test went red at `:139` (`expected 'replaced' to be 'run_25905'`). Blob `04f49a96f07d` to `7c20c0ada09a`, restored. The test imports `./body-runner.js` and `./quickjs-runner.js` from `src`, so no dist rebuild was involved. ## Local verification (head `df1c7164`; `256f0583` adds changesets only) - `pnpm --filter @objectstack/runtime exec vitest run`: Test Files 449 passed (449), Tests 8015 passed, 19 skipped. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 330 of 332 files passed on the first run. The two failures were `published-subpath-console.pin` and `published-subpath-hook-body.pin`, which threw "packages/cli is not built". After `pnpm --filter @objectstack/cli build` both passed (29 tests). The integration tier is declared to CI. - `pnpm --filter @objectstack/runtime --filter @objectstack/cli typecheck`: exit 0. - `pnpm lint` (repo-wide eslint): exit 0. - Derived gates (`dispatch-gates --commands`, 98 families): 95 exit 0. `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity` first exited 3 (CLI dist absent) and exit 0 after the CLI build. `--ran` reconciliation: 98 derived, 98 run. - NOT MEASURED (exit 3, prerequisite only, none reads a changed file): - `check:dual-build-cjs-loads`: unbuilt plugin dists; - `@objectstack/spec check:skill-examples`: client-react dist absent; - `check-plugin-teardown-shape --self-test`: shallow clone, fixture commit unreachable. CI runs all three. ## Acceptance notes - A body may still define `ctx.provenance` itself on a write that carries none (the key is absent there, as with `ctx.dispatch`). That only affects the body's own VM. Nothing is written back from it, and it cannot reach another hook or the engine. - `attributedUserId` and `performedByClientId` are exposed alongside `flowRunId`. All three are declared, server-stamped attribution marks. `session.userId` is already on the body face. - #25653 (seat 1) edits the action face of `body-runner.ts`. This PR touches only the hook face's `buildSandboxContext` plus a new helper below it. Whichever lands second merges `main`. --- _Generated by [Claude Code](https://claude.ai/code/session_0144xurUaQ5v2UtFYLpYp28J)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1ac2bb5 commit 1ee1d1b

11 files changed

Lines changed: 367 additions & 30 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): `objectstack build` lowers a hook handler that reads `ctx.provenance` instead of bundling it
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The sandboxed hook body now carries `ctx.provenance` (see the `@objectstack/runtime` entry), so the lowerer no longer refuses a handler that reads it. Such a handler ships as a metadata-only body, and `os lint` no longer reports `hook-body/bundled-fallback` for that read. The CLI's published type surface does not change: `dist/index.d.ts`, `dist/hook-body.d.ts` and `dist/console.d.ts` are byte-identical before and after. Nothing to migrate: a handler that read `ctx.provenance` was bundled before and runs in-process, where it behaves the same. Rebuilding turns it into a body.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
feat(runtime): a hook body reads `ctx.provenance`, so a guard that tells a flow's write from a user's own write ships as metadata
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
The sandboxed hook body now carries the declared `HookContext.provenance`, the same server-stamped write provenance an in-process handler reads. On a write from a flow data node, `ctx.provenance.flowRunId` is that flow run's id. `attributedUserId` and `performedByClientId` ride along when the engine stamps them. Each member is a string or absent, and `ctx.provenance` itself is absent on a write with no origin, such as a plain REST write. Read it as `ctx.provenance?.flowRunId`. The object is frozen in the VM. It is built from the server-side execution context, so a payload field named `provenance` stays on `ctx.input` and never reaches it.
11+
12+
The widening is on the published type surface. `ScriptContext`, exported from the `@objectstack/runtime` barrel, gains one optional member: `provenance?: { flowRunId?: string; attributedUserId?: string; performedByClientId?: string }`. Measured on `dist/index.d.ts` before and after the change:
13+
- the file grows from 338119 to 339467 bytes;
14+
- the export list is byte-identical, with the same 184 names;
15+
- the only declaration that changes is that one member and its doc comment.
16+
17+
Nothing to migrate: the member is optional, and a `ScriptRunner` that ignores it behaves exactly as before.

‎content/docs/automation/hook-bodies.mdx‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ The script sees only what the surrounding `ctx` object exposes:
9898
| `ctx.input` | Mutable record being inserted/updated/etc. | none |
9999
| `ctx.previous` | Pre-update record (update events only). | none |
100100
| `ctx.user` / `ctx.session` | Identity context. | none |
101+
| `ctx.provenance` | Where the write came from (hook bodies only) — see [Write provenance](#write-provenance--ctxprovenance). Absent on a write with no origin. | none |
101102
| `ctx.api.object(name).find\|count\|aggregate` | Cross-object reads, scoped to current tenant. | `api.read` |
102103
| `ctx.api.object(name).insert\|update\|delete` | Cross-object writes. | `api.write` |
103104
| `ctx.title()` | This record's title — the object's `nameField`, **including when it is a `formula`** (evaluated server-side against the record already in hand: no extra read over the record's own columns, one read per lookup a title such as `record.book.title + ' · ' + record.reader.name` reads through). | none |
@@ -106,6 +107,34 @@ The script sees only what the surrounding `ctx` object exposes:
106107
| `ctx.log.{info,warn,error}` | Structured logging. | `log` |
107108
| `ctx.connector(name).<method>(...)` _(planned)_ | Outbound HTTP / SaaS calls. **Not yet wired into the sandbox** — ships with the separate Connector spec. | (separate Connector spec) |
108109

110+
### Write provenance — `ctx.provenance`
111+
112+
A hook body sees the same server-stamped write provenance an in-process handler does — the declared [`HookContext.provenance`](/docs/references/data/hook), copied into the sandbox member by member:
113+
114+
| Member | Present when |
115+
|---|---|
116+
| `ctx.provenance.flowRunId` | The write comes from an automation flow's data node; the value is that flow run's id. |
117+
| `ctx.provenance.attributedUserId` | The write is authorized as the system and credited to a real person (for example, the admin behind an identity-adapter call). |
118+
| `ctx.provenance.performedByClientId` | An AI client acting through the MCP OAuth door performed the write on behalf of the user in `ctx.session.userId`. |
119+
120+
Each member is a string or absent, and `ctx.provenance` itself is **absent** when none applies — a plain REST write carries no provenance — so read it as `ctx.provenance?.flowRunId`. That is the guard that tells a flow's write apart from a user writing the same fields by hand, which matters most for a flow that runs `runAs: 'user'`: there `ctx.user`, `ctx.session` and `ctx.input` are identical for both writes.
121+
122+
```ts
123+
// Only lead conversion may set `converted_from_lead`.
124+
handler: (ctx) => {
125+
if (ctx.input.converted_from_lead && !ctx.provenance?.flowRunId) {
126+
throw new Error('converted_from_lead is set by lead conversion only');
127+
}
128+
},
129+
```
130+
131+
Two guarantees hold, so the mark is safe to build a guard on:
132+
133+
- **It cannot be forged through the data API.** The engine builds it from the server-side execution context, which a transport assembles from the authenticated session and never from request input (the automation engine sets `flowRunId` on its own data nodes); a payload field that happens to be named `provenance` stays a field on `ctx.input`.
134+
- **It is read-only in the sandbox.** The object is frozen and the key is not writable, so a body cannot change its own copy.
135+
136+
It is a signal for app code, not an authorization input: no security middleware reads it, and each write is still authorized as its caller. Action bodies do not carry it.
137+
109138
### Naming a record — `ctx.title()`
110139

111140
A body composing a message needs the record's **name**, and until this accessor
@@ -176,7 +205,7 @@ The CLI builder **rejects** any source that uses:
176205
- references to identifiers from value-only top-level imports
177206
- `.sudo(` and `.create(` — members that are real on the **in-process** `ScopedContext` / `ObjectRepository` and absent from the VM's `ctx.api`, so lowering them would ship a body that `TypeError`s on its first run. Write `.insert({ ... })` instead of `.create({ ... })` — it is the only insert verb the `IScopedObjectRepository` contract declares — and reach for [`runAs: 'system'`](/docs/automation/hooks#elevation--runas) instead of `.sudo()`. `Object.create()` is a real sandbox global and is **not** affected.
178207
- `ctx.dispatch.scope` and `ctx.submitted` — context members the **in-process** `HookContext` carries and the body face deliberately does not: a body's `ctx.dispatch` is `{ mode, index }` only, and it has no `ctx.submitted`. Lowered, a write through `scope` `TypeError`s on its first run and a read of `submitted` sees `undefined`, so a guard on it evaluates as if nothing were submitted. For cross-dispatch work, do it once at `ctx.dispatch.index === 0` or keep the state in the record through `ctx.api`; to derive from a read-only field, read the stored row on `ctx.previous`. `ctx.dispatch.mode` / `ctx.dispatch.index`, and a field that merely happens to be named `scope` or `submitted` (`ctx.input.scope`), are **not** affected.
179-
- `ctx.provenance`, `ctx.ql` and `ctx.transaction` (hook handlers) — the rest of the in-process `HookContext` the body face does not carry. A body reads `ctx.provenance` as `undefined`, so a guard on `ctx.provenance.flowRunId` evaluates as if the write had no origin; `ctx.ql` (the engine) and `ctx.transaction` (a driver handle) are live host objects no copy into the sandbox can carry. Reach other objects through `ctx.api.object(name)`, and group writes with `ctx.api.transaction(fn)`, which is **not** affected. The enumeration behind this list is derived, not remembered: a test compares every member `HookContextSchema` declares against every member the sandbox marshals, and fails on one that is neither refused here nor recorded as a deliberate exception.
208+
- `ctx.ql` and `ctx.transaction` (hook handlers) — the rest of the in-process `HookContext` the body face does not carry. `ctx.ql` (the engine) and `ctx.transaction` (a driver handle) are live host objects no copy into the sandbox can carry. Reach other objects through `ctx.api.object(name)`, and group writes with `ctx.api.transaction(fn)`, which is **not** affected. The enumeration behind this list is derived, not remembered: a test compares every member `HookContextSchema` declares against every member the sandbox marshals, and fails on one that is neither refused here nor recorded as a deliberate exception.
180209

181210
Like `.sudo(` and `.create(`, these refuse the **body**, not the handler: `objectstack build` bundles the handler into its runtime module (`objectstack-runtime.*.mjs`) instead and still exits 0 (with a warning naming the hook), `os lint` reports it as `hook-body/bundled-fallback`, and `objectstack build --strict-body` fails on it. The bundled handler runs in-process wherever that module is served beside `objectstack.json`; a deployment that serves the artifact alone has no function to bind the hook to, so it logs the hook as refused at boot and the hook never fires — build with `--strict-body` when the artifact must be body-only.
182211

‎packages/cli/src/lint/hook-body-lowering.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,34 @@ describe('checkHookBodyLowering', () => {
265265
});
266266
});
267267

268+
// [#25905] `ctx.provenance` used to sit in the family above. The body face now
269+
// carries it, so a guard telling a flow data node's write from a user's own
270+
// write ships as a metadata-only body and lint has nothing to say about it.
271+
describe('a body reading the write provenance the sandbox carries (#25905)', () => {
272+
const flowOnlyHook = {
273+
name: 'account_protection',
274+
object: 'account',
275+
events: ['beforeInsert', 'beforeUpdate'],
276+
handler: (ctx: any) => {
277+
if (ctx.input.converted_from_lead && !ctx.provenance?.flowRunId) {
278+
throw new Error('converted_from_lead is set by lead conversion only');
279+
}
280+
},
281+
};
282+
283+
it('reports nothing for it, the bundled-fallback rule included', () => {
284+
expect(checkHookBodyLowering({ hooks: [flowOnlyHook] })).toEqual([]);
285+
});
286+
287+
it('and the build lowers it to a body that reads the member', () => {
288+
const lowering = lowerCallables({ hooks: [{ ...flowOnlyHook }] });
289+
expect(lowering.bodyExtractionWarnings).toEqual([]);
290+
expect(lowering.bodyExtracted).toBe(1);
291+
const lowered = (lowering.lowered.hooks as Array<Record<string, unknown>>)[0];
292+
expect((lowered.body as { source: string }).source).toContain('ctx.provenance?.flowRunId');
293+
});
294+
});
295+
268296
it('truncates the multi-line offending-source dump to one line', () => {
269297
const [issue] = checkHookBodyLowering({ hooks: [forbiddenTokenHook] });
270298
expect(issue.message).not.toContain('--- offending body source ---');

‎packages/cli/src/utils/extract-hook-body.ts‎

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -474,21 +474,13 @@ const FORBIDDEN_PATTERNS: Array<{ rx: RegExp; reason: string; face?: LoweringFac
474474
// are measured against the HOOK context; the action face's lowering is
475475
// unchanged.
476476
//
477-
// `provenance` is the silent half, like `submitted`: a guard on
478-
// `ctx.provenance?.flowRunId` reads `undefined` once lowered and evaluates as
479-
// if the write had no origin. ⛔ Marshalling it into the VM instead is a body
480-
// contract decision (the body face is declared key by key), not a fix to make
481-
// here — the same posture `dispatch.scope` records above.
482-
{
483-
rx: hookContextMemberRx('provenance'),
484-
face: 'hook',
485-
reason:
486-
'`ctx.provenance` is not reachable from a sandboxed body — the VM\'s `ctx` is assembled key by key '
487-
+ 'and does not carry the write\'s provenance, so it reads `undefined` there: a guard written on '
488-
+ '`ctx.provenance.flowRunId` or `ctx.provenance.attributedUserId` evaluates as if the write had no '
489-
+ 'origin, and reaching into it is a TypeError at run time. A body has no equivalent today; leave '
490-
+ 'this handler bundled so it runs in-process, where `ctx.provenance` carries the server-stamped marks',
491-
},
477+
// `provenance` WAS the silent half of this family, like `submitted`, and is
478+
// no longer a member of it: since #25905 `buildSandboxContext` carries the
479+
// declared `HookContextSchema.provenance` across the sandbox boundary (its
480+
// three members, each a string or absent, frozen in the VM), so a body reads
481+
// `ctx.provenance?.flowRunId` exactly as an in-process handler does and a
482+
// handler reading it lowers. The enumeration pin
483+
// (test/lowered-body-face-enumeration.test.ts) holds that classification.
492484
// `ql` and `transaction` are live host objects — the engine, a driver
493485
// transaction handle — that a copy into the VM heap cannot carry, the
494486
// `.sudo(` shape one level up: real in-process, absent from the body.

‎packages/cli/test/hook-body-parameter-rebind.test.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,9 +296,6 @@ describe('capability inference follows the rebound names', () => {
296296

297297
describe('the hook-context members the enumeration found unrefused (#22837)', () => {
298298
it.each([
299-
['provenance', async (ctx: any) => { if (ctx.provenance?.flowRunId) ctx.input.note = 'flow'; }],
300-
['provenance', async (ctx: any) => { const { provenance } = ctx; ctx.input.note = String(provenance); }],
301-
['provenance', async ({ input, provenance }: any) => { input.note = String(provenance?.attributedUserId); }],
302299
['ql', async (ctx: any) => { await ctx.ql.find('t', {}); }],
303300
['ql', async (ctx: any) => { const c = ctx; await c['ql'].find('t', {}); }],
304301
['transaction', async (ctx: any) => { ctx.input.note = ctx.transaction ? 'tx' : 'none'; }],
@@ -309,6 +306,17 @@ describe('the hook-context members the enumeration found unrefused (#22837)', ()
309306
expect(err.message).toContain(`\`ctx.${member}\``);
310307
});
311308

309+
// [#25905] `provenance` left this family: the body face now carries it, so
310+
// every spelling that used to be refused lowers — the parameter-destructured
311+
// one through the rebind prologue, like any other carried member.
312+
it.each([
313+
async (ctx: any) => { if (ctx.provenance?.flowRunId) ctx.input.note = 'flow'; },
314+
async (ctx: any) => { const { provenance } = ctx; ctx.input.note = String(provenance); },
315+
async ({ input, provenance }: any) => { input.note = String(provenance?.attributedUserId); },
316+
])('lowers a `ctx.provenance` read (%#), which the body face carries', (fn) => {
317+
expect(() => lower(fn)).not.toThrow();
318+
});
319+
312320
it('does NOT refuse the body face\'s own transaction route, or a field of the same name', () => {
313321
const lowered: Fn[] = [
314322
async (ctx: any) => { await ctx.api.transaction(async () => { await ctx.api.object('t').insert({}); }); },

‎packages/cli/test/lowered-body-face-enumeration.test.ts‎

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,9 @@ describe('the lowered body face, against the in-process HookContext (#22837)', (
279279
expect(inProcess.get('dispatch')).toEqual(new Set(['mode', 'index', 'scope']));
280280
expect(body.get('dispatch')).toEqual(new Set(['mode', 'index']));
281281
expect(body.get('session')).toBe('passthrough');
282+
// [#25905] Carried member by member, so a member the spec adds to it later is seen as absent.
283+
expect(inProcess.get('provenance')).toEqual(new Set(['flowRunId', 'attributedUserId', 'performedByClientId']));
284+
expect(body.get('provenance')).toEqual(new Set(['flowRunId', 'attributedUserId', 'performedByClientId']));
282285
// A tombstone is no member: `session.roles` is `retiredKey(…)`.
283286
expect(inProcess.get('session')).not.toContain('roles');
284287
expect(absent.length).toBeGreaterThan(0);
@@ -306,16 +309,11 @@ describe('the lowered body face, against the in-process HookContext (#22837)', (
306309
}
307310
});
308311

309-
// The measured state on the base of #22837, so a reader sees the classification
310-
// without running the derivation — and so a member moving class is a visible diff.
311-
it('classifies today: one exception, five refusals', () => {
312-
expect(absent).toEqual(['dispatch.scope', 'id', 'provenance', 'ql', 'submitted', 'transaction']);
313-
expect(absent.filter((p) => refusalOf(p) !== null)).toEqual([
314-
'dispatch.scope',
315-
'provenance',
316-
'ql',
317-
'submitted',
318-
'transaction',
319-
]);
312+
// The measured state, so a reader sees the classification without running the
313+
// derivation — and so a member moving class is a visible diff. `provenance`
314+
// left the absent set at #25905, when the body face started carrying it.
315+
it('classifies today: one exception, four refusals', () => {
316+
expect(absent).toEqual(['dispatch.scope', 'id', 'ql', 'submitted', 'transaction']);
317+
expect(absent.filter((p) => refusalOf(p) !== null)).toEqual(['dispatch.scope', 'ql', 'submitted', 'transaction']);
320318
});
321319
});

‎packages/runtime/src/sandbox/body-runner.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,6 +1206,22 @@ function buildSandboxContext(
12061206
if ('where' in bag) inputOptions.where = bag.where;
12071207
}
12081208

1209+
// [#25905] The declared write provenance (`HookContextSchema.provenance`),
1210+
// carried across the sandbox boundary BY CONTRACT, so a lowered body can tell
1211+
// a flow data node's write (`flowRunId`) from a user's own write exactly as an
1212+
// in-process handler does. Projected member by member, each in its declared
1213+
// shape (a string) only — the same unrecognised-shape rule as `dispatch`
1214+
// above — and the whole key left ABSENT when nothing survives, which is what
1215+
// the engine face carries on a write with no origin. The source is the
1216+
// engine's own `buildProvenance()` output, read off the execution context and
1217+
// never off the payload, so nothing a request submits reaches it.
1218+
const provenanceRaw = engineCtx?.provenance;
1219+
const provenance = declaredStringMarks({
1220+
flowRunId: provenanceRaw?.flowRunId,
1221+
attributedUserId: provenanceRaw?.attributedUserId,
1222+
performedByClientId: provenanceRaw?.performedByClientId,
1223+
});
1224+
12091225
return {
12101226
input: inputSnapshot ?? {},
12111227
// Preserve `undefined` for `previous` on insert events so hooks can
@@ -1255,10 +1271,27 @@ function buildSandboxContext(
12551271
// keeps no envelope key for this fact at all, so `ctx.api` carries nothing
12561272
// a body could sniff instead.
12571273
...(engineCtx?.referentialCascadeDelete === true ? { referentialCascadeDelete: true } : {}),
1274+
provenance,
12581275
crypto: globalThis.crypto,
12591276
};
12601277
}
12611278

1279+
/**
1280+
* The string-valued members of `marks`, or `undefined` when none is a string —
1281+
* so a member in any other shape is left absent rather than guessed at, and an
1282+
* all-absent envelope is no envelope at all.
1283+
*/
1284+
function declaredStringMarks<K extends string>(
1285+
marks: Record<K, unknown>,
1286+
): Partial<Record<K, string>> | undefined {
1287+
const out: Partial<Record<K, string>> = {};
1288+
for (const key of Object.keys(marks) as K[]) {
1289+
const value = marks[key];
1290+
if (typeof value === 'string') out[key] = value;
1291+
}
1292+
return Object.keys(out).length > 0 ? out : undefined;
1293+
}
1294+
12621295
function buildActionSandboxContext(
12631296
actionCtx: any,
12641297
ql: any,

0 commit comments

Comments
 (0)