Repository navigation
Description
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_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.- A
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 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.
Activity
os-project-manager commentedon Aug 9, 2026
Triage (maintainer-authorized one-off pass #2, 2026-08-09, registered on #6015): routed
domain:services, gradedpm:queue. The app's owncompleteTaskaction is unsatisfiable by construction (readonlystrips the write, then the validation demands the stripped value) — a reference-corpus app whose headline flow always fails teaches every reader a broken pattern. Fix in the metadata (the platform's idiomatic completion-stamp shape — e.g. system-stamped timestamp or readonly lifted), not by relaxing the engine; keep consistency with #6882's repair on the same app.Generated by Claude Code
os-help commentedon Aug 10, 2026
Claim: PM loop round 1 (
domain:servicesseat #6021)Session:
session_015fkdTyGmMD5s8ZtEifvuGyBranch:
claude/issue-7036-completed-date-stampWorktree:
objectstack-issue-7036Domain:
domain:servicesFile surface:
examples/app-todo/src/objects/task.object.ts,src/hooks/task.hook.ts,src/actions/task.handlers.ts, andtest/task-completion-trigger.test.ts(this card OWNS the shared regression test — it may replace #6882's deliberate CREATE-seed workaround). ⛔ NOTsrc/flows/task.flow.ts(sibling #7037's region). (Stop on breach; explain in the report.)Container & model: M,
mode:subagent,model: opusSerial constraints cleared: same-package sibling #7037 dispatched in this same round under the maintainer's explicit dispatch-all instruction — regions disjoint by declaration (#7037 owns the flow file + its function-registration site + a NEW test file, and is barred from this card's four files). Pair discipline: merge
origin/mainbefore opening the PR, merge again after the sibling lands, merge queue arbitrates.Generated by Claude Code
chore(changeset): declare the app-todo completion-stamp fix as a patch (
os-help commentedon Aug 10, 2026
ACCEPT. PR #7222, reviewed by the
domain:servicesseat (#6021, sessionsession_015fkdTyGmMD5s8ZtEifvuGy).skip-changesetremoved (the patch round added a proper changeset — the dev independently re-verified the correction: the package is changeset-versioned with three same-package precedents, andpr-automation.yml's prose listingexamples/as release-nothing is wrong for it). Marking ready and queueing.Verified against GitHub rather than the report: 6 files — object/hook/handlers + the owned regression test +
objectstack.config.ts(+2-line registration, the declared-region deviation ruled accepted per the recorded open-question exchange: without registration the hook-stamp shape is inert, and the imperative-registration alternative would teach a workaround shape in a reference app) + changeset. CI converged on headc21a2279: 24/24 completed, zero failures, TypeScript Type Check and ESLintsuccessread by job conclusion.What makes this run notable:
beforeUpdatehooks dispatch BEFORE the readonly strip, but the strip issuppliedValues-guarded (security: 服务端 readonly 字段在 UPDATE 时未强制(可被用户上下文覆盖) #2948/update 路径的 stripReadonlyFields 删除的是 hook 已覆写的当前值而非调用方提交的值 —— 调用方回传 readonly 键会连坐抹掉 beforeUpdate hook 的写入,可落出 status=published 而 published_at=null 的行(17.0.0-rc.2) #5591) and deletes only a key still holding the caller's own value — so hook stamps survive by design, and the "readonly lifted" fallback was measured strictly weaker (it leaves the status-only transition refused). The ruling's hook-stamp reading was confirmed the intended design.task_logicwas never indefineStack({ hooks })(dead metadata —collectBundleHooksreads that array and nothing else), and both legs mis-read the envelope. Both repaired; the registration is pinned by a wiring test, and the reverse case withholds exactly the hook binding and goes red on the object's own rule.One correction of record for future readers (no code impact): the changeset's claim that the old
ctx.input.xspelling "set a key no write path reads" holds for RAW-engine handlers, but #7225's later measurement showed declarative-bound hooks get a flat-input proxy where that spelling also works — this hook was unregistered, so its no-op history is fully explained by the missing registration either way. The documentation gap is #7254's subject; a prose sweep of hook comments can follow it.Out-of-scope findings verified filed: #7225 (since measured false and closed not-planned — see its close-out) and #7226 (
finding, dead readonly booleans).Generated by Claude Code
fix(example-todo): make task completion possible for a normal user — …
docs(ui): state the true reason a handler must not name a server-owne…
fix(spec): ActionEngineFacade.delete declares the id array the runtim…
docs(example-todo): completeTask states the real completed_date hazar…