The most common action in any app — set a field on the current record — has no declarative form for a ROW action, while the BULK form is fully declarative #14092

Description

@os-warren

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 designbuildActionExecutionContext 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , '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

      The most common action in any app — set a field on the current record — has no declarative form for a ROW action, while the BULK form is fully declarative #14092

      Description

      @os-warren

      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 designbuildActionExecutionContext 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.

      Metadata

      Metadata

      Assignees

      No one assigned

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , '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

          The most common action in any app — set a field on the current record — has no declarative form for a ROW action, while the BULK form is fully declarative #14092

          Description

          @os-warren

          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 designbuildActionExecutionContext 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.

          Metadata

          Metadata

          Assignees

          No one assigned

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , '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

              The most common action in any app — set a field on the current record — has no declarative form for a ROW action, while the BULK form is fully declarative #14092

              Description

              @os-warren

              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 designbuildActionExecutionContext 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.

              Metadata

              Metadata

              Assignees

              No one assigned

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , '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

                  The most common action in any app — set a field on the current record — has no declarative form for a ROW action, while the BULK form is fully declarative #14092

                  Description

                  @os-warren

                  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 designbuildActionExecutionContext 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.

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Type

                    No type

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , '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

                      The most common action in any app — set a field on the current record — has no declarative form for a ROW action, while the BULK form is fully declarative #14092

                      Description

                      @os-warren

                      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 designbuildActionExecutionContext 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.

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Type

                        No type

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , '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

                          The most common action in any app — set a field on the current record — has no declarative form for a ROW action, while the BULK form is fully declarative #14092

                          Description

                          @os-warren

                          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 designbuildActionExecutionContext 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.

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Type

                            No type

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , '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

                              The most common action in any app — set a field on the current record — has no declarative form for a ROW action, while the BULK form is fully declarative #14092

                              Description

                              @os-warren

                              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 designbuildActionExecutionContext 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.

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Type

                                No type

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions