Found while implementing #6882 (arming the task_completion record-change flow). Filed standalone: it is in the object + action metadata, not in the flow's trigger wiring, and it is independent of that repair.
The defect
examples/app-todo/src/objects/task.object.ts declares:
completed_date: Field.datetime({
label: 'Completed Date',
readonly: true, // <-- write is STRIPPED on the update path
}),
and, in the same object's validationRules:
{
name: 'completed_date_required',
message: 'Completed date is required when status is Completed',
condition: P`record.status == "completed" && isBlank(record.completed_date)`,
}
The two are mutually unsatisfiable on the update path. ObjectQL.update runs the static-readonly strip before evaluateValidationRules (packages/objectql/src/engine.ts, the reportDroppedFields(preRo, ..., 'readonly') call sits immediately above the evaluateValidationRules(updateSchema, ...) call). So a payload carrying both keys loses completed_date, and the rule then sees a blank one and rejects the whole write.
The app's own completion action writes exactly that pair — examples/app-todo/src/actions/task.handlers.ts:
export async function completeTask(ctx: ActionContext): Promise<void> {
const { record, engine } = ctx;
await engine.update('todo_task', record.id as string, {
status: 'completed',
completed_date: new Date().toISOString(),
});
}
so "Complete task" is non-functional for an ordinary user, and the same applies to completeAllTasks.
Measured
Real kernel (ObjectQL + @objectstack/driver-sql better-sqlite3 :memory:) with app-todo's real Task object, one row inserted as status: 'not_started':
update status+completed_date (user ctx): REJECTED -> Completed date is required when status is Completed
update status only (user ctx): REJECTED -> Completed date is required when status is Completed
update status+completed_date (isSystem): OK -> status=completed completed_date=2026-08-09T10:00:00.000Z
insert already-completed: OK -> completed_date=2026-08-09T10:00:00.000Z
Two escapes exist and neither is the user path: an elevated (isSystem) write bypasses the readonly strip, and an INSERT may legitimately seed a read-only field (the engine's create path exempts readonly seeds on purpose). Every ordinary user update is refused.
Why it was not noticed
The status transition is also the trigger for task_completion, which was itself dead until #6882 — so nothing downstream of a completion ever ran, and nothing pointed at the transition being impossible in the first place. #6882's regression test works around this by seeding completed_date on CREATE, which is the one route the engine allows; that workaround is deliberate and is commented as such, but it is not a fix.
Shape of the fix (needs a decision, hence not a drive-by)
At least three readings, and they are not equivalent:
completed_date should not be readonly — it is a value the completion path legitimately writes, and readonly was meant as "users do not type this into a form", which is a form concern (readonly on the field's form binding) rather than a write-path one.
- A
beforeUpdate hook should stamp it — the object already ships task.hook.ts with a beforeInsert leg that defaults priority/status; stamping completed_date on the transition there makes the rule satisfiable without any caller writing the field, and makes completeTask a one-key update.
- The validation rule is the wrong instrument — if the field is genuinely system-maintained, requiring it of the caller is asking for something the caller is forbidden to supply.
The second reads like the intended design (the object's own trailing comment already says actions/task.handlers.ts "stamps completed_date on completion"), but which one ships is an app-semantics call.
Found while implementing #6882 (arming the
task_completionrecord-change flow). Filed standalone: it is in the object + action metadata, not in the flow's trigger wiring, and it is independent of that repair.The defect
examples/app-todo/src/objects/task.object.tsdeclares:and, in the same object's
validationRules:The two are mutually unsatisfiable on the update path.
ObjectQL.updateruns the static-readonlystrip beforeevaluateValidationRules(packages/objectql/src/engine.ts, thereportDroppedFields(preRo, ..., 'readonly')call sits immediately above theevaluateValidationRules(updateSchema, ...)call). So a payload carrying both keys losescompleted_date, and the rule then sees a blank one and rejects the whole write.The app's own completion action writes exactly that pair —
examples/app-todo/src/actions/task.handlers.ts:so "Complete task" is non-functional for an ordinary user, and the same applies to
completeAllTasks.Measured
Real kernel (ObjectQL +
@objectstack/driver-sqlbetter-sqlite3:memory:) with app-todo's realTaskobject, one row inserted asstatus: 'not_started':Two escapes exist and neither is the user path: an elevated (
isSystem) write bypasses the readonly strip, and an INSERT may legitimately seed a read-only field (the engine's create path exempts readonly seeds on purpose). Every ordinary user update is refused.Why it was not noticed
The status transition is also the trigger for
task_completion, which was itself dead until #6882 — so nothing downstream of a completion ever ran, and nothing pointed at the transition being impossible in the first place. #6882's regression test works around this by seedingcompleted_dateon CREATE, which is the one route the engine allows; that workaround is deliberate and is commented as such, but it is not a fix.Shape of the fix (needs a decision, hence not a drive-by)
At least three readings, and they are not equivalent:
completed_dateshould not bereadonly— it is a value the completion path legitimately writes, andreadonlywas meant as "users do not type this into a form", which is a form concern (readonlyon the field's form binding) rather than a write-path one.beforeUpdatehook should stamp it — the object already shipstask.hook.tswith abeforeInsertleg that defaultspriority/status; stampingcompleted_dateon the transition there makes the rule satisfiable without any caller writing the field, and makescompleteTaska one-key update.The second reads like the intended design (the object's own trailing comment already says
actions/task.handlers.ts"stampscompleted_dateon completion"), but which one ships is an app-semantics call.