Found while implementing #3. This is an upstream platform limitation (@objectstack/objectql 17.2.0), recorded here because the symptom is a wrong duly_task record.
Symptom
Reopening a completed task normally clears completed_at — the #3 hook writes null on the transition out of done, and test/task-hook.test.ts pins it. But if the caller's payload also carries completed_at: null, the clear is silently dropped and the record keeps a completion timestamp it no longer earns:
await data.update('duly_task', { id, status: 'done' }); // completed_at = <t>
await data.update('duly_task', { id, status: 'in_progress',
completed_at: null }); // caller sends null too
// measured:
persisted.completed_at === '2026-09-01T03:54:53.971Z' // expected null
Result: an in_progress task carrying a completion timestamp. No error is raised. The validation rule completed_at_required_when_done does not catch it — that rule only fires on status == 'done'.
Cause
stripReadonlyFields decides whether a readonly key in the payload is a caller write by comparing it to the pre-hook snapshot:
if (!(name in result)) continue;
if (!Object.prototype.hasOwnProperty.call(supplied, name)) continue;
if (!Object.is(result[name], supplied[name])) continue; // <-- here
delete result[name];
The Object.is guard is what normally lets a hook-derived value survive: the hook overwrote the caller's value, so they differ, so the key is kept. It cannot distinguish "the hook deliberately wrote the same value" from "the hook never touched it". When the hook's intended value and the caller's value are both null, the hook's write is deleted along with the caller's.
This contradicts the contract the platform states in its own strip diagnostics:
A value DERIVED by a beforeUpdate hook is not a caller write and is never stripped
That holds only when the derived value differs from the supplied one.
Scope
Narrow but real. Unaffected: a bare { status: 'in_progress' } reopen, and any form that round-trips the record (it sends the actual timestamp, which differs from null, so the hook's clear survives). Affected: an API client that explicitly nulls the field while reopening.
Not worked around in the #3 hook, deliberately
A consumer-side workaround (writing a sentinel that is not Object.is-equal to null) would be a hack around a producer defect and would make the hook's behaviour depend on an engine internal. The fix belongs upstream — the strip should track keys a hook actually wrote rather than infer it by value comparison.
Needs a maintainer decision on whether to carry a local workaround until the platform fix lands.
Found while implementing #3. This is an upstream platform limitation (
@objectstack/objectql17.2.0), recorded here because the symptom is a wrongduly_taskrecord.Symptom
Reopening a completed task normally clears
completed_at— the #3 hook writesnullon the transition out ofdone, andtest/task-hook.test.tspins it. But if the caller's payload also carriescompleted_at: null, the clear is silently dropped and the record keeps a completion timestamp it no longer earns:Result: an
in_progresstask carrying a completion timestamp. No error is raised. The validation rulecompleted_at_required_when_donedoes not catch it — that rule only fires onstatus == 'done'.Cause
stripReadonlyFieldsdecides whether a readonly key in the payload is a caller write by comparing it to the pre-hook snapshot:The
Object.isguard is what normally lets a hook-derived value survive: the hook overwrote the caller's value, so they differ, so the key is kept. It cannot distinguish "the hook deliberately wrote the same value" from "the hook never touched it". When the hook's intended value and the caller's value are bothnull, the hook's write is deleted along with the caller's.This contradicts the contract the platform states in its own strip diagnostics:
That holds only when the derived value differs from the supplied one.
Scope
Narrow but real. Unaffected: a bare
{ status: 'in_progress' }reopen, and any form that round-trips the record (it sends the actual timestamp, which differs fromnull, so the hook's clear survives). Affected: an API client that explicitly nulls the field while reopening.Not worked around in the #3 hook, deliberately
A consumer-side workaround (writing a sentinel that is not
Object.is-equal tonull) would be a hack around a producer defect and would make the hook's behaviour depend on an engine internal. The fix belongs upstream — the strip should track keys a hook actually wrote rather than infer it by value comparison.Needs a maintainer decision on whether to carry a local workaround until the platform fix lands.