Skip to content

Commit a4e2684

Browse files
os-zhuangclaude
andauthored
feat(runtime): action body 被丢弃的 ctx.record 写在调用时可见 (#4345) (#4357)
* feat(runtime,lint,spec): an action body's ctx.record writes are reported, not silently discarded (#4345) `ctx.record` in an L2 action body is a pre-fetched snapshot that nothing writes back, so every assignment to it was dropped — a correctly spelled, fully declared field exactly like an unknown one — while the action returned success. No diagnostic anywhere: the #4001 "silent no-op manufactures false completion" shape, one layer below the unknown-column drop #4271 covers. The snapshot stays read-only. An action's output is its return value and its write channel is `ctx.api`; making the snapshot writable would raise questions this bug does not answer (write back to what, under whose permissions, what `requiresRecord: false` means). What was wrong was the silence. Runtime: the sandbox installs a set/deleteProperty/defineProperty proxy over the snapshot, behind an accessor so a wholesale `ctx.record = {…}` cannot swap the recorder out, and reports the touched keys as `ScriptResult.droppedRecordWrites`. `actionBodyRunnerFactory` warns naming the discarded fields and the `ctx.api.object(...).update(...)` remedy. Writes still work inside the VM, so a body using the snapshot as scratch keeps its reads coherent — only the silence is removed. As a run-time trap it sees the computed keys, `Object.assign` and aliases static analysis cannot, and it covers metadata authored through Studio or the API, which no lint inspects. Hooks carry no `record` and install no proxy. Lint: new advisory rule `action-body-record-write-discarded` (`validateActionRecordWrites`) in REFERENCE_INTEGRITY_RULES, so `os validate`, `os lint` and `os compile` all report it. It never consults declared fields and offers no did-you-mean — the field name is not the bug — and it dedupes defineStack's merged action copies by value, not identity, so one authored action reports once. Advisory because a body may use the snapshot as local scratch, which no analysis short of data-flow tells from an intended persist. Docs: `ScriptContext.record`, `ActionSchema.body`, `ScriptBodySchema`, `content/docs/ui/actions.mdx` and `content/docs/automation/hook-bodies.mdx` now state the read-only semantics and name the `ctx.input` analogy as the trap it is. Verified on the showcase app: zero findings before, exactly one after planting the issue's repro — naming both declared fields and proposing the very `ctx.api` call it replaced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(actions): 说明两端都只报到不了库的写;活写模式保持安静 (#4345) --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent a13827e commit a4e2684

8 files changed

Lines changed: 546 additions & 4 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): the sandbox reports an action body's discarded `ctx.record` writes at invocation time (#4345)
6+
7+
#4362 closed the author-time half of #4345: `action-record-write-discarded`
8+
warns when a body assigns to `ctx.record` and the snapshot is provably dead.
9+
This is the run-time half, and it exists because a parse cannot reach three
10+
things a running action can:
11+
12+
- **computed keys and aliases**`ctx.record[k] = v`, `const r = ctx.record;
13+
r.x = 1`, which the lint deliberately skips rather than guess at;
14+
- **a wholesale replacement**`ctx.record = {…}`;
15+
- **bodies no lint ever sees** — metadata authored through Studio or the API
16+
never passes through `os validate` / `os lint` / `os compile`.
17+
18+
The sandbox installs a `set`/`deleteProperty`/`defineProperty` proxy over the
19+
snapshot, behind an accessor so a wholesale replacement cannot swap the recorder
20+
out, and surfaces the touched keys as `ScriptResult.droppedRecordWrites`.
21+
`actionBodyRunnerFactory` logs a warning naming the discarded fields and the
22+
`ctx.api.object(...).update(...)` remedy. Writes still work *inside* the VM, so
23+
a body using the snapshot as scratch keeps its reads coherent — only the silence
24+
is removed.
25+
26+
**Only dead writes are reported**, on the same reading #4362 uses: a snapshot
27+
that leaves the body as a value may have carried the write with it, so
28+
29+
```js
30+
ctx.record.stage = 'won';
31+
await ctx.api.object('crm_deal').update(ctx.record); // lands — stays quiet
32+
```
33+
34+
is not reported, while a plain property read does not rescue a write (the
35+
`ctx.recordId || (ctx.record && ctx.record.id)` guard idiom real action bodies
36+
are written with still reports). An `ownKeys` after a write marks the escape.
37+
A wrong "discarded" asserts something false about the stored record, which is
38+
worse than a miss.
39+
40+
Hooks carry no `record`, so they install no proxy and pay nothing. `ctx.record`
41+
remains read-only; whether the runtime should instead refuse or honour the write
42+
is still open — reporting a discard prejudges neither answer.

content/docs/ui/actions.mdx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,40 @@ export const MarkDoneAction = defineAction({
9999

100100
Body-carrying actions are **registered automatically** at boot.
101101

102+
<Callout type="warn">
103+
**`ctx.record` is read-only — persist through `ctx.api`.**
104+
105+
`ctx.record` is the record the dispatcher pre-fetched before the action ran: a
106+
snapshot the runtime never writes back. Assigning to it changes a copy that
107+
dies with the sandbox, and the action still returns success:
108+
109+
```js
110+
ctx.record.done = true; // ❌ discarded — even though `done` is a declared field
111+
return { ok: true }; // the action reports success, the record is unchanged
112+
113+
await ctx.api.object('showcase_task').update({ id: ctx.recordId, done: true }); // ✅ persists
114+
```
115+
116+
This holds for **declared** fields too — it is not a spelling problem, so no
117+
did-you-mean will appear. Do not reason from `ctx.input`, which *is* written
118+
back in a [hook body](/docs/automation/hook-bodies); an action's output is its
119+
return value and its write channel is `ctx.api` (declare
120+
`capabilities: ['api.write']`).
121+
122+
You will be told rather than left guessing. `os validate` / `os lint` /
123+
`os compile` raise `action-record-write-discarded`, and the sandbox logs the
124+
discarded fields at invocation time — the latter also catching computed keys,
125+
aliases and bodies authored in Studio, which no lint ever inspects.
126+
127+
Both report only writes that reach **nothing**. Building a payload on the
128+
snapshot and then persisting it is a normal, live pattern and stays quiet:
129+
130+
```js
131+
ctx.record.done = true;
132+
await ctx.api.object('showcase_task').update(ctx.record); // ✅ lands — not reported
133+
```
134+
</Callout>
135+
102136
### Path B: a registered handler (full TypeScript)
103137

104138
For logic that belongs in real source files, point `target` at a handler name

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,4 +201,87 @@ describe('actionBodyRunnerFactory', () => {
201201
const out = await fn!({ params: {} });
202202
expect(out).toEqual({ count: 3 });
203203
});
204+
205+
// The hook path writes `ctx.input` back; the action path has no `ctx.record`
206+
// counterpart, so a body that assigns to `ctx.record` gets a green action and
207+
// an unchanged record. That stays true — an action's write channel is
208+
// `ctx.api` — but it is no longer silent (#4345).
209+
describe('discarded ctx.record writes are reported (#4345)', () => {
210+
const warnsFor = async (source: string, record?: Record<string, unknown>) => {
211+
const warns: Array<{ msg: string; meta: any }> = [];
212+
const factory = actionBodyRunnerFactory(runner, {
213+
ql: {},
214+
appId: 'crm',
215+
logger: { warn: (msg: string, meta: any) => warns.push({ msg, meta }) },
216+
});
217+
const fn = factory({
218+
name: 'close_deal',
219+
object: 'crm_deal',
220+
body: { language: 'js', source, capabilities: [] },
221+
});
222+
const value = await fn!({ record, recordId: record?.id });
223+
return { warns, value };
224+
};
225+
226+
it('warns naming the discarded fields and the ctx.api remedy', async () => {
227+
const { warns, value } = await warnsFor("ctx.record.stage = 'won'; return { ok: true };", {
228+
id: 'deal_1',
229+
stage: 'negotiation',
230+
});
231+
// The action still reports success — the warning is the only signal the
232+
// author's intended write did not happen.
233+
expect(value).toEqual({ ok: true });
234+
expect(warns).toHaveLength(1);
235+
expect(warns[0].msg).toContain('read-only');
236+
expect(warns[0].msg).toContain("ctx.api.object('crm_deal').update(");
237+
expect(warns[0].meta.fields).toEqual(['stage']);
238+
expect(warns[0].meta.action).toBe('close_deal');
239+
});
240+
241+
it('warns for a DECLARED field exactly as for an unknown one — the whole point of #4345', async () => {
242+
// The runner never consults the object's fields: `stage` (declared on the
243+
// object in the issue's repro) and `stgae` (a typo) are dropped alike, so
244+
// both must warn. A rule that fired only on the unknown one would imply
245+
// the declared one landed.
246+
const declared = await warnsFor("ctx.record.stage = 'won'; return null;", { id: 'd', stage: 'x' });
247+
const typo = await warnsFor("ctx.record.stgae = 'won'; return null;", { id: 'd', stage: 'x' });
248+
expect(declared.warns).toHaveLength(1);
249+
expect(typo.warns).toHaveLength(1);
250+
});
251+
252+
it('stays quiet when the body only reads the record', async () => {
253+
const { warns, value } = await warnsFor('return { stage: ctx.record.stage };', {
254+
id: 'deal_1',
255+
stage: 'negotiation',
256+
});
257+
expect(value).toEqual({ stage: 'negotiation' });
258+
expect(warns).toEqual([]);
259+
});
260+
261+
it('stays quiet for an action with no pre-fetched record', async () => {
262+
const { warns } = await warnsFor('return { ok: true };');
263+
expect(warns).toEqual([]);
264+
});
265+
266+
it('stays quiet when the body persists correctly through ctx.api', async () => {
267+
const updates: unknown[] = [];
268+
const factory = actionBodyRunnerFactory(runner, {
269+
ql: { object: () => ({ update: async (d: unknown) => { updates.push(d); return d; } }) },
270+
appId: 'crm',
271+
logger: { warn: () => { throw new Error('the documented remedy must not warn'); } },
272+
});
273+
const fn = factory({
274+
name: 'close_deal',
275+
object: 'crm_deal',
276+
body: {
277+
language: 'js',
278+
source:
279+
"await ctx.api.object('crm_deal').update({ id: ctx.recordId, stage: 'won' }); return { ok: true };",
280+
capabilities: ['api.write'],
281+
},
282+
});
283+
expect(await fn!({ record: { id: 'deal_1' }, recordId: 'deal_1' })).toEqual({ ok: true });
284+
expect(updates).toEqual([{ id: 'deal_1', stage: 'won' }]);
285+
});
286+
});
204287
});

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@
3030
* Writes go through `Object.assign`, which means the host engine's
3131
* flat-record Proxy (installed by `wrapDeclarativeHook`) sees them
3232
* via its set trap.
33+
*
34+
* The ACTION path deliberately has no step 4: its output is the script's
35+
* return value, and its write channel is `ctx.api.object(...)`. In particular
36+
* `ctx.record` is a read-only pre-fetched snapshot — writes to it are
37+
* discarded whether or not the field is declared (#4345). That stays true;
38+
* what changed is that the discard is now REPORTED
39+
* ({@link warnDiscardedRecordWrites}) instead of silent.
3340
*/
3441

3542
import type { Hook } from '@objectstack/spec/data';
@@ -131,6 +138,7 @@ export function actionBodyRunnerFactory(
131138
// configurable action default (5000ms) apply.
132139
timeoutMs: (body as any).timeoutMs ?? action.timeoutMs,
133140
});
141+
warnDiscardedRecordWrites(result, action.name, action.object, opts);
134142
return result.value;
135143
} catch (err: any) {
136144
opts.logger?.error?.('[BodyRunner] sandboxed action threw', err, {
@@ -143,6 +151,41 @@ export function actionBodyRunnerFactory(
143151
};
144152
}
145153

154+
/**
155+
* Report `ctx.record` writes the action path discards (#4345).
156+
*
157+
* The hook path writes `ctx.input` back to the engine via
158+
* {@link applyMutationsToInput}; the action path has no counterpart for
159+
* `ctx.record`, by design — an action's output is its return value, and its
160+
* write channel is `ctx.api`. What was wrong was not the design but the
161+
* SILENCE: a body assigning to `ctx.record` got a successful action and an
162+
* unchanged record, with no diagnostic anywhere, and the field being correctly
163+
* declared changed nothing (the #4001 false-completion shape).
164+
*
165+
* Advisory, not fatal — the same reason the author-time lint warns rather than
166+
* gates: a body may legitimately use the snapshot as local scratch
167+
* (`ctx.record.total = a + b; return { total: ctx.record.total }`), which is
168+
* indistinguishable here from an intended persist. The message states what is
169+
* true of both — the writes did not leave the VM — and names the remedy.
170+
* Ratcheting to a throw is a decision for field data, not a default.
171+
*/
172+
function warnDiscardedRecordWrites(
173+
result: ScriptResult,
174+
actionName: string,
175+
object: string | undefined,
176+
opts: FactoryOptions,
177+
): void {
178+
const fields = result.droppedRecordWrites;
179+
if (!fields || fields.length === 0) return;
180+
opts.logger?.warn?.(
181+
`[BodyRunner] action '${actionName}' wrote ${fields.length} field(s) to ctx.record, which is a read-only ` +
182+
`pre-fetched snapshot — the writes never left the sandbox and the stored record is unchanged. ` +
183+
`To persist, call ctx.api.object('${object ?? '<object>'}').update({ id: ctx.recordId, … }) ` +
184+
`(needs the 'api.write' capability). See #4345.`,
185+
{ appId: opts.appId, action: actionName, object, fields },
186+
);
187+
}
188+
146189
function applyMutationsToInput(engineCtx: any, result: ScriptResult): void {
147190
const target = engineCtx?.input;
148191
if (!target || typeof target !== 'object') return;
@@ -254,6 +297,9 @@ function buildActionSandboxContext(actionCtx: any, ql: any): ScriptContext {
254297
session: actionCtx?.session,
255298
object: typeof actionCtx?.object === 'string' ? actionCtx.object : undefined,
256299
recordId,
300+
// A snapshot by construction, and read-only by contract (#4345): nothing
301+
// downstream writes it back. `warnDiscardedRecordWrites` reports the writes
302+
// a body makes to it rather than letting them vanish.
257303
record: unwrapProxyToPlain(actionCtx?.record),
258304
api: buildSandboxApi(actionCtx, ql, 'action body'),
259305
log: actionCtx?.logger,

0 commit comments

Comments
 (0)