Measured against @objectstack/spec / @objectstack/rest / @objectstack/runtime17.2.0, while building the completion interaction of the duly metadata app (objectstack-ai/duly#4): three buttons that each set one field on one record.
The asymmetry
Selection → declarative. A list view's bulkActionDefs expresses it exactly:
{name: 'duly_task_bulk_complete',operation: 'update',patch: {status: 'done'},visible: P`record.status == "open" || record.status == "in_progress"`}No action, no handler, no code. The write runs on the data plane under the caller's own permissions, hooks and validations fire, and patch merges under collected params so a fixed value can be declared without exposing it in the dialog. This is the right shape and it works.
Row → nothing.ActionType is url | form | flow | script | api | modal. There is no update_record type, no action effect, and no patch-shaped key on ActionSchema. So the identical intent, one row instead of twenty, has to be hand-written.
Why neither near-miss is the declarative form
type: 'api' + method: 'PATCH' + bodyExtra is a declarative HTTP call, not a declarative field write. The author hand-writes the platform's own data-API path into application metadata:
target: '/api/v1/data/duly_task/${ctx.recordId}'- Nothing binds that string to the action's
objectName; the object name is duplicated into a path literal. - Nothing checks it at author time —
objectstack validate passes on any string. - It pins the transport.
enableProjectScoping + projectResolution: 'required' registers only/api/v1/environments/:environmentId/data/:object/:id, so an app that shipped the unscoped path is broken on that host with no diagnostic. - The platform cannot tell it is a record update, which is presumably why
undoable ("single-record update actions only — captures the record's prior field values") has nothing to key on here.
For AI-authored metadata this is the worst available shape: it parses green and 404s at the click. (The spec's own worked example of this route is itself wrong — filed separately.)
type: 'flow' + a flow with 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 surface where validate currently misses bare predicates (#14089).
The cost is authorization, not the one-line write
This is the part that matters. A handler's ctx.engine is system-elevated and RLS/FLS-bypassing by design — buildActionExecutionContext stamps isSystem: true onto the caller's context, and both dispatch surfaces log the write as TRUSTED. visible is a UI hide, not authorization.
So every app that wants "tick this row" must hand-write a privileged write and re-establish the authorization the declarative bulk path gets for free. And the obvious guard does not work: the dispatcher loads ctx.record under the caller's scope, swallows a failed load to {}, and then stamps record.id = recordId on unconditionally — so ctx.record.id is present even when the caller cannot read the row. We had to key the check on a field that is required: true on the object instead:
conststatus=text(record.status);if(!status)throwrefuse(`Task ${id} is not available to you.`,'DULY_TASK_NOT_AVAILABLE',404);That is subtle, undocumented, discovered by reading the dispatcher, and every app author — human or AI — has to rediscover it or ship an authorization hole on a private object.
What would close it
A row-action counterpart to bulkActionDefs' operation: 'update' — the platform knowing the action is a single-record field write, so it can route it through the data plane under the caller's own credentials, bind the object from objectName, and let undoable mean something. Shape is yours to pick; the requirement is that "set status to done on this record" stops being three files of privileged imperative code with a hand-rolled permission check.
Where this came from
objectstack-ai/duly#4. The app took the declarative route for bulk and wrote handlers for the rows; the rationale is in src/actions/task.handlers.ts there. Fourth platform gap from dogfooding duly — siblings #14087, #14088, #14089.
Measured against
@objectstack/spec/@objectstack/rest/@objectstack/runtime17.2.0, while building the completion interaction of thedulymetadata app (objectstack-ai/duly#4): three buttons that each set one field on one record.The asymmetry
Selection → declarative. A list view's
bulkActionDefsexpresses it exactly:No action, no handler, no code. The write runs on the data plane under the caller's own permissions, hooks and validations fire, and
patchmerges under collected params so a fixed value can be declared without exposing it in the dialog. This is the right shape and it works.Row → nothing.
ActionTypeisurl | form | flow | script | api | modal. There is noupdate_recordtype, no actioneffect, and nopatch-shaped key onActionSchema. So the identical intent, one row instead of twenty, has to be hand-written.Why neither near-miss is the declarative form
type: 'api'+method: 'PATCH'+bodyExtrais a declarative HTTP call, not a declarative field write. The author hand-writes the platform's own data-API path into application metadata:target: '/api/v1/data/duly_task/${ctx.recordId}'objectName; the object name is duplicated into a path literal.objectstack validatepasses on any string.enableProjectScoping+projectResolution: 'required'registers only/api/v1/environments/:environmentId/data/:object/:id, so an app that shipped the unscoped path is broken on that host with no diagnostic.undoable("single-record update actions only — captures the record's prior field values") has nothing to key on here.For AI-authored metadata this is the worst available shape: it parses green and 404s at the click. (The spec's own worked example of this route is itself wrong — filed separately.)
type: 'flow'+ a flow with 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 surface wherevalidatecurrently misses bare predicates (#14089).The cost is authorization, not the one-line write
This is the part that matters. A handler's
ctx.engineis system-elevated and RLS/FLS-bypassing by design —buildActionExecutionContextstampsisSystem: trueonto the caller's context, and both dispatch surfaces log the write as TRUSTED.visibleis a UI hide, not authorization.So every app that wants "tick this row" must hand-write a privileged write and re-establish the authorization the declarative bulk path gets for free. And the obvious guard does not work: the dispatcher loads
ctx.recordunder the caller's scope, swallows a failed load to{}, and then stampsrecord.id = recordIdon unconditionally — soctx.record.idis present even when the caller cannot read the row. We had to key the check on a field that isrequired: trueon the object instead:That is subtle, undocumented, discovered by reading the dispatcher, and every app author — human or AI — has to rediscover it or ship an authorization hole on a
privateobject.What would close it
A row-action counterpart to
bulkActionDefs'operation: 'update'— the platform knowing the action is a single-record field write, so it can route it through the data plane under the caller's own credentials, bind the object fromobjectName, and letundoablemean something. Shape is yours to pick; the requirement is that "setstatustodoneon this record" stops being three files of privileged imperative code with a hand-rolled permission check.Where this came from
objectstack-ai/duly#4. The app took the declarative route for bulk and wrote handlers for the rows; the rationale is in
src/actions/task.handlers.tsthere. Fourth platform gap from dogfoodingduly— siblings #14087, #14088, #14089.