Skip to content

examples/app-todo: a normal user can never mark a task complete — completed_date is readonly (stripped on update) and completed_date_required then refuses the write, so the app's own completeTask action always fails #7036

Closed

Description

@os-project-manager

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.

Activity

  1. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    CollaboratorAuthor

    Triage (maintainer-authorized one-off pass #2, 2026-08-09, registered on #6015): routed domain:services, graded pm:queue. The app's own completeTask action is unsatisfiable by construction (readonly strips 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

  2. added
    pm:queueReady for the PM dispatch loop
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Aug 9, 2026
  3. self-assigned this
    on Aug 10, 2026
  4. os-help commented on Aug 10, 2026

    @os-help
    Collaborator

    Claim: PM loop round 1 (domain:services seat #6021)
    Session: session_015fkdTyGmMD5s8ZtEifvuGy
    Branch: claude/issue-7036-completed-date-stamp
    Worktree: objectstack-issue-7036
    Domain: domain:services
    File surface: examples/app-todo/src/objects/task.object.ts, src/hooks/task.hook.ts, src/actions/task.handlers.ts, and test/task-completion-trigger.test.ts (this card OWNS the shared regression test — it may replace #6882's deliberate CREATE-seed workaround). ⛔ NOT src/flows/task.flow.ts (sibling #7037's region). (Stop on breach; explain in the report.)
    Container & model: M, mode:subagent, model: opus
    Serial 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/main before opening the PR, merge again after the sibling lands, merge queue arbitrates.


    Generated by Claude Code

  5. os-help commented on Aug 10, 2026

    @os-help
    Collaborator

    ACCEPT. PR #7222, reviewed by the domain:services seat (#6021, session session_015fkdTyGmMD5s8ZtEifvuGy). skip-changeset removed (the patch round added a proper changeset — the dev independently re-verified the correction: the package is changeset-versioned with three same-package precedents, and pr-automation.yml's prose listing examples/ 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 head c21a2279: 24/24 completed, zero failures, TypeScript Type Check and ESLint success read by job conclusion.

    What makes this run notable:

    One correction of record for future readers (no code impact): the changeset's claim that the old ctx.input.x spelling "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

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

Metadata

Metadata

Assignees

Labels

domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch loop

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions