Uh oh!
There was an error while loading. Please reload this page.
One-click completion with undo, and skip-with-reason - #41
Merged
Conversation
The interaction the product rests on: complete, undo and skip on a task row,
plus bulk complete and bulk skip on the list views.
Routes taken, and why they differ:
- BULK is declarative. `bulkActionDefs` with `operation: 'update'` and a
static `patch` IS the platform's "set these fields on every selected
record" — no action, no handler, no code, and the write runs on the data
plane under the caller's own permissions.
- ROW is a handler, because no declarative row-action field write exists.
`ActionType` is url|form|flow|script|api|modal — no `update_record`, no
action `effect`. `type: 'api'` + `bodyExtra` is a declarative HTTP call that
requires hand-writing the platform's own data-API path into app metadata
(and the spec's worked example of it omits the `/data` segment the shipped
router needs). Filed upstream.
Completing sends `{ status: 'done' }` and nothing else; `completed_at` and
`last_update_at` stay the lifecycle hook's. No modal, no confirmation, no
percentage, no evidence gate on complete or undo — `undoable: true` plus the
`duly_task_undo` row action are what buy the tick its lack of ceremony. Skip
collects a reason, which is the one place a modal is correct because
`skip_needs_reason` refuses the write without one.
Each handler re-checks availability and the transition server-side:
`ctx.engine` is system-elevated and RLS-bypassing by design, and `visible` is
a UI hide, not authorization.
Also narrows three assertions in the catalog suite that iterated the whole
`dulyActions` array to claim it held only object-less headless actions. The
bijection they were really about is widened to cover object-bound actions
instead of being dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2pos-warren
marked this pull request as ready for review
September 1, 2026 05:03
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Sep 1, 2026
Closed
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 freeto 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#4
One-click completion, undo, and skip-with-reason — plus bulk complete and bulk skip on the list views.
The route question, answered plainly
The instruction for this round was: before writing a handler, find out whether the platform has a declarative action that sets a field value. I looked, and the answer is different for the two halves of this card.
Bulk — yes, and it is used. A list view's
bulkActionDefswithoperation: 'update'and a staticpatchis the platform's "set these fields on every selected record". No action, no handler, no code:It is also the better write: a data-plane update running under the caller's own permissions, hooks and validations firing normally — strictly safer than N dispatches through an action handler's
ctx.engine, which is system-elevated and RLS-bypassing by design.Row — no.
ActionTypeisurl | form | flow | script | api | modal. There is noupdate_recordtype, no actioneffect, nopatchkey onActionSchema. The two near-misses and why neither is it:type: 'api'+method: 'PATCH'+bodyExtrais a declarative HTTP call, not a declarative field write. It requires hand-writing the platform's own data-API path into app metadata —/api/v1/data/duly_task/${ctx.recordId}. Nothing binds that string toobjectName, nothing checks it at author time, and it pins the transport: withprojectResolution: 'required'only the/environments/:environmentId/scoped route exists, so the app breaks on that host with no diagnostic. For AI-authored metadata it is the worst available shape — parses green, 404s at the click. The spec's own worked example of this route is itself wrong (it omits the/datasegment), which is filed separately.type: 'flow'+ anupdate_recordnode is genuinely declarative, but it is one flow per button to assign one string, plus a flow-run record per tick, on the one surface wherevalidatecurrently misses bare predicates (objectstack#14089).So: declarative for bulk, handlers for the rows, and the gap filed upstream as objectstack-ai/objectstack#14092 — with #14093 for the wrong doc example. The full argument lives in the
src/actions/task.handlers.tsheader so the next author does not have to re-derive it.The real cost of that gap
Not the one-line write — the authorization.
ctx.engineis system-elevated and RLS/FLS-bypassing, andvisibleis a UI hide, not authorization. So each handler re-establishes what the declarative bulk path gets for free. The obvious guard does not work: the dispatcher loadsctx.recordunder the caller's scope, swallows a failed load to{}, then stampsrecord.id = recordIdon unconditionally — soctx.record.idis present even when the caller cannot read the row. The check keys onstatus(required: true, so every stored row has one) instead. That is the part of #14092 that matters.What it does
{ status: 'done' }{ status: 'in_progress' }{ status: 'skipped', skip_reason }open,in_progressdoneopen,in_progresscompleted_atandlast_update_atare never sent — they stay the lifecycle hook's (#3). No confirmation on complete or undo, no percentage, no evidence gate.undoable: truegives complete the platform's own toast-level Undo for the mistake noticed immediately;duly_task_undocovers the one noticed after the toast is gone. Both exist because the toast is transient and the promise ("an accidental tick costs one click") is not.cancelledis deliberately not completable: re-completing a cancelled task would put it back into on-time rates that had correctly forgotten it.Gates
All four green on
f116421, the branch head:Two ablations, because the interesting failures are silent
1 — the trap with no author-time gate. Removed
registerTaskActionHandlers(ql)fromregister-handlers.ts(mutation confirmed on disk: the call count went 1 → 0 and the mutated function body was printed before anything was read):Confirmed: an unregistered handler ships green. The test is the only gate, so it dispatches through the engine's own
executeActionrather than inspecting a registry.2 — the bare predicate. Mutated
duly_task_undo'svisibleto barestatus == "done"(confirmed on disk: qualified 0, bare 1, with the three predicate lines printed).validaterejects it —— and the suite catches it independently. So the action surface really is gated (unlike flows, objectstack#14089); both legs restored via
trap … EXIT INT TERMand the clean tree verified after each.Tests —
test/task-actions.test.ts, 35 cases on a real booted engineAgainst the app's own config with the in-memory driver, because two claims here are about the pipeline, not the handler: the registration, and "sends
{status:'done'}and nothing else, and the record comes back withcompleted_atset".dispatch()mirrors the platform dispatcher's context construction — including the swallow-then-stamp detail above, sosubject: 'unreadable'reproduces a caller who cannot read the row.Refusals are asserted by envelope (
code+status), never by the bare fact that something threw. And the object's own rule is pinned separately from the handler's early check, so the two cannot drift into one guard doing all the work:One measured hazard is pinned in both directions. A predicate update carries one payload for all N rows, so the
completed_atthe hook stamps for a genuinely-transitioning row is written to the whole batch — verified: bulk-completing a selection containing an already-done row moves that row's completion instant. The bulk defs'visiblepredicate is what makes such a selection unreachable, which is why it is load-bearing rather than decoration. Filed as #39 for a server-side guard, since the client predicate is not the authority.Scope notes
src/views/task.view.ts— Kanban, gantt and timeline lenses over the same task data #12 owns this file in a later round, so the edit is one sharedbulkActionsconst plus a one-linebulkActionDefs:on each of the four multi-select grids. Not oncalendar: no multi-select gesture to hang a selection bar on. NorowActionsoverride either — the actions' ownlocationsare the contract, and an allow-list here would be a second place to maintain that could silently exclude Kanban, gantt and timeline lenses over the same task data #12's.test/catalog-instantiate.test.ts— not in the declared file surface, but this PR makes three of its assertions false: they iterated the wholedulyActionsarray to claim it held only object-less, headless actions, which was true only while catalog was the only feature. Two are narrowed to the catalog actions; the third — the declaration↔handler bijection, the valuable one — is widened to cover object-bound actions rather than dropped.objectstack.config.tsedit. Barrel only.requiredPermissionson the new actions.dulyPermissionSetsis empty, so naming a capability nothing grants would either hide the buttons from everyone or be inert. Queued as The three task row actions are ungated byrequiredPermissionstoo — same gap as the catalog actions #40, a sub-issue of Gate the catalog actions withrequiredPermissions—duly_catalog_applyis currently ungated #30, behind the same Security model — positions, permission sets, sharing rules #8 dependency as its parent.Filed
ActionSchema.method's worked example points at a route the router does not mountcompleted_aton an already-done row in the batchrequiredPermissionsgate as the catalog actions (sub-issue of #30)Generated by Claude Code
Generated by Claude Code