fix(example-todo): make task completion possible for a normal user — stamp completed_date in the hook instead of demanding it from the caller (#7036) - #7222
Merged
Conversation
…7036) `completed_date` is `readonly` (a non-system caller's write is stripped on the update path) and `completed_date_required` then refused the write for missing exactly the value that had just been dropped, so `completeTask` — and every user-driven status transition into `completed` — always failed. Fixed in the metadata, the platform's system-stamp shape: `task.hook.ts` gains a `beforeUpdate` leg that stamps `completed_date` on the transition into `completed` and clears it on the transition out. A hook's write survives the readonly strip by design (#2948/#5591), so the rule is satisfied by the server and callers send `status` alone. Also registers the hook in `defineStack({ hooks })` — it had never been in that array, so the whole file was dead metadata — and repairs the two existing legs, which read the record off `ctx.input` instead of `ctx.input.data`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy
…pleted-date-stamp
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
… not `console` The root type program has `lib: ["ES2020"]` and no node types, so `console` is undefined there. The two calls were pre-existing but had never been measured: `task.hook.ts` entered the root program for the first time in this branch, because until now nothing imported it. Routing them through the engine handle the hook context already carries is also the better shape for a reference app — it honours the kernel's configured log level. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy
…pleted-date-stamp
#7036) `@objectstack/example-todo` is a changeset-versioned package (it carries a version and a CHANGELOG, and `.changeset/config.json` does not ignore it), and the sister repair #6882 shipped with one. This change is user-visible — `completeTask` went from always failing to working — so it declares a patch rather than taking the `skip-changeset` route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy
os-help
marked this pull request as ready for review
August 10, 2026 04:08
This was referenced Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #7036
A normal user could never mark a task complete.
completed_dateisreadonly, so a non-system caller's write to it is stripped from the payload on the update path;completed_date_requiredthen saw a blank value and refused the whole write. The two declarations were mutually unsatisfiable, and the app's owncompleteTaskaction — which sent exactly that pair — always failed.Premise re-verified on current
origin/mainThe card's measured table reproduces exactly, against the app's real
Taskobject on a real kernel (ObjectQL + sqlite-wasm), one row inserted asstatus: 'not_started':The engine ordering the card cites is unchanged:
reportDroppedFields(preRo, ..., 'readonly')still sits immediately above theevaluateValidationRules(updateSchema, ...)call inpackages/objectql/src/engine.ts.The ordering question the card left open, measured
Whether a
beforeUpdatehook's writes land before or after the readonly strip was flagged UNVERIFIED. Measured answer, and it is the reason this shape works:The hook runs BEFORE the strip, and the strip is built to spare it.
triggerHooks('beforeUpdate', …)dispatches at engine.ts:7054;stripReadonlyFieldsruns at 7349. But the strip only deletes a key that both (a) the caller supplied and (b) still holds the caller's own value — thesuppliedValuessnapshot plus theObject.isidentity check from #5591. A value a hook wrote is a platform value and survives. The engine's own comment says so in as many words: "Runs AFTER hooks/middleware stamped their columns".Measured on the app's real object: a one-key user-context update (
{ status: 'completed' }) with the hook bound →OK,completed_datepersisted.Why not "readonly lifted"
Measured too, and it is strictly weaker — it fixes only the two-key call:
A status-only transition is the path a form, a list-view inline edit, and #6882's own flow test all drive, and lifting
readonlyleaves it broken while also handing a server-owned timestamp to the caller. So this PR keepsreadonlyand puts the stamp where the platform puts it.What changed
src/objects/task.hook.ts— abeforeUpdateleg stampscompleted_dateon the transition intocompletedand clears it on the transition out. The stamp is unconditional, not??=: if a caller supplied the key and the hook left it alone, the value would still be the caller's, the strip would delete it, and the rule would refuse the write again.task_logicwas never indefineStack({ hooks }), so the entire file was dead metadata — it type-checked, it read as wired, and it never ran.collectBundleHookswalks that array and nothing else. Both sibling example apps (app-crm,app-showcase) already registerhooks: allHooks; this brings app-todo in line.ctx.input(ctx.input.priority,ctx.input.status) rather thanctx.input.data. Per theHookContextSchema.inputcontract table,inputis an envelope —{ data, options }on insert,{ id, data, options }on update — so those assignments set keys nothing reads. This was invisible while the hook was unregistered and would have shipped live otherwise.src/actions/task.handlers.ts—completeTaskandmassCompleteTaskssendstatusalone.src/objects/task.object.ts— comments only;completed_datestaysreadonlyand the validation rule stays. The rule is now the assertion that the stamp happened: if the hook is ever unregistered or its guard breaks, the write is refused loudly instead of committing a completed task with no completion date.Leaving
completedclears the stampDocumented in the object and hook metadata, and tested.
completed_datemeans "when this task was completed", so a reopened task must not carry one — a retained stamp is a stale timestamp every report and list view reads as fact. Guarded onstatusactually being part of the write, so an unrelated edit of a completed task ({ progress_percent: 100 }) is not read as a reopen.Tests
test/task-completion-trigger.test.ts— this card owns it. #6882's deliberate CREATE-seed workaround is gone; the task is created as a user creates one and the completion is a real user-context update. Six cases added: the headline case, the caller-echoes-the-key case (#5591 direction), the clear-on-reopen case, the forge-outside-a-transition case (#2948 unchanged), the wiring pin, and a reverse case.Reverse verification — predicted red, and red for the right reason. Restoring the pre-fix hook turns 5 of 11 cases red, four of them raising
ValidationError: Completed date is required when status is Completed— the defect's own message. The in-suite reverse case rebuilds that by withholding exactly one thing (the hook binding) from an otherwise identical kernel, and asserts the refusal rather than a missing value: an unstamped completion does not commit quietly, it is refused.test/seed-check.tsboots the real config throughAppPluginand confirms the registration is live rather than merely declared:Changeset
None.
examples/**releases nothing, andpr-automation.ymlnames it explicitly among the paths whose route is theskip-changesetlabel rather than a changeset file. Label applied.Declared-region deviation — one file, disclosed
My dispatch region was
src/objects/task.object.ts,task.hook.ts,src/actions/task.handlers.tsand this test file, with the app'sdefineStackregistration site barred as sibling #7037's ground. This PR adds two lines toobjectstack.config.ts(an import andhooks: [taskHook]).The region was declared before anyone knew the hook was unregistered — that fact came out of the measurement above. Without those two lines the hook never runs and the PR fixes nothing, and every alternative measured is either strictly weaker (readonly lifted) or teaches a worse pattern in a reference app (registering a plain object hook imperatively from the action-handler bootstrap). The addition is placed next to
data:, well away from where #7037'sfunctions:key lands, andorigin/mainwas merged before opening this PR.src/flows/task.flow.tsandtest/task-recurrence.test.tsare untouched. Flagging it for the reviewer rather than deciding it silently.Generated by Claude Code