Skip to content

Commit 2af5eac

Browse files
os-warrenclaude
andauthored
fix(objectql,runtime): route delete and defineProperty into the row a hook persists (#12396)
`delete ctx.input.x` in a hook was a no-op on both execution paths while an assignment on the same object in the same call landed. In-process: `installFlatInput`'s flat-record Proxy trapped get/set/has/ownKeys/ getOwnPropertyDescriptor but not `deleteProperty`, so the delete fell through to the WRAPPER one level above `data` and returned true. `defineProperty` had the same gap and the worse shape — the `get` trap's fall-through read the value back, so the read-back CONFIRMED a write the record never received. Both now route into `data`, like `set`. Sandboxed: `applyMutationsToInput` wrote a QuickJS body's mutations home with `Object.assign`, which cannot represent a removal. Keys the VM deleted are now diffed against the entry snapshot, filtered through the same JSON lens the sandbox boundary uses so a key that never crossed cannot be destroyed on its absence. Both halves land together: closing one alone would make the same authored `delete` behave differently in-process than in the sandbox. Card: #12277 Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o Co-authored-by: Claude <noreply@anthropic.com>
1 parent cdbd920 commit 2af5eac

5 files changed

Lines changed: 552 additions & 4 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
fix(objectql,runtime): `delete ctx.input.x` in a hook actually removes the field (#12277)
7+
8+
A hook that stripped a field from its input with `delete` did nothing, on BOTH
9+
execution paths, while an assignment made two lines above it on the same object
10+
in the same call landed normally. Nothing raised, and nothing in the platform
11+
reported it.
12+
13+
Graded `minor` rather than `patch` deliberately: it moves data that reaches
14+
downstream consumers. Any shipped hook that already contains
15+
`delete ctx.input.<field>` has been a no-op until now and starts taking effect
16+
on upgrade — which is the point, and is also exactly why it must not arrive as
17+
a silent patch. No API is removed and no accept set narrows.
18+
19+
### The two mechanisms, which were unrelated and produced one outcome
20+
21+
**In-process (`installFlatInput`, `packages/objectql/src/hook-wrappers.ts`).**
22+
The flat-record `Proxy` a declarative hook receives over the engine's
23+
`{ data, options, id? }` wrapper trapped `get` / `set` / `has` / `ownKeys` /
24+
`getOwnPropertyDescriptor` — but not `deleteProperty`. The delete therefore fell
25+
through to `Reflect.deleteProperty` on the WRAPPER, one level above the record,
26+
removing a key that was never there and returning `true`. `set` was trapped and
27+
wrote into `data`, which is what the engine persists; hence assignment survived
28+
and deletion evaporated.
29+
30+
**Sandboxed (`applyMutationsToInput`,
31+
`packages/runtime/src/sandbox/body-runner.ts`).** A QuickJS body's mutations
32+
were written home with `Object.assign(target, result.mutatedInput)`.
33+
`Object.assign` copies own enumerable properties and **has no way to represent a
34+
removal**: a key the VM deleted is simply not in the snapshot, and the host's
35+
key stayed. Deletions are now diffed against the entry snapshot and applied
36+
separately.
37+
38+
Both are fixed in one change on purpose. Closing either alone would make the
39+
same authored `delete` behave differently depending on whether the hook body
40+
runs in-process or in the sandbox — a worse contract than the symmetric silence
41+
it replaced.
42+
43+
### What an author could see, before and after
44+
45+
The sandboxed path is the one with no tell at all. Measured on the pre-fix code,
46+
one hook call, host row alongside:
47+
48+
```
49+
delete ctx.input.internal_notes -> true
50+
'internal_notes' in ctx.input -> false <- the VM agrees
51+
Object.keys(ctx.input) -> ['subject'] <- ...and so does this
52+
host ctx.input after write-back -> { subject: 'HELP',
53+
internal_notes: 'STAFF-ONLY' }
54+
```
55+
56+
The in-process path was less deceptive than reported, and the correction is
57+
worth having in writing: only `delete`'s own return value lied there. `'k' in
58+
input`, `input.k` and `Object.keys(input)` all went on honestly reporting the key
59+
as present, so an author who checked with anything other than the return value
60+
would have seen the no-op.
61+
62+
### `Object.defineProperty(ctx.input, …)` was the same gap, and nobody reported it
63+
64+
Found while enumerating the trap set, fixed in the same stroke because it is the
65+
strictly worse shape: it defined on the wrapper, and the `get` trap's
66+
fall-through then read the value straight back — so `input.k` CONFIRMED a write
67+
that never reached `data`, while `Object.keys(input)` denied it and the record
68+
never received it. It now routes into `data` like `set` and `deleteProperty` do.
69+
One inherited JS invariant follows: a proxy may not report success for an
70+
explicitly `configurable: false` descriptor its target does not carry, so
71+
`Object.defineProperty(input, 'x', { value: 1, configurable: false })` now throws
72+
a `TypeError` where it used to define, silently and uselessly, on the wrapper.
73+
Omitting `configurable` — the common spelling, and the one spread and
74+
`Object.assign` produce — is unaffected.
75+
76+
### The direction the sandbox write-back deliberately does not overreach in
77+
78+
Absence from the exit snapshot is the only evidence a deletion leaves, and on its
79+
own it is ambiguous: a key whose host value is `undefined` (or a function, or a
80+
symbol) never survived `JSON.stringify` INTO the VM either, so it is missing from
81+
the dump without anyone having deleted it. The diff is filtered through the same
82+
JSON lens the boundary uses, so such a key is left alone. Every failure mode of
83+
that probe is conservative — an unprobeable key is simply not deletable — because
84+
losing a delete is recoverable and destroying a field on evidence that was never
85+
there is not. One residual miss follows and is named here rather than discovered
86+
later: a `bigint`-valued key crosses into the VM as a string but is dropped by the
87+
probe, so deleting one is still lost.
88+
89+
Measured consumer cost of the reported half: a guest-intake app stripped the
90+
fields an anonymous web-to-case / web-to-lead submitter must not write —
91+
internal staff notes, the resolution, the escalation flag, the owner — with
92+
fifteen `delete` statements, every one inert. A submission carrying
93+
`internal_notes` and `resolution` stored them verbatim, and the app's unit tests
94+
stayed green throughout, because they drive the handler with a plain object where
95+
`delete` genuinely works.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#12277] Every mutation JS offers on `ctx.input` lands in the row the engine
5+
* persists — not just assignment.
6+
*
7+
* `installFlatInput` (`hook-wrappers.ts`) hands a declarative hook a flat-record
8+
* Proxy over the engine's `{ data, options, id? }` wrapper. It trapped `set`
9+
* but not `deleteProperty` or `defineProperty`, so those two fell through to
10+
* `Reflect.*` on the WRAPPER — one level above `data` — and changed a key that
11+
* was never there, on an object the engine does not read.
12+
*
13+
* ## What each assertion is worth, and why the two gaps are not the same shape
14+
*
15+
* The measurement that produced this file (pre-fix, one hook call):
16+
*
17+
* ```
18+
* delete Object.defineProperty
19+
* operation's own result → true (no throw)
20+
* `k in input` → true —
21+
* `input.k` → CALLER-VALUE DEFINED ← agrees!
22+
* `Object.keys(input)` → includes k excludes k
23+
* what the engine persisted → CALLER-VALUE absent
24+
* ```
25+
*
26+
* `delete`'s lie was confined to its own return value: the three other
27+
* read-backs stayed honest and reported the key still present. That is a
28+
* silent no-op, and it is what the card reported.
29+
*
30+
* `Object.defineProperty` — which no one reported — is the strictly worse
31+
* shape, and the reason this file pins BOTH: the `get` trap's fall-through to
32+
* the wrapper read the value straight back, so `input.k` CONFIRMED a write
33+
* that never reached `data`. A read-back that corroborates a write that did
34+
* not happen leaves an author no instrument to catch it with.
35+
*
36+
* So every case below asserts the CONJUNCTION — what the hook observes AND
37+
* what the engine is left holding — rather than either alone. Asserting only
38+
* the stored row would pass on an engine whose read-backs lie in the other
39+
* direction; asserting only the read-backs is what shipped the defect.
40+
*
41+
* The `assign-then-delete` case is the DISCRIMINATOR carried over from the
42+
* report: a `{...callerData, ...hookInput}` merge upstream would produce the
43+
* same symptoms as a missing trap, and it would restore the CALLER's value.
44+
* Seeing the hook's own assigned value survive a delete rules the merge out —
45+
* and post-fix, seeing the key vanish entirely rules out a merge just as
46+
* firmly, from the other side.
47+
*
48+
* `wrapDeclarativeHook` is driven directly rather than through `ObjectQL`: the
49+
* defect is in the wrapper's Proxy, and a full engine dispatch would put a
50+
* driver's own copy semantics between the hook and the assertion.
51+
*/
52+
53+
import { describe, it, expect } from 'vitest';
54+
import { wrapDeclarativeHook } from './hook-wrappers.js';
55+
56+
const silentLogger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
57+
58+
/** Run `handler` as a declarative hook over a caller payload; return the row the engine keeps. */
59+
async function runHook(
60+
data: Record<string, unknown>,
61+
handler: (input: any) => void,
62+
): Promise<Record<string, unknown>> {
63+
const meta: any = { name: 'trap_probe', object: 'case', event: 'beforeInsert' };
64+
const wrapped = wrapDeclarativeHook(meta, (async (ctx: any) => handler(ctx.input)) as any, {
65+
logger: silentLogger,
66+
});
67+
const raw: any = { data, options: {} };
68+
await wrapped({ object: 'case', event: 'beforeInsert', input: raw } as any);
69+
return raw.data as Record<string, unknown>;
70+
}
71+
72+
describe('[#12277] `delete ctx.input.x` removes the field from the persisted row', () => {
73+
it('the hook read-backs and the stored row agree that the key is gone', async () => {
74+
const seen: Record<string, unknown> = {};
75+
const persisted = await runHook(
76+
{ subject: 'help', owner_id: 'CALLER-VALUE' },
77+
(input) => {
78+
seen.deleteReturned = delete input.owner_id;
79+
seen.inOperator = 'owner_id' in input;
80+
seen.propertyRead = input.owner_id;
81+
seen.objectKeys = Object.keys(input);
82+
seen.spread = { ...input };
83+
seen.descriptor = Object.getOwnPropertyDescriptor(input, 'owner_id');
84+
},
85+
);
86+
87+
// What the author observes. Pre-fix, only the first of these was `true`
88+
// and every other line reported the key still present.
89+
expect(seen.deleteReturned).toBe(true);
90+
expect(seen.inOperator).toBe(false);
91+
expect(seen.propertyRead).toBeUndefined();
92+
expect(seen.objectKeys).toEqual(['subject']);
93+
expect(seen.spread).toEqual({ subject: 'help' });
94+
expect(seen.descriptor).toBeUndefined();
95+
96+
// …and what the engine is left holding. This is the half the author cannot
97+
// reach from inside the hook, and the half the defect falsified.
98+
expect(persisted).toEqual({ subject: 'help' });
99+
});
100+
101+
it('POSITIVE CONTROL — an assignment in the same call still lands', async () => {
102+
// Without this, every assertion above would also pass against a wrapper
103+
// that had stopped writing anything through to `data` at all.
104+
const persisted = await runHook({ subject: 'help', owner_id: 'CALLER-VALUE' }, (input) => {
105+
input.subject = 'HELP';
106+
delete input.owner_id;
107+
});
108+
expect(persisted).toEqual({ subject: 'HELP' });
109+
});
110+
111+
it('DISCRIMINATOR — assign-then-delete leaves no key, not the caller value', async () => {
112+
// A `{...callerData, ...hookInput}` merge would answer `CALLER-NOTE` here.
113+
const seen: Record<string, unknown> = {};
114+
const persisted = await runHook({ note: 'CALLER-NOTE' }, (input) => {
115+
input.note = 'ASSIGNED-THEN-DELETED';
116+
seen.afterAssign = input.note;
117+
delete input.note;
118+
seen.afterDelete = input.note;
119+
});
120+
expect(seen.afterAssign).toBe('ASSIGNED-THEN-DELETED');
121+
expect(seen.afterDelete).toBeUndefined();
122+
expect(persisted).toEqual({});
123+
});
124+
125+
it('deleting a key that was never in the payload is a no-op that reports success', async () => {
126+
const persisted = await runHook({ subject: 'help' }, (input) => {
127+
expect(delete input.never_here).toBe(true);
128+
});
129+
expect(persisted).toEqual({ subject: 'help' });
130+
});
131+
132+
it('the operation envelope is addressed separately from the record fields', async () => {
133+
// `id`/`options`/`ast`/`data` are wrapper keys on every other trap, and
134+
// `deleteProperty` routes them the same way — a hook deleting `options`
135+
// must not punch a hole in a record field that happens to share the name.
136+
const meta: any = { name: 'envelope', object: 'case', event: 'beforeUpdate' };
137+
const wrapped = wrapDeclarativeHook(meta, (async (ctx: any) => {
138+
delete ctx.input.options;
139+
}) as any, { logger: silentLogger });
140+
const raw: any = { id: 'r1', data: { options: 'A RECORD FIELD CALLED OPTIONS' }, options: { multi: true } };
141+
await wrapped({ object: 'case', event: 'beforeUpdate', input: raw } as any);
142+
expect('options' in raw).toBe(false);
143+
expect(raw.data).toEqual({ options: 'A RECORD FIELD CALLED OPTIONS' });
144+
});
145+
});
146+
147+
describe('[#12277] `Object.defineProperty(ctx.input, …)` lands in the persisted row', () => {
148+
it('the confirming read-back is now telling the truth', async () => {
149+
// The pre-fix failure this case exists for: `input.defined_key` read back
150+
// `DEFINED` while `data` never received it, so the instrument an author
151+
// would reach for to check AGREED with a write that did not happen.
152+
const seen: Record<string, unknown> = {};
153+
const persisted = await runHook({ subject: 'help' }, (input) => {
154+
Object.defineProperty(input, 'defined_key', {
155+
value: 'DEFINED',
156+
enumerable: true,
157+
writable: true,
158+
configurable: true,
159+
});
160+
seen.propertyRead = input.defined_key;
161+
seen.inKeys = Object.keys(input).includes('defined_key');
162+
});
163+
expect(seen.propertyRead).toBe('DEFINED');
164+
expect(seen.inKeys).toBe(true);
165+
expect(persisted).toEqual({ subject: 'help', defined_key: 'DEFINED' });
166+
});
167+
});

packages/objectql/src/hook-wrappers.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,8 @@ export function wrapDeclarativeHook(
498498
* of any other key fall through to `data`. Writes always go to `data`
499499
* (creating it if missing) so the engine's downstream `input.data`
500500
* read picks up mutations made by user code as `input.field = value`.
501+
* "Writes" means every mutation JS has, not assignment alone: `delete` and
502+
* `Object.defineProperty` route into `data` too (#12277).
501503
*/
502504
function installFlatInput(ctx: HookContext): () => void {
503505
const raw: any = ctx.input ?? {};
@@ -532,6 +534,59 @@ function installFlatInput(ctx: HookContext): () => void {
532534
ensureData()[prop as string] = value;
533535
return true;
534536
},
537+
// [#12277] The mutation traps are a SET, not a list: every operation JS
538+
// offers for changing a property has to land in `data`, because `data` is
539+
// the object the engine persists. `set` alone was trapped, so
540+
// `delete input.x` and `Object.defineProperty(input, 'x', …)` fell through
541+
// to `Reflect.*` on the WRAPPER — one level above the record — and did
542+
// nothing to the row while reporting success.
543+
//
544+
// The two gaps had different shapes, and the worse-shaped one is the one
545+
// nobody reported:
546+
//
547+
// - `delete input.x` returned `true` and changed nothing. The other
548+
// read-backs stayed HONEST (`'x' in input`, `input.x`,
549+
// `Object.keys(input)` all still showed the key), so the lie was
550+
// confined to `delete`'s own return value.
551+
// - `Object.defineProperty(input, 'x', …)` defined on the wrapper, and
552+
// the `get` trap's fall-through to the wrapper then READ IT BACK — so
553+
// `input.x` confirmed a write that never reached `data`. That is the
554+
// shape with no instrument to catch it from inside a hook.
555+
//
556+
// Measured cost of the `delete` half before this landed: a guest-intake
557+
// app stripped the fields an anonymous submitter must not write with 15
558+
// `delete` statements, every one inert, and its unit tests stayed green
559+
// because they drive the handler with a plain object.
560+
//
561+
// `deleteProperty` deliberately does NOT call `ensureData()`: with no
562+
// `data` on the wrapper, `get` reads fall through to the wrapper itself,
563+
// so that is where the key would live and where the delete belongs.
564+
// Materialising an empty `data` just to delete out of it would be a write
565+
// performed by a removal.
566+
deleteProperty(target, prop) {
567+
if (prop === 'id' || prop === 'options' || prop === 'ast' || prop === 'data') {
568+
return Reflect.deleteProperty(target, prop);
569+
}
570+
const data = target.data;
571+
if (data && typeof data === 'object') {
572+
return Reflect.deleteProperty(data as object, prop);
573+
}
574+
return Reflect.deleteProperty(target, prop);
575+
},
576+
// Routed for the same reason `set` is. One inherited JS invariant is worth
577+
// naming: a proxy may not report success for an explicitly
578+
// `configurable: false` descriptor the TARGET does not carry, so
579+
// `Object.defineProperty(input, 'x', { value: 1, configurable: false })`
580+
// now throws a TypeError where it used to silently define on the wrapper.
581+
// A throw is a diagnosis; the silence was not. Omitting `configurable`
582+
// entirely (the common spelling, and every spelling `Object.assign` and
583+
// spread produce) is unaffected.
584+
defineProperty(target, prop, desc) {
585+
if (prop === 'id' || prop === 'options' || prop === 'ast' || prop === 'data') {
586+
return Reflect.defineProperty(target, prop, desc);
587+
}
588+
return Reflect.defineProperty(ensureData(), prop, desc);
589+
},
535590
has(target, prop) {
536591
if (prop === 'id' || prop === 'options' || prop === 'ast' || prop === 'data') {
537592
return prop in target;

0 commit comments

Comments
 (0)