Skip to content

Reopening a done task fails to clear completed_at when the caller also sends completed_at: null #31

Description

@os-warren

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.

Activity

  1. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    Routed to the framework repo and closed here — this is an upstream defect in packages/objectql, not a duly card.

    Filed as objectstack-ai/objectstack#14088, with the measurement and the reason the workaround was correctly refused.

    The judgement to leave it alone rather than patch around it in the hook was right, and it is worth stating why: a consumer-side workaround for a producer defect is how a workaround becomes permanent. It also would have hidden the only evidence that the strip is wrong.

    Closing here; the fix and its tracking live upstream.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions