Blocked-by: objectstack-ai/objectstack#11552
Found while correcting the rc.2-era bulk-hook comments (#1246). Filed unassigned — this is a record, not a claim. Not fixed in #1246's PR, which is comment text only.
⚠️ Edited by the PM seat 2026-08-24 after the investigation dispatched from this card returned. Two changes, both marked in place: the last_reviewed_at exemption below was measured false and is struck; the class section now carries the completed triage. The original body is in this issue's edit history. See the review comment for the ruling.
What is wrong
#779 closed the old bulk-path hazard: ctx.previous was always absent, so hooks reading it spun uselessly. It is genuinely fixed — on the pinned @objectstack/* 17.1.0 a predicate write dispatches once per matched row with input.id bound and previous bound to that row's pre-image.
That fix hands the same population a different hazard, and it is the harder one to see because the hook now appears to work.
Per ADR-0058 Addendum II D3, quoted from the installed @objectstack/spec (dist/data/index.d.ts):
D3 — the payload is BATCH-scoped, and that is the merge rule. There is exactly ONE payload for a predicate update — driver.updateMany takes one SET clause for N rows — and every per-row beforeUpdate context carries THAT payload, not a per-row copy. […] a rewrite CONDITIONED on the row (ctx.previous, ctx.input.id) is therefore outside this contract: it does not scope itself to the row it was decided on, it widens to every matched row. Per-row previous is supplied so a guard can REFUSE the write, not so a rewrite can be aimed at one row.
knowledge_article_publish_timestamps (src/objects/knowledge_article.hook.ts) makes exactly such a rewrite. Its first-publish criterion is conditioned on the row:
const existing = input.published_at ?? previous?.published_at;
if (existing === undefined || existing === null || existing === '') {
input.published_at = nowIso;
}
On a predicate update, whichever matched row takes that branch sets published_at for the whole batch.
Measured, on the pinned 17.1.0
Real ObjectQL + InMemoryDriver, three published rows, one predicate update { where: { status: 'published' }, multi: true }. Each dispatch stamped a distinct marker date so the stored value names the row whose dispatch computed it.
dispatch id=a1 previous.published_at=2024-01-01T00:00:00.000Z data.published_at ON ENTRY=ABSENT
dispatch id=a2 previous.published_at=2024-06-01T00:00:00.000Z data.published_at ON ENTRY=ABSENT
dispatch id=a3 previous.published_at=ABSENT data.published_at ON ENTRY=ABSENT
payload object count : 3
ALL dispatches share ONE payload object (===): true
--- stored rows ---
{"id":"a1","published_at":"2099-03-03T00:00:00.000Z"}
{"id":"a2","published_at":"2099-03-03T00:00:00.000Z"}
{"id":"a3","published_at":"2099-03-03T00:00:00.000Z"}
a1 and a2 each carried their own historical publish date and each correctly declined to stamp. They were overwritten anyway, with a3's value — the only row that took the branch. A second run with the real clock shows the same shape: two 2024 dates replaced by today's.
Trigger condition: at least one matched row lacks published_at. If every matched row already has one, no dispatch takes the branch and nothing widens — which is why this is easy to miss in a spot check.
Blast radius: all_articles sorts published_at desc, so a single mass edit can reorder the article list and destroy imported publication history — the same class of damage as #780, reached by a different route.
What is NOT affected
- Bulk LOAD (batch insert) is fine. Measured: 4 rows, 4 dispatches, each row keeps its own payload — historical
published_at preserved per row, drafts untouched. A batch insert is N records, not one SET clause, so D3 does not apply.
last_reviewed_at is fine. It is stamped unconditionally, so it is row-invariant and D5-equivalent to the by-id path. ⚠️ STRUCK — this was wrong, and measured wrong. last_reviewed_at is unconditional only after an early return that reads the row, so it is row-invariant only when every matched row is published — true of where: { status: 'published' }, false in general. Measured on a mixed batch (where: { category } over one published and two draft articles): both drafts were stamped last_reviewed_at by the published row's dispatch. Same widening, quieter key.
- Single-record updates are fine and unchanged.
The class — triaged, 9 of 17
17 files under src/objects/*.hook.ts read previous. All 17 were classified against the mechanical test, refined by the last_reviewed_at measurement above to count a row-reading early return as row-conditioning: does any payload write depend on the row, including through an early return that reads it?
9 carry at least one row-conditioned payload write. None can be fixed app-side (see the blocker below):
| hook |
shape |
knowledge_article_publish_timestamps |
published_at existence criterion — this card |
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 / duplicate_status gated on previous?.duplicate_status |
account_protection |
last_activity_date gated on input.owner_id !== previous.owner_id |
campaign_member_lifecycle and forecast_derive_period carry the identical existence-criterion shape and are the closest twins.
Clean: contact_integrity, product_catalog, quote_workflow / quote_on_accepted, contract_validation, campaign_validation, article_feedback_metrics_refresh, opportunity_amount_rollup, quote_total_rollup, case_resolution_article_normalize, lead_automation, lead_auto_assign — payload-only writes, beforeInsert-only writes, after* events, or previous read solely to throw. product_catalog and contract_validation are instructive: reading previous only to refuse is exactly D3's blessed pattern, and they get away with it because they refuse unconditionally, never "refuse on the batch path".
⛔ Why this cannot be fixed here — the blocker
Blocked-by: objectstack-ai/objectstack#11552.
The platform behaves as declared: D3 states the batch-scoping and states that a row-conditioned rewrite is outside the contract, and the ADR is explicit that D3 is a contract statement rather than an enforcement, for a stated reason. That is a deliberate ruling and this card does not argue with it.
The gap is narrower: D3's three routes for row-specific work — throw, ctx.api per row, caller paginates — all require the handler to know it is on the per-row predicate path, and a body-only hook cannot know that. Measured on 17.1.0: buildSandboxContext marshals input, previous, user, session, event, object, result, api, log, crypto and nothing else; ctx.dispatch is absent, and input.id / input.options are dropped because the flattening proxy marks them non-enumerable while the unwrap copies enumerable own keys only.
⚠️ The natural fix is silently inert. A ctx.dispatch?.mode === 'per-row' guard lowers cleanly through extractHookBody, passes every in-process test in this repo, and evaluates false on every production dispatch — so the widening continues while the code reads as though it were prevented. That trap is now named in the hook's own comment (PR #1274) so the next author does not build it.
PR #1274 ships what is correct today: the comment, and a tripwire test that goes red when the platform hands bodies a per-row signal.
⚠️ When #11552 closes, that is not automatically an unlock. This repo consumes @objectstack/* from npm, so the unlock predicate is a read of the published artefact — see #1206 for the round where that distinction mattered.
Suggested next step, once unblocked
A test in the shape of test/hook-query-predicate.test.ts could hold the whole class by driving a real predicate update over a mixed batch and asserting no payload key is set on a row whose own previous said it should not be. Note the existing hook tests cannot: they hand hook.handler a fresh input per call — per-row payload copies, the shape D3 says the engine does not build — so they stay green before and after any fix.
Related: #1246 (the comment correction that surfaced this), #779 (the fix that created this shape), #780 (the same damage by another route), #788, #1016 (the harness-fidelity card this touches).
Blocked-by: objectstack-ai/objectstack#11552
Found while correcting the rc.2-era bulk-hook comments (#1246). Filed unassigned — this is a record, not a claim. Not fixed in #1246's PR, which is comment text only.
What is wrong
#779 closed the old bulk-path hazard:
ctx.previouswas always absent, so hooks reading it spun uselessly. It is genuinely fixed — on the pinned@objectstack/* 17.1.0a predicate write dispatches once per matched row withinput.idbound andpreviousbound to that row's pre-image.That fix hands the same population a different hazard, and it is the harder one to see because the hook now appears to work.
Per ADR-0058 Addendum II D3, quoted from the installed
@objectstack/spec(dist/data/index.d.ts):knowledge_article_publish_timestamps(src/objects/knowledge_article.hook.ts) makes exactly such a rewrite. Its first-publish criterion is conditioned on the row:On a predicate update, whichever matched row takes that branch sets
published_atfor the whole batch.Measured, on the pinned 17.1.0
Real
ObjectQL+InMemoryDriver, three published rows, one predicate update{ where: { status: 'published' }, multi: true }. Each dispatch stamped a distinct marker date so the stored value names the row whose dispatch computed it.a1anda2each carried their own historical publish date and each correctly declined to stamp. They were overwritten anyway, witha3's value — the only row that took the branch. A second run with the real clock shows the same shape: two 2024 dates replaced by today's.Trigger condition: at least one matched row lacks
published_at. If every matched row already has one, no dispatch takes the branch and nothing widens — which is why this is easy to miss in a spot check.Blast radius:
all_articlessortspublished_at desc, so a single mass edit can reorder the article list and destroy imported publication history — the same class of damage as #780, reached by a different route.What is NOT affected
published_atpreserved per row, drafts untouched. A batch insert is N records, not one SET clause, so D3 does not apply.last_reviewed_atis fine. It is stamped unconditionally, so it is row-invariant and D5-equivalent to the by-id path.last_reviewed_atis unconditional only after an early return that reads the row, so it is row-invariant only when every matched row is published — true ofwhere: { status: 'published' }, false in general. Measured on a mixed batch (where: { category }over one published and two draft articles): both drafts were stampedlast_reviewed_atby the published row's dispatch. Same widening, quieter key.The class — triaged, 9 of 17
17 files under
src/objects/*.hook.tsreadprevious. All 17 were classified against the mechanical test, refined by thelast_reviewed_atmeasurement above to count a row-reading early return as row-conditioning: does any payload write depend on the row, including through an early return that reads it?9 carry at least one row-conditioned payload write. None can be fixed app-side (see the blocker below):
knowledge_article_publish_timestampspublished_atexistence criterion — this cardcampaign_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_type/duplicate_statusgated onprevious?.duplicate_statusaccount_protectionlast_activity_dategated oninput.owner_id !== previous.owner_idcampaign_member_lifecycleandforecast_derive_periodcarry the identical existence-criterion shape and are the closest twins.Clean:
contact_integrity,product_catalog,quote_workflow/quote_on_accepted,contract_validation,campaign_validation,article_feedback_metrics_refresh,opportunity_amount_rollup,quote_total_rollup,case_resolution_article_normalize,lead_automation,lead_auto_assign— payload-only writes,beforeInsert-only writes,after*events, orpreviousread solely to throw.product_catalogandcontract_validationare instructive: readingpreviousonly to refuse is exactly D3's blessed pattern, and they get away with it because they refuse unconditionally, never "refuse on the batch path".⛔ Why this cannot be fixed here — the blocker
Blocked-by: objectstack-ai/objectstack#11552.The platform behaves as declared: D3 states the batch-scoping and states that a row-conditioned rewrite is outside the contract, and the ADR is explicit that D3 is a contract statement rather than an enforcement, for a stated reason. That is a deliberate ruling and this card does not argue with it.
The gap is narrower: D3's three routes for row-specific work — throw,
ctx.apiper row, caller paginates — all require the handler to know it is on the per-row predicate path, and a body-only hook cannot know that. Measured on 17.1.0:buildSandboxContextmarshalsinput, previous, user, session, event, object, result, api, log, cryptoand nothing else;ctx.dispatchis absent, andinput.id/input.optionsare dropped because the flattening proxy marks them non-enumerable while the unwrap copies enumerable own keys only.ctx.dispatch?.mode === 'per-row'guard lowers cleanly throughextractHookBody, passes every in-process test in this repo, and evaluatesfalseon every production dispatch — so the widening continues while the code reads as though it were prevented. That trap is now named in the hook's own comment (PR #1274) so the next author does not build it.PR #1274 ships what is correct today: the comment, and a tripwire test that goes red when the platform hands bodies a per-row signal.
@objectstack/*from npm, so the unlock predicate is a read of the published artefact — see #1206 for the round where that distinction mattered.Suggested next step, once unblocked
A test in the shape of
test/hook-query-predicate.test.tscould hold the whole class by driving a real predicate update over a mixed batch and asserting no payload key is set on a row whose ownprevioussaid it should not be. Note the existing hook tests cannot: they handhook.handlera freshinputper call — per-row payload copies, the shape D3 says the engine does not build — so they stay green before and after any fix.Related: #1246 (the comment correction that surfaced this), #779 (the fix that created this shape), #780 (the same damage by another route), #788, #1016 (the harness-fidelity card this touches).