[hooks] ADR-0058 Addendum II D3 names three routes out of the batch-scoped payload, and a body-only hook can reach none of them — the sandbox context carries no per-row signal (declared ≠ observable) #11552

Description

@os-warren

Filed unassigned — a record from an app-side investigation (hotcrm#1265), not a claim.

The gap

ADR-0058 Addendum II D3 makes the predicate-update payload BATCH-scoped and says so plainly, including that a rewrite conditioned on the row is outside the contract. It then names the three supported routes for row-specific work, quoted from the installed @objectstack/spec 17.1.0 (dist/data/index.d.ts):

The three supported routes for row-specific work are: throw (which is what the guard case wants), write through ctx.api per row, or have the CALLER paginate the batch into by-id updates.

The ADR is explicit that D3 is a contract statement and not an enforcement, and gives the reason (no static rule can decide whether a rewrite is row-invariant). That part is a deliberate, reasoned ruling and this card does not argue with it.

The gap is narrower and, I think, unintended: routes 1 and 2 require the handler to know it is on the per-row predicate path, and a hook shipped body-only cannot know that. The signal exists on the engine context and is dropped at the sandbox boundary.

Measured on 17.1.0

buildSandboxContext (@objectstack/runtime) builds the hook body's context with exactly:

input, previous, user, session, event, object, result, api, log, crypto

No dispatch. And input is unwrapProxyToPlain(engineCtx.input), i.e. Object.fromEntries(Object.entries(v)) — enumerable own keys only. On the per-row path dispatchPerRowBeforeHooks builds input: { id: rowId, data: batchCtx.input.data, options }, and installFlatInput wraps it in a proxy whose ownKeys returns only the payload's data keys and whose getOwnPropertyDescriptor marks id / options / datanon-enumerable. So the unwrap copies the payload and drops id and options with it.

Probe body run through the real QuickJSScriptRunner + hookBodyRunnerFactory, against an engine context shaped exactly as dispatchPerRowBeforeHooks builds one:

hasDispatch : "undefined"
inputKeys : ["title"] <- payload only
inputId : undefined
inputOptions : undefined
previousKeys : ["id","status","published_at"]
ctxKeys : ["input","previous","user","session","event","object","api","log","crypto"]

So inside a shipped body:

  • ctx.dispatch?.mode — absent, so route 1 (throw) cannot be scoped to the batch path;
  • ctx.input.options.multi / .where — absent, though D2 states these are visible to the before* phase ("input.options is the CALLER's bag (where and multi visible)"). That is true of the engine context and false of the body context;
  • ctx.input.id — absent, so route 2 (ctx.api per row) has no row id from input and, more fundamentally, cannot tell whether it should fire at all (on the single-record path it would double-write);
  • ctx.previouspresent. The one row-conditioned input that does cross is exactly the one D3 says must not aim a rewrite.

Route 3 (caller paginates) is not available to a handler at all.

Net: the surface hands the author the ingredient that makes the mistake expressible and withholds every ingredient that would let them comply.

Why this bites harder than "unenforced"

The ADR's own reasoning for leaving D3 unenforced is that no static rule can decide row-invariance, and that the hazard therefore belongs in authoring docs plus a possible advisory lint. That reasoning assumes the author who reads the docs can then act on them. Today they cannot, if the hook ships body-only.

Worse, the natural fix is silently inert. A guard written as:

if(ctx.dispatch?.mode==='per-row'){/* refuse, or skip the row-conditioned write */}

lowers cleanly through extractHookBody, passes in-process handler tests (which run the closure with a full engine context), and evaluates to false on every dispatch in production — so the widening continues while the code reads as though it were prevented. This is the hint / ctx.log.debug / crypto.hash family (#7661, #4391): declared on one face, absent on the face that ships.

Blast radius, measured in the exemplar app

hotcrm#1265 was filed for one hook. Triaging its beforeUpdate handlers against the mechanical test — does a payload write depend on the row?9 of the 17 previous-reading hook files carry at least one row-conditioned payload write, and none of them can be fixed app-side for the reason above:

hookshape
knowledge_article_publish_timestampspublished_at existence criterion
campaign_member_lifecycleresponse_date existence criterion + wasResponded from previous
forecast_derive_periodsnapshot_date: !input.x && !previous?.x
case_sla_defaultssla_due_date gated on !previous?.sla_due_date, value from the row's account tier
task_completioncompleted_date / progress_percent gated on previous?.status
opportunity_lifecycleclose_date / stage_entry_date gated on previous.stage
event_schedule_deriveduration_minutes / end_datetime from previous fallbacks
lead_duplicate_checkduplicate_of_type gated on previous?.duplicate_status
account_protectionlast_activity_date gated on input.owner_id !== previous.owner_id

The clean ones are instructive too: product_catalog and contract_validation read previousonly to throw, which is precisely D3's blessed pattern — and they get away with it only because they refuse unconditionally, never "refuse on the batch path".

What would close it

Options, roughly in increasing cost — the choice is a maintainer's, and I have deliberately not assumed one:

  1. Marshal a per-row signal into the sandbox context. Smallest change: add dispatch (or a boolean isPerRow) to buildSandboxContext. Makes route 1 expressible immediately.
  2. Also surface input.options (multi / where), which D2 already declares visible to the before* phase and which the body context contradicts today. This is arguably a spec-vs-engine drift independent of D3.
  3. Advisory lint at the seam the ADR itself names (packages/lint's validate-hook-body-writes) — flag a payload write whose guard or value reads previous / input.id. Complements 1 rather than replacing it: a lint tells the author they have a problem, but only 1 gives them a way to fix it.

If the intended answer is instead "a row-conditioned stamp does not belong in a hook at all, use an action/flow", that is a legitimate ruling too — but it should be written down, because D3 currently reads as though routes 1 and 2 are available.

Reproduction

hotcrm@claude/issue-1265-batch-scoped-payload carries a passing tripwire test asserting the four facts above (test/hooks-runtime-service.test.ts, #1265 — the shipped hook body cannot tell it is on a per-row predicate dispatch). It is written to go red when this card is fixed.

Related: hotcrm#1265 (the app-side card, blocked on this), #5574 (per-row before* dispatch), #6966 (input.id binding semantics), #3700 (hook body writes not statically checkable), #7661 / #4391 (same declared-but-absent-in-sandbox family).

Metadata

Metadata

Assignees

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

    [hooks] ADR-0058 Addendum II D3 names three routes out of the batch-scoped payload, and a body-only hook can reach none of them — the sandbox context carries no per-row signal (declared ≠ observable) #11552

    Description

    @os-warren

    Filed unassigned — a record from an app-side investigation (hotcrm#1265), not a claim.

    The gap

    ADR-0058 Addendum II D3 makes the predicate-update payload BATCH-scoped and says so plainly, including that a rewrite conditioned on the row is outside the contract. It then names the three supported routes for row-specific work, quoted from the installed @objectstack/spec 17.1.0 (dist/data/index.d.ts):

    The three supported routes for row-specific work are: throw (which is what the guard case wants), write through ctx.api per row, or have the CALLER paginate the batch into by-id updates.

    The ADR is explicit that D3 is a contract statement and not an enforcement, and gives the reason (no static rule can decide whether a rewrite is row-invariant). That part is a deliberate, reasoned ruling and this card does not argue with it.

    The gap is narrower and, I think, unintended: routes 1 and 2 require the handler to know it is on the per-row predicate path, and a hook shipped body-only cannot know that. The signal exists on the engine context and is dropped at the sandbox boundary.

    Measured on 17.1.0

    buildSandboxContext (@objectstack/runtime) builds the hook body's context with exactly:

    input, previous, user, session, event, object, result, api, log, crypto
    

    No dispatch. And input is unwrapProxyToPlain(engineCtx.input), i.e. Object.fromEntries(Object.entries(v)) — enumerable own keys only. On the per-row path dispatchPerRowBeforeHooks builds input: { id: rowId, data: batchCtx.input.data, options }, and installFlatInput wraps it in a proxy whose ownKeys returns only the payload's data keys and whose getOwnPropertyDescriptor marks id / options / datanon-enumerable. So the unwrap copies the payload and drops id and options with it.

    Probe body run through the real QuickJSScriptRunner + hookBodyRunnerFactory, against an engine context shaped exactly as dispatchPerRowBeforeHooks builds one:

    hasDispatch : "undefined"
    inputKeys : ["title"] <- payload only
    inputId : undefined
    inputOptions : undefined
    previousKeys : ["id","status","published_at"]
    ctxKeys : ["input","previous","user","session","event","object","api","log","crypto"]
    

    So inside a shipped body:

    • ctx.dispatch?.mode — absent, so route 1 (throw) cannot be scoped to the batch path;
    • ctx.input.options.multi / .where — absent, though D2 states these are visible to the before* phase ("input.options is the CALLER's bag (where and multi visible)"). That is true of the engine context and false of the body context;
    • ctx.input.id — absent, so route 2 (ctx.api per row) has no row id from input and, more fundamentally, cannot tell whether it should fire at all (on the single-record path it would double-write);
    • ctx.previouspresent. The one row-conditioned input that does cross is exactly the one D3 says must not aim a rewrite.

    Route 3 (caller paginates) is not available to a handler at all.

    Net: the surface hands the author the ingredient that makes the mistake expressible and withholds every ingredient that would let them comply.

    Why this bites harder than "unenforced"

    The ADR's own reasoning for leaving D3 unenforced is that no static rule can decide row-invariance, and that the hazard therefore belongs in authoring docs plus a possible advisory lint. That reasoning assumes the author who reads the docs can then act on them. Today they cannot, if the hook ships body-only.

    Worse, the natural fix is silently inert. A guard written as:

    if(ctx.dispatch?.mode==='per-row'){/* refuse, or skip the row-conditioned write */}

    lowers cleanly through extractHookBody, passes in-process handler tests (which run the closure with a full engine context), and evaluates to false on every dispatch in production — so the widening continues while the code reads as though it were prevented. This is the hint / ctx.log.debug / crypto.hash family (#7661, #4391): declared on one face, absent on the face that ships.

    Blast radius, measured in the exemplar app

    hotcrm#1265 was filed for one hook. Triaging its beforeUpdate handlers against the mechanical test — does a payload write depend on the row?9 of the 17 previous-reading hook files carry at least one row-conditioned payload write, and none of them can be fixed app-side for the reason above:

    hookshape
    knowledge_article_publish_timestampspublished_at existence criterion
    campaign_member_lifecycleresponse_date existence criterion + wasResponded from previous
    forecast_derive_periodsnapshot_date: !input.x && !previous?.x
    case_sla_defaultssla_due_date gated on !previous?.sla_due_date, value from the row's account tier
    task_completioncompleted_date / progress_percent gated on previous?.status
    opportunity_lifecycleclose_date / stage_entry_date gated on previous.stage
    event_schedule_deriveduration_minutes / end_datetime from previous fallbacks
    lead_duplicate_checkduplicate_of_type gated on previous?.duplicate_status
    account_protectionlast_activity_date gated on input.owner_id !== previous.owner_id

    The clean ones are instructive too: product_catalog and contract_validation read previousonly to throw, which is precisely D3's blessed pattern — and they get away with it only because they refuse unconditionally, never "refuse on the batch path".

    What would close it

    Options, roughly in increasing cost — the choice is a maintainer's, and I have deliberately not assumed one:

    1. Marshal a per-row signal into the sandbox context. Smallest change: add dispatch (or a boolean isPerRow) to buildSandboxContext. Makes route 1 expressible immediately.
    2. Also surface input.options (multi / where), which D2 already declares visible to the before* phase and which the body context contradicts today. This is arguably a spec-vs-engine drift independent of D3.
    3. Advisory lint at the seam the ADR itself names (packages/lint's validate-hook-body-writes) — flag a payload write whose guard or value reads previous / input.id. Complements 1 rather than replacing it: a lint tells the author they have a problem, but only 1 gives them a way to fix it.

    If the intended answer is instead "a row-conditioned stamp does not belong in a hook at all, use an action/flow", that is a legitimate ruling too — but it should be written down, because D3 currently reads as though routes 1 and 2 are available.

    Reproduction

    hotcrm@claude/issue-1265-batch-scoped-payload carries a passing tripwire test asserting the four facts above (test/hooks-runtime-service.test.ts, #1265 — the shipped hook body cannot tell it is on a per-row predicate dispatch). It is written to go red when this card is fixed.

    Related: hotcrm#1265 (the app-side card, blocked on this), #5574 (per-row before* dispatch), #6966 (input.id binding semantics), #3700 (hook body writes not statically checkable), #7661 / #4391 (same declared-but-absent-in-sandbox family).

    Metadata

    Metadata

    Assignees

    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

      [hooks] ADR-0058 Addendum II D3 names three routes out of the batch-scoped payload, and a body-only hook can reach none of them — the sandbox context carries no per-row signal (declared ≠ observable) #11552

      Description

      @os-warren

      Filed unassigned — a record from an app-side investigation (hotcrm#1265), not a claim.

      The gap

      ADR-0058 Addendum II D3 makes the predicate-update payload BATCH-scoped and says so plainly, including that a rewrite conditioned on the row is outside the contract. It then names the three supported routes for row-specific work, quoted from the installed @objectstack/spec 17.1.0 (dist/data/index.d.ts):

      The three supported routes for row-specific work are: throw (which is what the guard case wants), write through ctx.api per row, or have the CALLER paginate the batch into by-id updates.

      The ADR is explicit that D3 is a contract statement and not an enforcement, and gives the reason (no static rule can decide whether a rewrite is row-invariant). That part is a deliberate, reasoned ruling and this card does not argue with it.

      The gap is narrower and, I think, unintended: routes 1 and 2 require the handler to know it is on the per-row predicate path, and a hook shipped body-only cannot know that. The signal exists on the engine context and is dropped at the sandbox boundary.

      Measured on 17.1.0

      buildSandboxContext (@objectstack/runtime) builds the hook body's context with exactly:

      input, previous, user, session, event, object, result, api, log, crypto
      

      No dispatch. And input is unwrapProxyToPlain(engineCtx.input), i.e. Object.fromEntries(Object.entries(v)) — enumerable own keys only. On the per-row path dispatchPerRowBeforeHooks builds input: { id: rowId, data: batchCtx.input.data, options }, and installFlatInput wraps it in a proxy whose ownKeys returns only the payload's data keys and whose getOwnPropertyDescriptor marks id / options / datanon-enumerable. So the unwrap copies the payload and drops id and options with it.

      Probe body run through the real QuickJSScriptRunner + hookBodyRunnerFactory, against an engine context shaped exactly as dispatchPerRowBeforeHooks builds one:

      hasDispatch : "undefined"
      inputKeys : ["title"] <- payload only
      inputId : undefined
      inputOptions : undefined
      previousKeys : ["id","status","published_at"]
      ctxKeys : ["input","previous","user","session","event","object","api","log","crypto"]
      

      So inside a shipped body:

      • ctx.dispatch?.mode — absent, so route 1 (throw) cannot be scoped to the batch path;
      • ctx.input.options.multi / .where — absent, though D2 states these are visible to the before* phase ("input.options is the CALLER's bag (where and multi visible)"). That is true of the engine context and false of the body context;
      • ctx.input.id — absent, so route 2 (ctx.api per row) has no row id from input and, more fundamentally, cannot tell whether it should fire at all (on the single-record path it would double-write);
      • ctx.previouspresent. The one row-conditioned input that does cross is exactly the one D3 says must not aim a rewrite.

      Route 3 (caller paginates) is not available to a handler at all.

      Net: the surface hands the author the ingredient that makes the mistake expressible and withholds every ingredient that would let them comply.

      Why this bites harder than "unenforced"

      The ADR's own reasoning for leaving D3 unenforced is that no static rule can decide row-invariance, and that the hazard therefore belongs in authoring docs plus a possible advisory lint. That reasoning assumes the author who reads the docs can then act on them. Today they cannot, if the hook ships body-only.

      Worse, the natural fix is silently inert. A guard written as:

      if(ctx.dispatch?.mode==='per-row'){/* refuse, or skip the row-conditioned write */}

      lowers cleanly through extractHookBody, passes in-process handler tests (which run the closure with a full engine context), and evaluates to false on every dispatch in production — so the widening continues while the code reads as though it were prevented. This is the hint / ctx.log.debug / crypto.hash family (#7661, #4391): declared on one face, absent on the face that ships.

      Blast radius, measured in the exemplar app

      hotcrm#1265 was filed for one hook. Triaging its beforeUpdate handlers against the mechanical test — does a payload write depend on the row?9 of the 17 previous-reading hook files carry at least one row-conditioned payload write, and none of them can be fixed app-side for the reason above:

      hookshape
      knowledge_article_publish_timestampspublished_at existence criterion
      campaign_member_lifecycleresponse_date existence criterion + wasResponded from previous
      forecast_derive_periodsnapshot_date: !input.x && !previous?.x
      case_sla_defaultssla_due_date gated on !previous?.sla_due_date, value from the row's account tier
      task_completioncompleted_date / progress_percent gated on previous?.status
      opportunity_lifecycleclose_date / stage_entry_date gated on previous.stage
      event_schedule_deriveduration_minutes / end_datetime from previous fallbacks
      lead_duplicate_checkduplicate_of_type gated on previous?.duplicate_status
      account_protectionlast_activity_date gated on input.owner_id !== previous.owner_id

      The clean ones are instructive too: product_catalog and contract_validation read previousonly to throw, which is precisely D3's blessed pattern — and they get away with it only because they refuse unconditionally, never "refuse on the batch path".

      What would close it

      Options, roughly in increasing cost — the choice is a maintainer's, and I have deliberately not assumed one:

      1. Marshal a per-row signal into the sandbox context. Smallest change: add dispatch (or a boolean isPerRow) to buildSandboxContext. Makes route 1 expressible immediately.
      2. Also surface input.options (multi / where), which D2 already declares visible to the before* phase and which the body context contradicts today. This is arguably a spec-vs-engine drift independent of D3.
      3. Advisory lint at the seam the ADR itself names (packages/lint's validate-hook-body-writes) — flag a payload write whose guard or value reads previous / input.id. Complements 1 rather than replacing it: a lint tells the author they have a problem, but only 1 gives them a way to fix it.

      If the intended answer is instead "a row-conditioned stamp does not belong in a hook at all, use an action/flow", that is a legitimate ruling too — but it should be written down, because D3 currently reads as though routes 1 and 2 are available.

      Reproduction

      hotcrm@claude/issue-1265-batch-scoped-payload carries a passing tripwire test asserting the four facts above (test/hooks-runtime-service.test.ts, #1265 — the shipped hook body cannot tell it is on a per-row predicate dispatch). It is written to go red when this card is fixed.

      Related: hotcrm#1265 (the app-side card, blocked on this), #5574 (per-row before* dispatch), #6966 (input.id binding semantics), #3700 (hook body writes not statically checkable), #7661 / #4391 (same declared-but-absent-in-sandbox family).

      Metadata

      Metadata

      Assignees

      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

        [hooks] ADR-0058 Addendum II D3 names three routes out of the batch-scoped payload, and a body-only hook can reach none of them — the sandbox context carries no per-row signal (declared ≠ observable) #11552

        Description

        @os-warren

        Filed unassigned — a record from an app-side investigation (hotcrm#1265), not a claim.

        The gap

        ADR-0058 Addendum II D3 makes the predicate-update payload BATCH-scoped and says so plainly, including that a rewrite conditioned on the row is outside the contract. It then names the three supported routes for row-specific work, quoted from the installed @objectstack/spec 17.1.0 (dist/data/index.d.ts):

        The three supported routes for row-specific work are: throw (which is what the guard case wants), write through ctx.api per row, or have the CALLER paginate the batch into by-id updates.

        The ADR is explicit that D3 is a contract statement and not an enforcement, and gives the reason (no static rule can decide whether a rewrite is row-invariant). That part is a deliberate, reasoned ruling and this card does not argue with it.

        The gap is narrower and, I think, unintended: routes 1 and 2 require the handler to know it is on the per-row predicate path, and a hook shipped body-only cannot know that. The signal exists on the engine context and is dropped at the sandbox boundary.

        Measured on 17.1.0

        buildSandboxContext (@objectstack/runtime) builds the hook body's context with exactly:

        input, previous, user, session, event, object, result, api, log, crypto
        

        No dispatch. And input is unwrapProxyToPlain(engineCtx.input), i.e. Object.fromEntries(Object.entries(v)) — enumerable own keys only. On the per-row path dispatchPerRowBeforeHooks builds input: { id: rowId, data: batchCtx.input.data, options }, and installFlatInput wraps it in a proxy whose ownKeys returns only the payload's data keys and whose getOwnPropertyDescriptor marks id / options / datanon-enumerable. So the unwrap copies the payload and drops id and options with it.

        Probe body run through the real QuickJSScriptRunner + hookBodyRunnerFactory, against an engine context shaped exactly as dispatchPerRowBeforeHooks builds one:

        hasDispatch : "undefined"
        inputKeys : ["title"] <- payload only
        inputId : undefined
        inputOptions : undefined
        previousKeys : ["id","status","published_at"]
        ctxKeys : ["input","previous","user","session","event","object","api","log","crypto"]
        

        So inside a shipped body:

        • ctx.dispatch?.mode — absent, so route 1 (throw) cannot be scoped to the batch path;
        • ctx.input.options.multi / .where — absent, though D2 states these are visible to the before* phase ("input.options is the CALLER's bag (where and multi visible)"). That is true of the engine context and false of the body context;
        • ctx.input.id — absent, so route 2 (ctx.api per row) has no row id from input and, more fundamentally, cannot tell whether it should fire at all (on the single-record path it would double-write);
        • ctx.previouspresent. The one row-conditioned input that does cross is exactly the one D3 says must not aim a rewrite.

        Route 3 (caller paginates) is not available to a handler at all.

        Net: the surface hands the author the ingredient that makes the mistake expressible and withholds every ingredient that would let them comply.

        Why this bites harder than "unenforced"

        The ADR's own reasoning for leaving D3 unenforced is that no static rule can decide row-invariance, and that the hazard therefore belongs in authoring docs plus a possible advisory lint. That reasoning assumes the author who reads the docs can then act on them. Today they cannot, if the hook ships body-only.

        Worse, the natural fix is silently inert. A guard written as:

        if(ctx.dispatch?.mode==='per-row'){/* refuse, or skip the row-conditioned write */}

        lowers cleanly through extractHookBody, passes in-process handler tests (which run the closure with a full engine context), and evaluates to false on every dispatch in production — so the widening continues while the code reads as though it were prevented. This is the hint / ctx.log.debug / crypto.hash family (#7661, #4391): declared on one face, absent on the face that ships.

        Blast radius, measured in the exemplar app

        hotcrm#1265 was filed for one hook. Triaging its beforeUpdate handlers against the mechanical test — does a payload write depend on the row?9 of the 17 previous-reading hook files carry at least one row-conditioned payload write, and none of them can be fixed app-side for the reason above:

        hookshape
        knowledge_article_publish_timestampspublished_at existence criterion
        campaign_member_lifecycleresponse_date existence criterion + wasResponded from previous
        forecast_derive_periodsnapshot_date: !input.x && !previous?.x
        case_sla_defaultssla_due_date gated on !previous?.sla_due_date, value from the row's account tier
        task_completioncompleted_date / progress_percent gated on previous?.status
        opportunity_lifecycleclose_date / stage_entry_date gated on previous.stage
        event_schedule_deriveduration_minutes / end_datetime from previous fallbacks
        lead_duplicate_checkduplicate_of_type gated on previous?.duplicate_status
        account_protectionlast_activity_date gated on input.owner_id !== previous.owner_id

        The clean ones are instructive too: product_catalog and contract_validation read previousonly to throw, which is precisely D3's blessed pattern — and they get away with it only because they refuse unconditionally, never "refuse on the batch path".

        What would close it

        Options, roughly in increasing cost — the choice is a maintainer's, and I have deliberately not assumed one:

        1. Marshal a per-row signal into the sandbox context. Smallest change: add dispatch (or a boolean isPerRow) to buildSandboxContext. Makes route 1 expressible immediately.
        2. Also surface input.options (multi / where), which D2 already declares visible to the before* phase and which the body context contradicts today. This is arguably a spec-vs-engine drift independent of D3.
        3. Advisory lint at the seam the ADR itself names (packages/lint's validate-hook-body-writes) — flag a payload write whose guard or value reads previous / input.id. Complements 1 rather than replacing it: a lint tells the author they have a problem, but only 1 gives them a way to fix it.

        If the intended answer is instead "a row-conditioned stamp does not belong in a hook at all, use an action/flow", that is a legitimate ruling too — but it should be written down, because D3 currently reads as though routes 1 and 2 are available.

        Reproduction

        hotcrm@claude/issue-1265-batch-scoped-payload carries a passing tripwire test asserting the four facts above (test/hooks-runtime-service.test.ts, #1265 — the shipped hook body cannot tell it is on a per-row predicate dispatch). It is written to go red when this card is fixed.

        Related: hotcrm#1265 (the app-side card, blocked on this), #5574 (per-row before* dispatch), #6966 (input.id binding semantics), #3700 (hook body writes not statically checkable), #7661 / #4391 (same declared-but-absent-in-sandbox family).

        Metadata

        Metadata

        Assignees

        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

          [hooks] ADR-0058 Addendum II D3 names three routes out of the batch-scoped payload, and a body-only hook can reach none of them — the sandbox context carries no per-row signal (declared ≠ observable) #11552

          Description

          @os-warren

          Filed unassigned — a record from an app-side investigation (hotcrm#1265), not a claim.

          The gap

          ADR-0058 Addendum II D3 makes the predicate-update payload BATCH-scoped and says so plainly, including that a rewrite conditioned on the row is outside the contract. It then names the three supported routes for row-specific work, quoted from the installed @objectstack/spec 17.1.0 (dist/data/index.d.ts):

          The three supported routes for row-specific work are: throw (which is what the guard case wants), write through ctx.api per row, or have the CALLER paginate the batch into by-id updates.

          The ADR is explicit that D3 is a contract statement and not an enforcement, and gives the reason (no static rule can decide whether a rewrite is row-invariant). That part is a deliberate, reasoned ruling and this card does not argue with it.

          The gap is narrower and, I think, unintended: routes 1 and 2 require the handler to know it is on the per-row predicate path, and a hook shipped body-only cannot know that. The signal exists on the engine context and is dropped at the sandbox boundary.

          Measured on 17.1.0

          buildSandboxContext (@objectstack/runtime) builds the hook body's context with exactly:

          input, previous, user, session, event, object, result, api, log, crypto
          

          No dispatch. And input is unwrapProxyToPlain(engineCtx.input), i.e. Object.fromEntries(Object.entries(v)) — enumerable own keys only. On the per-row path dispatchPerRowBeforeHooks builds input: { id: rowId, data: batchCtx.input.data, options }, and installFlatInput wraps it in a proxy whose ownKeys returns only the payload's data keys and whose getOwnPropertyDescriptor marks id / options / datanon-enumerable. So the unwrap copies the payload and drops id and options with it.

          Probe body run through the real QuickJSScriptRunner + hookBodyRunnerFactory, against an engine context shaped exactly as dispatchPerRowBeforeHooks builds one:

          hasDispatch : "undefined"
          inputKeys : ["title"] <- payload only
          inputId : undefined
          inputOptions : undefined
          previousKeys : ["id","status","published_at"]
          ctxKeys : ["input","previous","user","session","event","object","api","log","crypto"]
          

          So inside a shipped body:

          • ctx.dispatch?.mode — absent, so route 1 (throw) cannot be scoped to the batch path;
          • ctx.input.options.multi / .where — absent, though D2 states these are visible to the before* phase ("input.options is the CALLER's bag (where and multi visible)"). That is true of the engine context and false of the body context;
          • ctx.input.id — absent, so route 2 (ctx.api per row) has no row id from input and, more fundamentally, cannot tell whether it should fire at all (on the single-record path it would double-write);
          • ctx.previouspresent. The one row-conditioned input that does cross is exactly the one D3 says must not aim a rewrite.

          Route 3 (caller paginates) is not available to a handler at all.

          Net: the surface hands the author the ingredient that makes the mistake expressible and withholds every ingredient that would let them comply.

          Why this bites harder than "unenforced"

          The ADR's own reasoning for leaving D3 unenforced is that no static rule can decide row-invariance, and that the hazard therefore belongs in authoring docs plus a possible advisory lint. That reasoning assumes the author who reads the docs can then act on them. Today they cannot, if the hook ships body-only.

          Worse, the natural fix is silently inert. A guard written as:

          if(ctx.dispatch?.mode==='per-row'){/* refuse, or skip the row-conditioned write */}

          lowers cleanly through extractHookBody, passes in-process handler tests (which run the closure with a full engine context), and evaluates to false on every dispatch in production — so the widening continues while the code reads as though it were prevented. This is the hint / ctx.log.debug / crypto.hash family (#7661, #4391): declared on one face, absent on the face that ships.

          Blast radius, measured in the exemplar app

          hotcrm#1265 was filed for one hook. Triaging its beforeUpdate handlers against the mechanical test — does a payload write depend on the row?9 of the 17 previous-reading hook files carry at least one row-conditioned payload write, and none of them can be fixed app-side for the reason above:

          hookshape
          knowledge_article_publish_timestampspublished_at existence criterion
          campaign_member_lifecycleresponse_date existence criterion + wasResponded from previous
          forecast_derive_periodsnapshot_date: !input.x && !previous?.x
          case_sla_defaultssla_due_date gated on !previous?.sla_due_date, value from the row's account tier
          task_completioncompleted_date / progress_percent gated on previous?.status
          opportunity_lifecycleclose_date / stage_entry_date gated on previous.stage
          event_schedule_deriveduration_minutes / end_datetime from previous fallbacks
          lead_duplicate_checkduplicate_of_type gated on previous?.duplicate_status
          account_protectionlast_activity_date gated on input.owner_id !== previous.owner_id

          The clean ones are instructive too: product_catalog and contract_validation read previousonly to throw, which is precisely D3's blessed pattern — and they get away with it only because they refuse unconditionally, never "refuse on the batch path".

          What would close it

          Options, roughly in increasing cost — the choice is a maintainer's, and I have deliberately not assumed one:

          1. Marshal a per-row signal into the sandbox context. Smallest change: add dispatch (or a boolean isPerRow) to buildSandboxContext. Makes route 1 expressible immediately.
          2. Also surface input.options (multi / where), which D2 already declares visible to the before* phase and which the body context contradicts today. This is arguably a spec-vs-engine drift independent of D3.
          3. Advisory lint at the seam the ADR itself names (packages/lint's validate-hook-body-writes) — flag a payload write whose guard or value reads previous / input.id. Complements 1 rather than replacing it: a lint tells the author they have a problem, but only 1 gives them a way to fix it.

          If the intended answer is instead "a row-conditioned stamp does not belong in a hook at all, use an action/flow", that is a legitimate ruling too — but it should be written down, because D3 currently reads as though routes 1 and 2 are available.

          Reproduction

          hotcrm@claude/issue-1265-batch-scoped-payload carries a passing tripwire test asserting the four facts above (test/hooks-runtime-service.test.ts, #1265 — the shipped hook body cannot tell it is on a per-row predicate dispatch). It is written to go red when this card is fixed.

          Related: hotcrm#1265 (the app-side card, blocked on this), #5574 (per-row before* dispatch), #6966 (input.id binding semantics), #3700 (hook body writes not statically checkable), #7661 / #4391 (same declared-but-absent-in-sandbox family).

          Metadata

          Metadata

          Assignees

          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

            [hooks] ADR-0058 Addendum II D3 names three routes out of the batch-scoped payload, and a body-only hook can reach none of them — the sandbox context carries no per-row signal (declared ≠ observable) #11552

            Description

            @os-warren

            Filed unassigned — a record from an app-side investigation (hotcrm#1265), not a claim.

            The gap

            ADR-0058 Addendum II D3 makes the predicate-update payload BATCH-scoped and says so plainly, including that a rewrite conditioned on the row is outside the contract. It then names the three supported routes for row-specific work, quoted from the installed @objectstack/spec 17.1.0 (dist/data/index.d.ts):

            The three supported routes for row-specific work are: throw (which is what the guard case wants), write through ctx.api per row, or have the CALLER paginate the batch into by-id updates.

            The ADR is explicit that D3 is a contract statement and not an enforcement, and gives the reason (no static rule can decide whether a rewrite is row-invariant). That part is a deliberate, reasoned ruling and this card does not argue with it.

            The gap is narrower and, I think, unintended: routes 1 and 2 require the handler to know it is on the per-row predicate path, and a hook shipped body-only cannot know that. The signal exists on the engine context and is dropped at the sandbox boundary.

            Measured on 17.1.0

            buildSandboxContext (@objectstack/runtime) builds the hook body's context with exactly:

            input, previous, user, session, event, object, result, api, log, crypto
            

            No dispatch. And input is unwrapProxyToPlain(engineCtx.input), i.e. Object.fromEntries(Object.entries(v)) — enumerable own keys only. On the per-row path dispatchPerRowBeforeHooks builds input: { id: rowId, data: batchCtx.input.data, options }, and installFlatInput wraps it in a proxy whose ownKeys returns only the payload's data keys and whose getOwnPropertyDescriptor marks id / options / datanon-enumerable. So the unwrap copies the payload and drops id and options with it.

            Probe body run through the real QuickJSScriptRunner + hookBodyRunnerFactory, against an engine context shaped exactly as dispatchPerRowBeforeHooks builds one:

            hasDispatch : "undefined"
            inputKeys : ["title"] <- payload only
            inputId : undefined
            inputOptions : undefined
            previousKeys : ["id","status","published_at"]
            ctxKeys : ["input","previous","user","session","event","object","api","log","crypto"]
            

            So inside a shipped body:

            • ctx.dispatch?.mode — absent, so route 1 (throw) cannot be scoped to the batch path;
            • ctx.input.options.multi / .where — absent, though D2 states these are visible to the before* phase ("input.options is the CALLER's bag (where and multi visible)"). That is true of the engine context and false of the body context;
            • ctx.input.id — absent, so route 2 (ctx.api per row) has no row id from input and, more fundamentally, cannot tell whether it should fire at all (on the single-record path it would double-write);
            • ctx.previouspresent. The one row-conditioned input that does cross is exactly the one D3 says must not aim a rewrite.

            Route 3 (caller paginates) is not available to a handler at all.

            Net: the surface hands the author the ingredient that makes the mistake expressible and withholds every ingredient that would let them comply.

            Why this bites harder than "unenforced"

            The ADR's own reasoning for leaving D3 unenforced is that no static rule can decide row-invariance, and that the hazard therefore belongs in authoring docs plus a possible advisory lint. That reasoning assumes the author who reads the docs can then act on them. Today they cannot, if the hook ships body-only.

            Worse, the natural fix is silently inert. A guard written as:

            if(ctx.dispatch?.mode==='per-row'){/* refuse, or skip the row-conditioned write */}

            lowers cleanly through extractHookBody, passes in-process handler tests (which run the closure with a full engine context), and evaluates to false on every dispatch in production — so the widening continues while the code reads as though it were prevented. This is the hint / ctx.log.debug / crypto.hash family (#7661, #4391): declared on one face, absent on the face that ships.

            Blast radius, measured in the exemplar app

            hotcrm#1265 was filed for one hook. Triaging its beforeUpdate handlers against the mechanical test — does a payload write depend on the row?9 of the 17 previous-reading hook files carry at least one row-conditioned payload write, and none of them can be fixed app-side for the reason above:

            hookshape
            knowledge_article_publish_timestampspublished_at existence criterion
            campaign_member_lifecycleresponse_date existence criterion + wasResponded from previous
            forecast_derive_periodsnapshot_date: !input.x && !previous?.x
            case_sla_defaultssla_due_date gated on !previous?.sla_due_date, value from the row's account tier
            task_completioncompleted_date / progress_percent gated on previous?.status
            opportunity_lifecycleclose_date / stage_entry_date gated on previous.stage
            event_schedule_deriveduration_minutes / end_datetime from previous fallbacks
            lead_duplicate_checkduplicate_of_type gated on previous?.duplicate_status
            account_protectionlast_activity_date gated on input.owner_id !== previous.owner_id

            The clean ones are instructive too: product_catalog and contract_validation read previousonly to throw, which is precisely D3's blessed pattern — and they get away with it only because they refuse unconditionally, never "refuse on the batch path".

            What would close it

            Options, roughly in increasing cost — the choice is a maintainer's, and I have deliberately not assumed one:

            1. Marshal a per-row signal into the sandbox context. Smallest change: add dispatch (or a boolean isPerRow) to buildSandboxContext. Makes route 1 expressible immediately.
            2. Also surface input.options (multi / where), which D2 already declares visible to the before* phase and which the body context contradicts today. This is arguably a spec-vs-engine drift independent of D3.
            3. Advisory lint at the seam the ADR itself names (packages/lint's validate-hook-body-writes) — flag a payload write whose guard or value reads previous / input.id. Complements 1 rather than replacing it: a lint tells the author they have a problem, but only 1 gives them a way to fix it.

            If the intended answer is instead "a row-conditioned stamp does not belong in a hook at all, use an action/flow", that is a legitimate ruling too — but it should be written down, because D3 currently reads as though routes 1 and 2 are available.

            Reproduction

            hotcrm@claude/issue-1265-batch-scoped-payload carries a passing tripwire test asserting the four facts above (test/hooks-runtime-service.test.ts, #1265 — the shipped hook body cannot tell it is on a per-row predicate dispatch). It is written to go red when this card is fixed.

            Related: hotcrm#1265 (the app-side card, blocked on this), #5574 (per-row before* dispatch), #6966 (input.id binding semantics), #3700 (hook body writes not statically checkable), #7661 / #4391 (same declared-but-absent-in-sandbox family).

            Metadata

            Metadata

            Assignees

            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

              [hooks] ADR-0058 Addendum II D3 names three routes out of the batch-scoped payload, and a body-only hook can reach none of them — the sandbox context carries no per-row signal (declared ≠ observable) #11552

              Description

              @os-warren

              Filed unassigned — a record from an app-side investigation (hotcrm#1265), not a claim.

              The gap

              ADR-0058 Addendum II D3 makes the predicate-update payload BATCH-scoped and says so plainly, including that a rewrite conditioned on the row is outside the contract. It then names the three supported routes for row-specific work, quoted from the installed @objectstack/spec 17.1.0 (dist/data/index.d.ts):

              The three supported routes for row-specific work are: throw (which is what the guard case wants), write through ctx.api per row, or have the CALLER paginate the batch into by-id updates.

              The ADR is explicit that D3 is a contract statement and not an enforcement, and gives the reason (no static rule can decide whether a rewrite is row-invariant). That part is a deliberate, reasoned ruling and this card does not argue with it.

              The gap is narrower and, I think, unintended: routes 1 and 2 require the handler to know it is on the per-row predicate path, and a hook shipped body-only cannot know that. The signal exists on the engine context and is dropped at the sandbox boundary.

              Measured on 17.1.0

              buildSandboxContext (@objectstack/runtime) builds the hook body's context with exactly:

              input, previous, user, session, event, object, result, api, log, crypto
              

              No dispatch. And input is unwrapProxyToPlain(engineCtx.input), i.e. Object.fromEntries(Object.entries(v)) — enumerable own keys only. On the per-row path dispatchPerRowBeforeHooks builds input: { id: rowId, data: batchCtx.input.data, options }, and installFlatInput wraps it in a proxy whose ownKeys returns only the payload's data keys and whose getOwnPropertyDescriptor marks id / options / datanon-enumerable. So the unwrap copies the payload and drops id and options with it.

              Probe body run through the real QuickJSScriptRunner + hookBodyRunnerFactory, against an engine context shaped exactly as dispatchPerRowBeforeHooks builds one:

              hasDispatch : "undefined"
              inputKeys : ["title"] <- payload only
              inputId : undefined
              inputOptions : undefined
              previousKeys : ["id","status","published_at"]
              ctxKeys : ["input","previous","user","session","event","object","api","log","crypto"]
              

              So inside a shipped body:

              • ctx.dispatch?.mode — absent, so route 1 (throw) cannot be scoped to the batch path;
              • ctx.input.options.multi / .where — absent, though D2 states these are visible to the before* phase ("input.options is the CALLER's bag (where and multi visible)"). That is true of the engine context and false of the body context;
              • ctx.input.id — absent, so route 2 (ctx.api per row) has no row id from input and, more fundamentally, cannot tell whether it should fire at all (on the single-record path it would double-write);
              • ctx.previouspresent. The one row-conditioned input that does cross is exactly the one D3 says must not aim a rewrite.

              Route 3 (caller paginates) is not available to a handler at all.

              Net: the surface hands the author the ingredient that makes the mistake expressible and withholds every ingredient that would let them comply.

              Why this bites harder than "unenforced"

              The ADR's own reasoning for leaving D3 unenforced is that no static rule can decide row-invariance, and that the hazard therefore belongs in authoring docs plus a possible advisory lint. That reasoning assumes the author who reads the docs can then act on them. Today they cannot, if the hook ships body-only.

              Worse, the natural fix is silently inert. A guard written as:

              if(ctx.dispatch?.mode==='per-row'){/* refuse, or skip the row-conditioned write */}

              lowers cleanly through extractHookBody, passes in-process handler tests (which run the closure with a full engine context), and evaluates to false on every dispatch in production — so the widening continues while the code reads as though it were prevented. This is the hint / ctx.log.debug / crypto.hash family (#7661, #4391): declared on one face, absent on the face that ships.

              Blast radius, measured in the exemplar app

              hotcrm#1265 was filed for one hook. Triaging its beforeUpdate handlers against the mechanical test — does a payload write depend on the row?9 of the 17 previous-reading hook files carry at least one row-conditioned payload write, and none of them can be fixed app-side for the reason above:

              hookshape
              knowledge_article_publish_timestampspublished_at existence criterion
              campaign_member_lifecycleresponse_date existence criterion + wasResponded from previous
              forecast_derive_periodsnapshot_date: !input.x && !previous?.x
              case_sla_defaultssla_due_date gated on !previous?.sla_due_date, value from the row's account tier
              task_completioncompleted_date / progress_percent gated on previous?.status
              opportunity_lifecycleclose_date / stage_entry_date gated on previous.stage
              event_schedule_deriveduration_minutes / end_datetime from previous fallbacks
              lead_duplicate_checkduplicate_of_type gated on previous?.duplicate_status
              account_protectionlast_activity_date gated on input.owner_id !== previous.owner_id

              The clean ones are instructive too: product_catalog and contract_validation read previousonly to throw, which is precisely D3's blessed pattern — and they get away with it only because they refuse unconditionally, never "refuse on the batch path".

              What would close it

              Options, roughly in increasing cost — the choice is a maintainer's, and I have deliberately not assumed one:

              1. Marshal a per-row signal into the sandbox context. Smallest change: add dispatch (or a boolean isPerRow) to buildSandboxContext. Makes route 1 expressible immediately.
              2. Also surface input.options (multi / where), which D2 already declares visible to the before* phase and which the body context contradicts today. This is arguably a spec-vs-engine drift independent of D3.
              3. Advisory lint at the seam the ADR itself names (packages/lint's validate-hook-body-writes) — flag a payload write whose guard or value reads previous / input.id. Complements 1 rather than replacing it: a lint tells the author they have a problem, but only 1 gives them a way to fix it.

              If the intended answer is instead "a row-conditioned stamp does not belong in a hook at all, use an action/flow", that is a legitimate ruling too — but it should be written down, because D3 currently reads as though routes 1 and 2 are available.

              Reproduction

              hotcrm@claude/issue-1265-batch-scoped-payload carries a passing tripwire test asserting the four facts above (test/hooks-runtime-service.test.ts, #1265 — the shipped hook body cannot tell it is on a per-row predicate dispatch). It is written to go red when this card is fixed.

              Related: hotcrm#1265 (the app-side card, blocked on this), #5574 (per-row before* dispatch), #6966 (input.id binding semantics), #3700 (hook body writes not statically checkable), #7661 / #4391 (same declared-but-absent-in-sandbox family).

              Metadata

              Metadata

              Assignees

              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

                [hooks] ADR-0058 Addendum II D3 names three routes out of the batch-scoped payload, and a body-only hook can reach none of them — the sandbox context carries no per-row signal (declared ≠ observable) #11552

                Description

                @os-warren

                Filed unassigned — a record from an app-side investigation (hotcrm#1265), not a claim.

                The gap

                ADR-0058 Addendum II D3 makes the predicate-update payload BATCH-scoped and says so plainly, including that a rewrite conditioned on the row is outside the contract. It then names the three supported routes for row-specific work, quoted from the installed @objectstack/spec 17.1.0 (dist/data/index.d.ts):

                The three supported routes for row-specific work are: throw (which is what the guard case wants), write through ctx.api per row, or have the CALLER paginate the batch into by-id updates.

                The ADR is explicit that D3 is a contract statement and not an enforcement, and gives the reason (no static rule can decide whether a rewrite is row-invariant). That part is a deliberate, reasoned ruling and this card does not argue with it.

                The gap is narrower and, I think, unintended: routes 1 and 2 require the handler to know it is on the per-row predicate path, and a hook shipped body-only cannot know that. The signal exists on the engine context and is dropped at the sandbox boundary.

                Measured on 17.1.0

                buildSandboxContext (@objectstack/runtime) builds the hook body's context with exactly:

                input, previous, user, session, event, object, result, api, log, crypto
                

                No dispatch. And input is unwrapProxyToPlain(engineCtx.input), i.e. Object.fromEntries(Object.entries(v)) — enumerable own keys only. On the per-row path dispatchPerRowBeforeHooks builds input: { id: rowId, data: batchCtx.input.data, options }, and installFlatInput wraps it in a proxy whose ownKeys returns only the payload's data keys and whose getOwnPropertyDescriptor marks id / options / datanon-enumerable. So the unwrap copies the payload and drops id and options with it.

                Probe body run through the real QuickJSScriptRunner + hookBodyRunnerFactory, against an engine context shaped exactly as dispatchPerRowBeforeHooks builds one:

                hasDispatch : "undefined"
                inputKeys : ["title"] <- payload only
                inputId : undefined
                inputOptions : undefined
                previousKeys : ["id","status","published_at"]
                ctxKeys : ["input","previous","user","session","event","object","api","log","crypto"]
                

                So inside a shipped body:

                • ctx.dispatch?.mode — absent, so route 1 (throw) cannot be scoped to the batch path;
                • ctx.input.options.multi / .where — absent, though D2 states these are visible to the before* phase ("input.options is the CALLER's bag (where and multi visible)"). That is true of the engine context and false of the body context;
                • ctx.input.id — absent, so route 2 (ctx.api per row) has no row id from input and, more fundamentally, cannot tell whether it should fire at all (on the single-record path it would double-write);
                • ctx.previouspresent. The one row-conditioned input that does cross is exactly the one D3 says must not aim a rewrite.

                Route 3 (caller paginates) is not available to a handler at all.

                Net: the surface hands the author the ingredient that makes the mistake expressible and withholds every ingredient that would let them comply.

                Why this bites harder than "unenforced"

                The ADR's own reasoning for leaving D3 unenforced is that no static rule can decide row-invariance, and that the hazard therefore belongs in authoring docs plus a possible advisory lint. That reasoning assumes the author who reads the docs can then act on them. Today they cannot, if the hook ships body-only.

                Worse, the natural fix is silently inert. A guard written as:

                if(ctx.dispatch?.mode==='per-row'){/* refuse, or skip the row-conditioned write */}

                lowers cleanly through extractHookBody, passes in-process handler tests (which run the closure with a full engine context), and evaluates to false on every dispatch in production — so the widening continues while the code reads as though it were prevented. This is the hint / ctx.log.debug / crypto.hash family (#7661, #4391): declared on one face, absent on the face that ships.

                Blast radius, measured in the exemplar app

                hotcrm#1265 was filed for one hook. Triaging its beforeUpdate handlers against the mechanical test — does a payload write depend on the row?9 of the 17 previous-reading hook files carry at least one row-conditioned payload write, and none of them can be fixed app-side for the reason above:

                hookshape
                knowledge_article_publish_timestampspublished_at existence criterion
                campaign_member_lifecycleresponse_date existence criterion + wasResponded from previous
                forecast_derive_periodsnapshot_date: !input.x && !previous?.x
                case_sla_defaultssla_due_date gated on !previous?.sla_due_date, value from the row's account tier
                task_completioncompleted_date / progress_percent gated on previous?.status
                opportunity_lifecycleclose_date / stage_entry_date gated on previous.stage
                event_schedule_deriveduration_minutes / end_datetime from previous fallbacks
                lead_duplicate_checkduplicate_of_type gated on previous?.duplicate_status
                account_protectionlast_activity_date gated on input.owner_id !== previous.owner_id

                The clean ones are instructive too: product_catalog and contract_validation read previousonly to throw, which is precisely D3's blessed pattern — and they get away with it only because they refuse unconditionally, never "refuse on the batch path".

                What would close it

                Options, roughly in increasing cost — the choice is a maintainer's, and I have deliberately not assumed one:

                1. Marshal a per-row signal into the sandbox context. Smallest change: add dispatch (or a boolean isPerRow) to buildSandboxContext. Makes route 1 expressible immediately.
                2. Also surface input.options (multi / where), which D2 already declares visible to the before* phase and which the body context contradicts today. This is arguably a spec-vs-engine drift independent of D3.
                3. Advisory lint at the seam the ADR itself names (packages/lint's validate-hook-body-writes) — flag a payload write whose guard or value reads previous / input.id. Complements 1 rather than replacing it: a lint tells the author they have a problem, but only 1 gives them a way to fix it.

                If the intended answer is instead "a row-conditioned stamp does not belong in a hook at all, use an action/flow", that is a legitimate ruling too — but it should be written down, because D3 currently reads as though routes 1 and 2 are available.

                Reproduction

                hotcrm@claude/issue-1265-batch-scoped-payload carries a passing tripwire test asserting the four facts above (test/hooks-runtime-service.test.ts, #1265 — the shipped hook body cannot tell it is on a per-row predicate dispatch). It is written to go red when this card is fixed.

                Related: hotcrm#1265 (the app-side card, blocked on this), #5574 (per-row before* dispatch), #6966 (input.id binding semantics), #3700 (hook body writes not statically checkable), #7661 / #4391 (same declared-but-absent-in-sandbox family).

                Metadata

                Metadata

                Assignees

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions