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.previous — present. 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:
| hook | shape |
|---|
knowledge_article_publish_timestamps | published_at existence criterion |
campaign_member_lifecycle | response_date existence criterion + wasResponded from previous |
forecast_derive_period | snapshot_date: !input.x && !previous?.x |
case_sla_defaults | sla_due_date gated on !previous?.sla_due_date, value from the row's account tier |
task_completion | completed_date / progress_percent gated on previous?.status |
opportunity_lifecycle | close_date / stage_entry_date gated on previous.stage |
event_schedule_derive | duration_minutes / end_datetime from previous fallbacks |
lead_duplicate_check | duplicate_of_type gated on previous?.duplicate_status |
account_protection | last_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:
- Marshal a per-row signal into the sandbox context. Smallest change: add
dispatch (or a boolean isPerRow) to buildSandboxContext. Makes route 1 expressible immediately. - 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. - 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).
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/spec17.1.0 (dist/data/index.d.ts):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:No
dispatch. AndinputisunwrapProxyToPlain(engineCtx.input), i.e.Object.fromEntries(Object.entries(v))— enumerable own keys only. On the per-row pathdispatchPerRowBeforeHooksbuildsinput: { id: rowId, data: batchCtx.input.data, options }, andinstallFlatInputwraps it in a proxy whoseownKeysreturns only the payload's data keys and whosegetOwnPropertyDescriptormarksid/options/datanon-enumerable. So the unwrap copies the payload and dropsidandoptionswith it.Probe body run through the real
QuickJSScriptRunner+hookBodyRunnerFactory, against an engine context shaped exactly asdispatchPerRowBeforeHooksbuilds one: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 thebefore*phase ("input.optionsis the CALLER's bag (whereandmultivisible)"). That is true of the engine context and false of the body context;ctx.input.id— absent, so route 2 (ctx.apiper row) has no row id frominputand, more fundamentally, cannot tell whether it should fire at all (on the single-record path it would double-write);ctx.previous— present. 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:
lowers cleanly through
extractHookBody, passes in-process handler tests (which run the closure with a full engine context), and evaluates tofalseon every dispatch in production — so the widening continues while the code reads as though it were prevented. This is thehint/ctx.log.debug/crypto.hashfamily (#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
beforeUpdatehandlers against the mechanical test — does a payload write depend on the row? — 9 of the 17previous-reading hook files carry at least one row-conditioned payload write, and none of them can be fixed app-side for the reason above:knowledge_article_publish_timestampspublished_atexistence criterioncampaign_member_lifecycleresponse_dateexistence criterion +wasRespondedfrompreviousforecast_derive_periodsnapshot_date:!input.x && !previous?.xcase_sla_defaultssla_due_dategated on!previous?.sla_due_date, value from the row's account tiertask_completioncompleted_date/progress_percentgated onprevious?.statusopportunity_lifecycleclose_date/stage_entry_dategated onprevious.stageevent_schedule_deriveduration_minutes/end_datetimefrompreviousfallbackslead_duplicate_checkduplicate_of_typegated onprevious?.duplicate_statusaccount_protectionlast_activity_dategated oninput.owner_id !== previous.owner_idThe clean ones are instructive too:
product_catalogandcontract_validationreadpreviousonly 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:
dispatch(or a booleanisPerRow) tobuildSandboxContext. Makes route 1 expressible immediately.input.options(multi/where), which D2 already declares visible to thebefore*phase and which the body context contradicts today. This is arguably a spec-vs-engine drift independent of D3.packages/lint'svalidate-hook-body-writes) — flag a payload write whose guard or value readsprevious/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-payloadcarries 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.idbinding semantics), #3700 (hook body writes not statically checkable), #7661 / #4391 (same declared-but-absent-in-sandbox family).