One-click completion with undo, and skip-with-reason - #41

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete
Sep 1, 2026
Merged

One-click completion with undo, and skip-with-reason#41
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 bulkActionDefs with operation: 'update' and a static patchis the platform's "set these fields on every selected record". No action, no handler, no code:

{name: 'duly_task_bulk_complete',operation: 'update',patch: {status: 'done'},visible: P`record.status == "open" || record.status == "in_progress"`}

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.ActionType is url | form | flow | script | api | modal. There is no update_record type, no action effect, no patch key on ActionSchema. The two near-misses and why neither is it:

  • type: 'api' + method: 'PATCH' + bodyExtra is 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 to objectName, nothing checks it at author time, and it pins the transport: with projectResolution: '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 /data segment), which is filed separately.
  • type: 'flow' + an update_record node 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 where validate currently 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.ts header 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.engine is system-elevated and RLS/FLS-bypassing, and visible is 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 loads ctx.record under the caller's scope, swallows a failed load to {}, then stamps record.id = recordId on unconditionally — so ctx.record.id is present even when the caller cannot read the row. The check keys on status (required: true, so every stored row has one) instead. That is the part of #14092 that matters.

What it does

completeundoskip
payload{ status: 'done' }{ status: 'in_progress' }{ status: 'skipped', skip_reason }
fromopen, in_progressdoneopen, in_progress
dialognonenonethe reason, and only the reason

completed_at and last_update_at are never sent — they stay the lifecycle hook's (#3). No confirmation on complete or undo, no percentage, no evidence gate. undoable: true gives complete the platform's own toast-level Undo for the mistake noticed immediately; duly_task_undo covers 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.

cancelled is 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:

✓ Validation passed (332ms) # pnpm validate — 5 Actions
(tsc --noEmit, silent) # pnpm typecheck
Test Files 6 passed (6) # pnpm test
Tests 210 passed (210)
✓ Build complete (498ms) # pnpm build → dist/objectstack.json (73.0 KB)

Two ablations, because the interesting failures are silent

1 — the trap with no author-time gate. Removed registerTaskActionHandlers(ql) from register-handlers.ts (mutation confirmed on disk: the call count went 1 → 0 and the mutated function body was printed before anything was read):

=== pnpm validate under the ablation ===
VALIDATE_EXIT=0
✓ Validation passed (287ms)
=== pnpm test under the ablation ===
TEST_EXIT=1 17 failed
FAIL test/task-actions.test.ts > registration > every declared handler-backed action has a handler registered under duly_task
FAIL test/task-actions.test.ts > registration > dispatches for real — the wiring end to end, not just the registry

Confirmed: an unregistered handler ships green. The test is the only gate, so it dispatches through the engine's own executeAction rather than inspecting a registry.

2 — the bare predicate. Mutated duly_task_undo's visible to bare status == "done" (confirmed on disk: qualified 0, bare 1, with the three predicate lines printed). validaterejects it —

✗ Author-time rules failed (1 issue)
• stack · action 'duly_task_undo' visible: bare reference `status` — … Write `record.status`.
rule: expression-invalid

— and the suite catches it independently. So the action surface really is gated (unlike flows, objectstack#14089); both legs restored via trap … EXIT INT TERM and the clean tree verified after each.

Tests — test/task-actions.test.ts, 35 cases on a real booted engine

Against 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 with completed_at set". dispatch() mirrors the platform dispatcher's context construction — including the swallow-then-stamp detail above, so subject: '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:

expect(caught.code).toBe('VALIDATION_FAILED');
expect(caught.message).toBe('Say why the task was skipped.');

One measured hazard is pinned in both directions. A predicate update carries one payload for all N rows, so the completed_at the 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' visible predicate 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

Filed

objectstack#14092no declarative row-action field write, while bulk has one — the platform gap this card was told to look for
objectstack#14093ActionSchema.method's worked example points at a route the router does not mount
duly#39bulk status write re-stamps completed_at on an already-done row in the batch
duly#40the three task actions need the same requiredPermissions gate as the catalog actions (sub-issue of #30)

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One-click completion with undo, and skip-with-reason

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

One-click completion with undo, and skip-with-reason - #41

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete
Sep 1, 2026
Merged

One-click completion with undo, and skip-with-reason#41
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 bulkActionDefs with operation: 'update' and a static patchis the platform's "set these fields on every selected record". No action, no handler, no code:

{name: 'duly_task_bulk_complete',operation: 'update',patch: {status: 'done'},visible: P`record.status == "open" || record.status == "in_progress"`}

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.ActionType is url | form | flow | script | api | modal. There is no update_record type, no action effect, no patch key on ActionSchema. The two near-misses and why neither is it:

  • type: 'api' + method: 'PATCH' + bodyExtra is 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 to objectName, nothing checks it at author time, and it pins the transport: with projectResolution: '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 /data segment), which is filed separately.
  • type: 'flow' + an update_record node 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 where validate currently 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.ts header 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.engine is system-elevated and RLS/FLS-bypassing, and visible is 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 loads ctx.record under the caller's scope, swallows a failed load to {}, then stamps record.id = recordId on unconditionally — so ctx.record.id is present even when the caller cannot read the row. The check keys on status (required: true, so every stored row has one) instead. That is the part of #14092 that matters.

What it does

completeundoskip
payload{ status: 'done' }{ status: 'in_progress' }{ status: 'skipped', skip_reason }
fromopen, in_progressdoneopen, in_progress
dialognonenonethe reason, and only the reason

completed_at and last_update_at are never sent — they stay the lifecycle hook's (#3). No confirmation on complete or undo, no percentage, no evidence gate. undoable: true gives complete the platform's own toast-level Undo for the mistake noticed immediately; duly_task_undo covers 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.

cancelled is 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:

✓ Validation passed (332ms) # pnpm validate — 5 Actions
(tsc --noEmit, silent) # pnpm typecheck
Test Files 6 passed (6) # pnpm test
Tests 210 passed (210)
✓ Build complete (498ms) # pnpm build → dist/objectstack.json (73.0 KB)

Two ablations, because the interesting failures are silent

1 — the trap with no author-time gate. Removed registerTaskActionHandlers(ql) from register-handlers.ts (mutation confirmed on disk: the call count went 1 → 0 and the mutated function body was printed before anything was read):

=== pnpm validate under the ablation ===
VALIDATE_EXIT=0
✓ Validation passed (287ms)
=== pnpm test under the ablation ===
TEST_EXIT=1 17 failed
FAIL test/task-actions.test.ts > registration > every declared handler-backed action has a handler registered under duly_task
FAIL test/task-actions.test.ts > registration > dispatches for real — the wiring end to end, not just the registry

Confirmed: an unregistered handler ships green. The test is the only gate, so it dispatches through the engine's own executeAction rather than inspecting a registry.

2 — the bare predicate. Mutated duly_task_undo's visible to bare status == "done" (confirmed on disk: qualified 0, bare 1, with the three predicate lines printed). validaterejects it —

✗ Author-time rules failed (1 issue)
• stack · action 'duly_task_undo' visible: bare reference `status` — … Write `record.status`.
rule: expression-invalid

— and the suite catches it independently. So the action surface really is gated (unlike flows, objectstack#14089); both legs restored via trap … EXIT INT TERM and the clean tree verified after each.

Tests — test/task-actions.test.ts, 35 cases on a real booted engine

Against 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 with completed_at set". dispatch() mirrors the platform dispatcher's context construction — including the swallow-then-stamp detail above, so subject: '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:

expect(caught.code).toBe('VALIDATION_FAILED');
expect(caught.message).toBe('Say why the task was skipped.');

One measured hazard is pinned in both directions. A predicate update carries one payload for all N rows, so the completed_at the 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' visible predicate 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

Filed

objectstack#14092no declarative row-action field write, while bulk has one — the platform gap this card was told to look for
objectstack#14093ActionSchema.method's worked example points at a route the router does not mount
duly#39bulk status write re-stamps completed_at on an already-done row in the batch
duly#40the three task actions need the same requiredPermissions gate as the catalog actions (sub-issue of #30)

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One-click completion with undo, and skip-with-reason

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

One-click completion with undo, and skip-with-reason - #41

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete
Sep 1, 2026
Merged

One-click completion with undo, and skip-with-reason#41
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 bulkActionDefs with operation: 'update' and a static patchis the platform's "set these fields on every selected record". No action, no handler, no code:

{name: 'duly_task_bulk_complete',operation: 'update',patch: {status: 'done'},visible: P`record.status == "open" || record.status == "in_progress"`}

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.ActionType is url | form | flow | script | api | modal. There is no update_record type, no action effect, no patch key on ActionSchema. The two near-misses and why neither is it:

  • type: 'api' + method: 'PATCH' + bodyExtra is 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 to objectName, nothing checks it at author time, and it pins the transport: with projectResolution: '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 /data segment), which is filed separately.
  • type: 'flow' + an update_record node 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 where validate currently 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.ts header 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.engine is system-elevated and RLS/FLS-bypassing, and visible is 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 loads ctx.record under the caller's scope, swallows a failed load to {}, then stamps record.id = recordId on unconditionally — so ctx.record.id is present even when the caller cannot read the row. The check keys on status (required: true, so every stored row has one) instead. That is the part of #14092 that matters.

What it does

completeundoskip
payload{ status: 'done' }{ status: 'in_progress' }{ status: 'skipped', skip_reason }
fromopen, in_progressdoneopen, in_progress
dialognonenonethe reason, and only the reason

completed_at and last_update_at are never sent — they stay the lifecycle hook's (#3). No confirmation on complete or undo, no percentage, no evidence gate. undoable: true gives complete the platform's own toast-level Undo for the mistake noticed immediately; duly_task_undo covers 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.

cancelled is 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:

✓ Validation passed (332ms) # pnpm validate — 5 Actions
(tsc --noEmit, silent) # pnpm typecheck
Test Files 6 passed (6) # pnpm test
Tests 210 passed (210)
✓ Build complete (498ms) # pnpm build → dist/objectstack.json (73.0 KB)

Two ablations, because the interesting failures are silent

1 — the trap with no author-time gate. Removed registerTaskActionHandlers(ql) from register-handlers.ts (mutation confirmed on disk: the call count went 1 → 0 and the mutated function body was printed before anything was read):

=== pnpm validate under the ablation ===
VALIDATE_EXIT=0
✓ Validation passed (287ms)
=== pnpm test under the ablation ===
TEST_EXIT=1 17 failed
FAIL test/task-actions.test.ts > registration > every declared handler-backed action has a handler registered under duly_task
FAIL test/task-actions.test.ts > registration > dispatches for real — the wiring end to end, not just the registry

Confirmed: an unregistered handler ships green. The test is the only gate, so it dispatches through the engine's own executeAction rather than inspecting a registry.

2 — the bare predicate. Mutated duly_task_undo's visible to bare status == "done" (confirmed on disk: qualified 0, bare 1, with the three predicate lines printed). validaterejects it —

✗ Author-time rules failed (1 issue)
• stack · action 'duly_task_undo' visible: bare reference `status` — … Write `record.status`.
rule: expression-invalid

— and the suite catches it independently. So the action surface really is gated (unlike flows, objectstack#14089); both legs restored via trap … EXIT INT TERM and the clean tree verified after each.

Tests — test/task-actions.test.ts, 35 cases on a real booted engine

Against 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 with completed_at set". dispatch() mirrors the platform dispatcher's context construction — including the swallow-then-stamp detail above, so subject: '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:

expect(caught.code).toBe('VALIDATION_FAILED');
expect(caught.message).toBe('Say why the task was skipped.');

One measured hazard is pinned in both directions. A predicate update carries one payload for all N rows, so the completed_at the 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' visible predicate 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

Filed

objectstack#14092no declarative row-action field write, while bulk has one — the platform gap this card was told to look for
objectstack#14093ActionSchema.method's worked example points at a route the router does not mount
duly#39bulk status write re-stamps completed_at on an already-done row in the batch
duly#40the three task actions need the same requiredPermissions gate as the catalog actions (sub-issue of #30)

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One-click completion with undo, and skip-with-reason

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

One-click completion with undo, and skip-with-reason - #41

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete
Sep 1, 2026
Merged

One-click completion with undo, and skip-with-reason#41
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 bulkActionDefs with operation: 'update' and a static patchis the platform's "set these fields on every selected record". No action, no handler, no code:

{name: 'duly_task_bulk_complete',operation: 'update',patch: {status: 'done'},visible: P`record.status == "open" || record.status == "in_progress"`}

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.ActionType is url | form | flow | script | api | modal. There is no update_record type, no action effect, no patch key on ActionSchema. The two near-misses and why neither is it:

  • type: 'api' + method: 'PATCH' + bodyExtra is 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 to objectName, nothing checks it at author time, and it pins the transport: with projectResolution: '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 /data segment), which is filed separately.
  • type: 'flow' + an update_record node 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 where validate currently 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.ts header 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.engine is system-elevated and RLS/FLS-bypassing, and visible is 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 loads ctx.record under the caller's scope, swallows a failed load to {}, then stamps record.id = recordId on unconditionally — so ctx.record.id is present even when the caller cannot read the row. The check keys on status (required: true, so every stored row has one) instead. That is the part of #14092 that matters.

What it does

completeundoskip
payload{ status: 'done' }{ status: 'in_progress' }{ status: 'skipped', skip_reason }
fromopen, in_progressdoneopen, in_progress
dialognonenonethe reason, and only the reason

completed_at and last_update_at are never sent — they stay the lifecycle hook's (#3). No confirmation on complete or undo, no percentage, no evidence gate. undoable: true gives complete the platform's own toast-level Undo for the mistake noticed immediately; duly_task_undo covers 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.

cancelled is 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:

✓ Validation passed (332ms) # pnpm validate — 5 Actions
(tsc --noEmit, silent) # pnpm typecheck
Test Files 6 passed (6) # pnpm test
Tests 210 passed (210)
✓ Build complete (498ms) # pnpm build → dist/objectstack.json (73.0 KB)

Two ablations, because the interesting failures are silent

1 — the trap with no author-time gate. Removed registerTaskActionHandlers(ql) from register-handlers.ts (mutation confirmed on disk: the call count went 1 → 0 and the mutated function body was printed before anything was read):

=== pnpm validate under the ablation ===
VALIDATE_EXIT=0
✓ Validation passed (287ms)
=== pnpm test under the ablation ===
TEST_EXIT=1 17 failed
FAIL test/task-actions.test.ts > registration > every declared handler-backed action has a handler registered under duly_task
FAIL test/task-actions.test.ts > registration > dispatches for real — the wiring end to end, not just the registry

Confirmed: an unregistered handler ships green. The test is the only gate, so it dispatches through the engine's own executeAction rather than inspecting a registry.

2 — the bare predicate. Mutated duly_task_undo's visible to bare status == "done" (confirmed on disk: qualified 0, bare 1, with the three predicate lines printed). validaterejects it —

✗ Author-time rules failed (1 issue)
• stack · action 'duly_task_undo' visible: bare reference `status` — … Write `record.status`.
rule: expression-invalid

— and the suite catches it independently. So the action surface really is gated (unlike flows, objectstack#14089); both legs restored via trap … EXIT INT TERM and the clean tree verified after each.

Tests — test/task-actions.test.ts, 35 cases on a real booted engine

Against 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 with completed_at set". dispatch() mirrors the platform dispatcher's context construction — including the swallow-then-stamp detail above, so subject: '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:

expect(caught.code).toBe('VALIDATION_FAILED');
expect(caught.message).toBe('Say why the task was skipped.');

One measured hazard is pinned in both directions. A predicate update carries one payload for all N rows, so the completed_at the 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' visible predicate 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

Filed

objectstack#14092no declarative row-action field write, while bulk has one — the platform gap this card was told to look for
objectstack#14093ActionSchema.method's worked example points at a route the router does not mount
duly#39bulk status write re-stamps completed_at on an already-done row in the batch
duly#40the three task actions need the same requiredPermissions gate as the catalog actions (sub-issue of #30)

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One-click completion with undo, and skip-with-reason

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

One-click completion with undo, and skip-with-reason - #41

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete
Sep 1, 2026
Merged

One-click completion with undo, and skip-with-reason#41
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 bulkActionDefs with operation: 'update' and a static patchis the platform's "set these fields on every selected record". No action, no handler, no code:

{name: 'duly_task_bulk_complete',operation: 'update',patch: {status: 'done'},visible: P`record.status == "open" || record.status == "in_progress"`}

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.ActionType is url | form | flow | script | api | modal. There is no update_record type, no action effect, no patch key on ActionSchema. The two near-misses and why neither is it:

  • type: 'api' + method: 'PATCH' + bodyExtra is 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 to objectName, nothing checks it at author time, and it pins the transport: with projectResolution: '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 /data segment), which is filed separately.
  • type: 'flow' + an update_record node 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 where validate currently 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.ts header 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.engine is system-elevated and RLS/FLS-bypassing, and visible is 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 loads ctx.record under the caller's scope, swallows a failed load to {}, then stamps record.id = recordId on unconditionally — so ctx.record.id is present even when the caller cannot read the row. The check keys on status (required: true, so every stored row has one) instead. That is the part of #14092 that matters.

What it does

completeundoskip
payload{ status: 'done' }{ status: 'in_progress' }{ status: 'skipped', skip_reason }
fromopen, in_progressdoneopen, in_progress
dialognonenonethe reason, and only the reason

completed_at and last_update_at are never sent — they stay the lifecycle hook's (#3). No confirmation on complete or undo, no percentage, no evidence gate. undoable: true gives complete the platform's own toast-level Undo for the mistake noticed immediately; duly_task_undo covers 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.

cancelled is 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:

✓ Validation passed (332ms) # pnpm validate — 5 Actions
(tsc --noEmit, silent) # pnpm typecheck
Test Files 6 passed (6) # pnpm test
Tests 210 passed (210)
✓ Build complete (498ms) # pnpm build → dist/objectstack.json (73.0 KB)

Two ablations, because the interesting failures are silent

1 — the trap with no author-time gate. Removed registerTaskActionHandlers(ql) from register-handlers.ts (mutation confirmed on disk: the call count went 1 → 0 and the mutated function body was printed before anything was read):

=== pnpm validate under the ablation ===
VALIDATE_EXIT=0
✓ Validation passed (287ms)
=== pnpm test under the ablation ===
TEST_EXIT=1 17 failed
FAIL test/task-actions.test.ts > registration > every declared handler-backed action has a handler registered under duly_task
FAIL test/task-actions.test.ts > registration > dispatches for real — the wiring end to end, not just the registry

Confirmed: an unregistered handler ships green. The test is the only gate, so it dispatches through the engine's own executeAction rather than inspecting a registry.

2 — the bare predicate. Mutated duly_task_undo's visible to bare status == "done" (confirmed on disk: qualified 0, bare 1, with the three predicate lines printed). validaterejects it —

✗ Author-time rules failed (1 issue)
• stack · action 'duly_task_undo' visible: bare reference `status` — … Write `record.status`.
rule: expression-invalid

— and the suite catches it independently. So the action surface really is gated (unlike flows, objectstack#14089); both legs restored via trap … EXIT INT TERM and the clean tree verified after each.

Tests — test/task-actions.test.ts, 35 cases on a real booted engine

Against 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 with completed_at set". dispatch() mirrors the platform dispatcher's context construction — including the swallow-then-stamp detail above, so subject: '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:

expect(caught.code).toBe('VALIDATION_FAILED');
expect(caught.message).toBe('Say why the task was skipped.');

One measured hazard is pinned in both directions. A predicate update carries one payload for all N rows, so the completed_at the 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' visible predicate 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

Filed

objectstack#14092no declarative row-action field write, while bulk has one — the platform gap this card was told to look for
objectstack#14093ActionSchema.method's worked example points at a route the router does not mount
duly#39bulk status write re-stamps completed_at on an already-done row in the batch
duly#40the three task actions need the same requiredPermissions gate as the catalog actions (sub-issue of #30)

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One-click completion with undo, and skip-with-reason

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

One-click completion with undo, and skip-with-reason - #41

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete
Sep 1, 2026
Merged

One-click completion with undo, and skip-with-reason#41
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 bulkActionDefs with operation: 'update' and a static patchis the platform's "set these fields on every selected record". No action, no handler, no code:

{name: 'duly_task_bulk_complete',operation: 'update',patch: {status: 'done'},visible: P`record.status == "open" || record.status == "in_progress"`}

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.ActionType is url | form | flow | script | api | modal. There is no update_record type, no action effect, no patch key on ActionSchema. The two near-misses and why neither is it:

  • type: 'api' + method: 'PATCH' + bodyExtra is 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 to objectName, nothing checks it at author time, and it pins the transport: with projectResolution: '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 /data segment), which is filed separately.
  • type: 'flow' + an update_record node 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 where validate currently 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.ts header 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.engine is system-elevated and RLS/FLS-bypassing, and visible is 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 loads ctx.record under the caller's scope, swallows a failed load to {}, then stamps record.id = recordId on unconditionally — so ctx.record.id is present even when the caller cannot read the row. The check keys on status (required: true, so every stored row has one) instead. That is the part of #14092 that matters.

What it does

completeundoskip
payload{ status: 'done' }{ status: 'in_progress' }{ status: 'skipped', skip_reason }
fromopen, in_progressdoneopen, in_progress
dialognonenonethe reason, and only the reason

completed_at and last_update_at are never sent — they stay the lifecycle hook's (#3). No confirmation on complete or undo, no percentage, no evidence gate. undoable: true gives complete the platform's own toast-level Undo for the mistake noticed immediately; duly_task_undo covers 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.

cancelled is 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:

✓ Validation passed (332ms) # pnpm validate — 5 Actions
(tsc --noEmit, silent) # pnpm typecheck
Test Files 6 passed (6) # pnpm test
Tests 210 passed (210)
✓ Build complete (498ms) # pnpm build → dist/objectstack.json (73.0 KB)

Two ablations, because the interesting failures are silent

1 — the trap with no author-time gate. Removed registerTaskActionHandlers(ql) from register-handlers.ts (mutation confirmed on disk: the call count went 1 → 0 and the mutated function body was printed before anything was read):

=== pnpm validate under the ablation ===
VALIDATE_EXIT=0
✓ Validation passed (287ms)
=== pnpm test under the ablation ===
TEST_EXIT=1 17 failed
FAIL test/task-actions.test.ts > registration > every declared handler-backed action has a handler registered under duly_task
FAIL test/task-actions.test.ts > registration > dispatches for real — the wiring end to end, not just the registry

Confirmed: an unregistered handler ships green. The test is the only gate, so it dispatches through the engine's own executeAction rather than inspecting a registry.

2 — the bare predicate. Mutated duly_task_undo's visible to bare status == "done" (confirmed on disk: qualified 0, bare 1, with the three predicate lines printed). validaterejects it —

✗ Author-time rules failed (1 issue)
• stack · action 'duly_task_undo' visible: bare reference `status` — … Write `record.status`.
rule: expression-invalid

— and the suite catches it independently. So the action surface really is gated (unlike flows, objectstack#14089); both legs restored via trap … EXIT INT TERM and the clean tree verified after each.

Tests — test/task-actions.test.ts, 35 cases on a real booted engine

Against 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 with completed_at set". dispatch() mirrors the platform dispatcher's context construction — including the swallow-then-stamp detail above, so subject: '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:

expect(caught.code).toBe('VALIDATION_FAILED');
expect(caught.message).toBe('Say why the task was skipped.');

One measured hazard is pinned in both directions. A predicate update carries one payload for all N rows, so the completed_at the 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' visible predicate 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

Filed

objectstack#14092no declarative row-action field write, while bulk has one — the platform gap this card was told to look for
objectstack#14093ActionSchema.method's worked example points at a route the router does not mount
duly#39bulk status write re-stamps completed_at on an already-done row in the batch
duly#40the three task actions need the same requiredPermissions gate as the catalog actions (sub-issue of #30)

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One-click completion with undo, and skip-with-reason

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

One-click completion with undo, and skip-with-reason - #41

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete
Sep 1, 2026
Merged

One-click completion with undo, and skip-with-reason#41
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 bulkActionDefs with operation: 'update' and a static patchis the platform's "set these fields on every selected record". No action, no handler, no code:

{name: 'duly_task_bulk_complete',operation: 'update',patch: {status: 'done'},visible: P`record.status == "open" || record.status == "in_progress"`}

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.ActionType is url | form | flow | script | api | modal. There is no update_record type, no action effect, no patch key on ActionSchema. The two near-misses and why neither is it:

  • type: 'api' + method: 'PATCH' + bodyExtra is 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 to objectName, nothing checks it at author time, and it pins the transport: with projectResolution: '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 /data segment), which is filed separately.
  • type: 'flow' + an update_record node 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 where validate currently 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.ts header 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.engine is system-elevated and RLS/FLS-bypassing, and visible is 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 loads ctx.record under the caller's scope, swallows a failed load to {}, then stamps record.id = recordId on unconditionally — so ctx.record.id is present even when the caller cannot read the row. The check keys on status (required: true, so every stored row has one) instead. That is the part of #14092 that matters.

What it does

completeundoskip
payload{ status: 'done' }{ status: 'in_progress' }{ status: 'skipped', skip_reason }
fromopen, in_progressdoneopen, in_progress
dialognonenonethe reason, and only the reason

completed_at and last_update_at are never sent — they stay the lifecycle hook's (#3). No confirmation on complete or undo, no percentage, no evidence gate. undoable: true gives complete the platform's own toast-level Undo for the mistake noticed immediately; duly_task_undo covers 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.

cancelled is 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:

✓ Validation passed (332ms) # pnpm validate — 5 Actions
(tsc --noEmit, silent) # pnpm typecheck
Test Files 6 passed (6) # pnpm test
Tests 210 passed (210)
✓ Build complete (498ms) # pnpm build → dist/objectstack.json (73.0 KB)

Two ablations, because the interesting failures are silent

1 — the trap with no author-time gate. Removed registerTaskActionHandlers(ql) from register-handlers.ts (mutation confirmed on disk: the call count went 1 → 0 and the mutated function body was printed before anything was read):

=== pnpm validate under the ablation ===
VALIDATE_EXIT=0
✓ Validation passed (287ms)
=== pnpm test under the ablation ===
TEST_EXIT=1 17 failed
FAIL test/task-actions.test.ts > registration > every declared handler-backed action has a handler registered under duly_task
FAIL test/task-actions.test.ts > registration > dispatches for real — the wiring end to end, not just the registry

Confirmed: an unregistered handler ships green. The test is the only gate, so it dispatches through the engine's own executeAction rather than inspecting a registry.

2 — the bare predicate. Mutated duly_task_undo's visible to bare status == "done" (confirmed on disk: qualified 0, bare 1, with the three predicate lines printed). validaterejects it —

✗ Author-time rules failed (1 issue)
• stack · action 'duly_task_undo' visible: bare reference `status` — … Write `record.status`.
rule: expression-invalid

— and the suite catches it independently. So the action surface really is gated (unlike flows, objectstack#14089); both legs restored via trap … EXIT INT TERM and the clean tree verified after each.

Tests — test/task-actions.test.ts, 35 cases on a real booted engine

Against 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 with completed_at set". dispatch() mirrors the platform dispatcher's context construction — including the swallow-then-stamp detail above, so subject: '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:

expect(caught.code).toBe('VALIDATION_FAILED');
expect(caught.message).toBe('Say why the task was skipped.');

One measured hazard is pinned in both directions. A predicate update carries one payload for all N rows, so the completed_at the 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' visible predicate 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

Filed

objectstack#14092no declarative row-action field write, while bulk has one — the platform gap this card was told to look for
objectstack#14093ActionSchema.method's worked example points at a route the router does not mount
duly#39bulk status write re-stamps completed_at on an already-done row in the batch
duly#40the three task actions need the same requiredPermissions gate as the catalog actions (sub-issue of #30)

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One-click completion with undo, and skip-with-reason

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

One-click completion with undo, and skip-with-reason - #41

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete
Sep 1, 2026
Merged

One-click completion with undo, and skip-with-reason#41
os-warren merged 1 commit into
mainfrom
claude/issue-4-one-click-complete

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 bulkActionDefs with operation: 'update' and a static patchis the platform's "set these fields on every selected record". No action, no handler, no code:

{name: 'duly_task_bulk_complete',operation: 'update',patch: {status: 'done'},visible: P`record.status == "open" || record.status == "in_progress"`}

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.ActionType is url | form | flow | script | api | modal. There is no update_record type, no action effect, no patch key on ActionSchema. The two near-misses and why neither is it:

  • type: 'api' + method: 'PATCH' + bodyExtra is 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 to objectName, nothing checks it at author time, and it pins the transport: with projectResolution: '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 /data segment), which is filed separately.
  • type: 'flow' + an update_record node 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 where validate currently 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.ts header 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.engine is system-elevated and RLS/FLS-bypassing, and visible is 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 loads ctx.record under the caller's scope, swallows a failed load to {}, then stamps record.id = recordId on unconditionally — so ctx.record.id is present even when the caller cannot read the row. The check keys on status (required: true, so every stored row has one) instead. That is the part of #14092 that matters.

What it does

completeundoskip
payload{ status: 'done' }{ status: 'in_progress' }{ status: 'skipped', skip_reason }
fromopen, in_progressdoneopen, in_progress
dialognonenonethe reason, and only the reason

completed_at and last_update_at are never sent — they stay the lifecycle hook's (#3). No confirmation on complete or undo, no percentage, no evidence gate. undoable: true gives complete the platform's own toast-level Undo for the mistake noticed immediately; duly_task_undo covers 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.

cancelled is 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:

✓ Validation passed (332ms) # pnpm validate — 5 Actions
(tsc --noEmit, silent) # pnpm typecheck
Test Files 6 passed (6) # pnpm test
Tests 210 passed (210)
✓ Build complete (498ms) # pnpm build → dist/objectstack.json (73.0 KB)

Two ablations, because the interesting failures are silent

1 — the trap with no author-time gate. Removed registerTaskActionHandlers(ql) from register-handlers.ts (mutation confirmed on disk: the call count went 1 → 0 and the mutated function body was printed before anything was read):

=== pnpm validate under the ablation ===
VALIDATE_EXIT=0
✓ Validation passed (287ms)
=== pnpm test under the ablation ===
TEST_EXIT=1 17 failed
FAIL test/task-actions.test.ts > registration > every declared handler-backed action has a handler registered under duly_task
FAIL test/task-actions.test.ts > registration > dispatches for real — the wiring end to end, not just the registry

Confirmed: an unregistered handler ships green. The test is the only gate, so it dispatches through the engine's own executeAction rather than inspecting a registry.

2 — the bare predicate. Mutated duly_task_undo's visible to bare status == "done" (confirmed on disk: qualified 0, bare 1, with the three predicate lines printed). validaterejects it —

✗ Author-time rules failed (1 issue)
• stack · action 'duly_task_undo' visible: bare reference `status` — … Write `record.status`.
rule: expression-invalid

— and the suite catches it independently. So the action surface really is gated (unlike flows, objectstack#14089); both legs restored via trap … EXIT INT TERM and the clean tree verified after each.

Tests — test/task-actions.test.ts, 35 cases on a real booted engine

Against 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 with completed_at set". dispatch() mirrors the platform dispatcher's context construction — including the swallow-then-stamp detail above, so subject: '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:

expect(caught.code).toBe('VALIDATION_FAILED');
expect(caught.message).toBe('Say why the task was skipped.');

One measured hazard is pinned in both directions. A predicate update carries one payload for all N rows, so the completed_at the 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' visible predicate 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

Filed

objectstack#14092no declarative row-action field write, while bulk has one — the platform gap this card was told to look for
objectstack#14093ActionSchema.method's worked example points at a route the router does not mount
duly#39bulk status write re-stamps completed_at on an already-done row in the batch
duly#40the three task actions need the same requiredPermissions gate as the catalog actions (sub-issue of #30)

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One-click completion with undo, and skip-with-reason

2 participants

@os-warren@claude