Platform ask from an app that has now paid for the workaround three times over. Filed from objectstack-ai/hotcrm#1165; nothing is broken on the platform today, so this is a contract gap, not a defect.
The situation
The engine implements deleteBehavior: 'set_null' by UPDATING the row that HOLDS the lookup. So "delete the contact" arrives at the holder's beforeUpdate looking exactly like an ordinary user edit — and any app guard that freezes settled records refuses it, which makes a frozen record able to keep the person it references undeletable forever (a GDPR erasure with no way to carry it out). HotCRM hit this in its three freeze guards on crm_lead, crm_opportunity and crm_quote.
The engine already knows the difference. ObjectQL.cascadeDeleteRelations builds:
constreferentialCtx={ ...context??{},__referentialFieldClear: true};But that key is operation-private, and there is no declared way for a hook to ask "is this write the engine's own reference cleanup?".
Measured on 17.1.0
Re-measured on @objectstack/* 17.1.0 (originally on 17.0.0 GA) by registering a probe hook at priority 199, immediately ahead of each guard, on all three objects, and walking the context of the engine's cleanup write against a user's hand-clear of the SAME lookup:
cascade (engine) hand-clear (user)
api.executionContext keys
[__referentialFieldClear, transaction] [isSystem, userId]
MARKER true undefined
ctx.user undefined { id: THE_CALLER }
ctx.session undefined { userId: THE_CALLER, isSystem: true }
ctx.input { id, LINK: null, updated_at } { id, LINK: null, updated_at, updated_by }
ctx.provenance undefined undefined
Identical on crm_opportunity, crm_quote and crm_lead.
So the marker IS reachable today, at ctx.api.executionContext.__referentialFieldClear. The app deliberately does not read it, and both reasons are exactly why a declared key is worth adding:
- It is an operation-private key. The
__ prefix is the platform's own convention for "not part of the contract" — the same convention buildSession enforces when it builds ctx.session field by field from a fixed, named allow-list that no __-prefixed key is in. An app building correctness on it has an undeclared dependency that can vanish in a patch release without anyone calling it a break. - Reachability through the shipped path is unproven. That reading was taken in a kernel rig, where handlers run natively and
ctx.api is the engine's own ScopedContext (keys measured as [engine, executionContext, joinedHandles]). In production a hook body runs body-only inside QuickJS, and ctx.api there is whatever buildSandboxApi hands it: engineCtx.api when that exposes object(), and otherwise a shim of { object } carrying no executionContext at all. A predicate reading the marker could be green in the rig and silently false in production — the worst available outcome for a guard. Same family as ctx.dispatch?.mode === 'per-row' (objectstack#11552), which this app has already paid for once.
Note the fallback the app is left with is not sound either — it just fails safe rather than silently. The two writes differ in shape only because the engine happens to omit updated_by on the cascade; nothing declares that, and both writes clear the same declared link from a value to null with the referenced row readable from either.
Reading !ctx.session is not an alternative discriminator: buildSession's own contract makes undefined mean "no identity envelope was supplied", which is equally true of any bare-kernel or programmatic write.
The ask
A first-class, declared marker on the hook context:
ctx.referentialFieldClear (boolean), declared on HookContextSchema in packages/spec;- populated on every reference-cleanup write the engine issues;
- and, being declared, carried across the sandbox boundary by contract rather than by luck — which is the half
__referentialFieldClear cannot offer at any level of care on the app side.
What it buys, concretely
In HotCRM specifically, and already quantified:
- three verbatim copies of a shape predicate (
lead_automation, opportunity_lifecycle, quote_workflow) collapse into one honest read. They are copies rather than a shared helper because hook bodies run body-only and cannot reach module scope, so the app also carries a drift pin asserting the three copies stay byte-identical; - the narrowness caveats that hotcrm#720 had to write down stop being load-bearing;
- the
REFERENCE_FIELDS-completeness pin (which exists only because a lookup added to an object and not to the app's hand-maintained field list would silently go on blocking deletes) becomes unnecessary.
Generalises to any app with a freeze/lock/approval guard on an object that is the target of a set_null lookup — the guard has to yield to the engine's cleanup and has no declared way to recognise it.
Back-link: objectstack-ai/hotcrm#1165 (carries the original 17.0.0 GA measurement and the app-side ruling to keep sniffing shape until this lands).
Generated by Claude Code
Platform ask from an app that has now paid for the workaround three times over. Filed from
objectstack-ai/hotcrm#1165; nothing is broken on the platform today, so this is a contract gap, not a defect.The situation
The engine implements
deleteBehavior: 'set_null'by UPDATING the row that HOLDS the lookup. So "delete the contact" arrives at the holder'sbeforeUpdatelooking exactly like an ordinary user edit — and any app guard that freezes settled records refuses it, which makes a frozen record able to keep the person it references undeletable forever (a GDPR erasure with no way to carry it out). HotCRM hit this in its three freeze guards oncrm_lead,crm_opportunityandcrm_quote.The engine already knows the difference.
ObjectQL.cascadeDeleteRelationsbuilds:But that key is operation-private, and there is no declared way for a hook to ask "is this write the engine's own reference cleanup?".
Measured on 17.1.0
Re-measured on
@objectstack/*17.1.0 (originally on 17.0.0 GA) by registering a probe hook at priority 199, immediately ahead of each guard, on all three objects, and walking the context of the engine's cleanup write against a user's hand-clear of the SAME lookup:Identical on
crm_opportunity,crm_quoteandcrm_lead.So the marker IS reachable today, at
ctx.api.executionContext.__referentialFieldClear. The app deliberately does not read it, and both reasons are exactly why a declared key is worth adding:__prefix is the platform's own convention for "not part of the contract" — the same conventionbuildSessionenforces when it buildsctx.sessionfield by field from a fixed, named allow-list that no__-prefixed key is in. An app building correctness on it has an undeclared dependency that can vanish in a patch release without anyone calling it a break.ctx.apiis the engine's ownScopedContext(keys measured as[engine, executionContext, joinedHandles]). In production a hook body runs body-only inside QuickJS, andctx.apithere is whateverbuildSandboxApihands it:engineCtx.apiwhen that exposesobject(), and otherwise a shim of{ object }carrying noexecutionContextat all. A predicate reading the marker could be green in the rig and silently false in production — the worst available outcome for a guard. Same family asctx.dispatch?.mode === 'per-row'(objectstack#11552), which this app has already paid for once.Note the fallback the app is left with is not sound either — it just fails safe rather than silently. The two writes differ in shape only because the engine happens to omit
updated_byon the cascade; nothing declares that, and both writes clear the same declared link from a value tonullwith the referenced row readable from either.Reading
!ctx.sessionis not an alternative discriminator:buildSession's own contract makesundefinedmean "no identity envelope was supplied", which is equally true of any bare-kernel or programmatic write.The ask
A first-class, declared marker on the hook context:
ctx.referentialFieldClear(boolean), declared onHookContextSchemainpackages/spec;__referentialFieldClearcannot offer at any level of care on the app side.What it buys, concretely
In HotCRM specifically, and already quantified:
lead_automation,opportunity_lifecycle,quote_workflow) collapse into one honest read. They are copies rather than a shared helper because hook bodies run body-only and cannot reach module scope, so the app also carries a drift pin asserting the three copies stay byte-identical;REFERENCE_FIELDS-completeness pin (which exists only because a lookup added to an object and not to the app's hand-maintained field list would silently go on blocking deletes) becomes unnecessary.Generalises to any app with a freeze/lock/approval guard on an object that is the target of a
set_nulllookup — the guard has to yield to the engine's cleanup and has no declared way to recognise it.Back-link:
objectstack-ai/hotcrm#1165(carries the original 17.0.0 GA measurement and the app-side ruling to keep sniffing shape until this lands).Generated by Claude Code