Found while authoring HotCRM's first mapping artifacts (objectstack-ai/hotcrm#603). Not blocking that work — filing so the divergence is recorded rather than rediscovered.
What happens
A CSV cell aimed at a structured address (json-backed) field passes the dry run and fails the real write.
Measured on 17.0.0-rc.1, POST /api/v1/data/crm_account/import, mapping Billing Address to billing_address (a Field.address):
dryRun: true, runAutomations: false
{ "total": 1, "ok": 1, "errors": 0, "created": 1,
"results": [ { "row": 1, "ok": true, "action": "created" } ] }
the same payload, dryRun omitted
{ "total": 1, "ok": 0, "errors": 1, "created": 0,
"results": [ { "row": 1, "ok": false, "action": "failed",
"code": "VALIDATION_FAILED",
"error": "Billing Address has an invalid address value: Invalid input: expected object, received string" } ] }
Nothing is corrupted — the engine rejects the string per row, loudly and readably. The problem is only that the dry run promised otherwise.
Why it matters
The dry run's stated contract is that it predicts the verdict the real write produces. import-coerce.ts says so twice, and both firstMissingRequiredField and firstConstraintViolation exist specifically to close that gap:
> Mirrors the numeric-range and string-length rules of the engine's validateRecord — same type applicability, same comparison, same code and message text — so the import's dry run predicts the verdict the real write produces (framework#3956). Before this, a dry run only reported coercion failures … so -500 in a min: 0 column was reported valid and then rejected by the write.
The engine's value-shape validation (valueShapeStrictFor / mediaValueShapeStrictFor) is a third such rule, and it has no pre-check counterpart. coerceCell routes address / location through its final catch-all — "Everything else (text, email, phone, json, html, single file, …): pass through" — so no verdict is formed at all.
This is precisely the "false all-clear" #3956 set out to eliminate, and it lands on the user who did the right thing: dry-run a 5,000-row file, get a clean report, run it, watch a column's worth of rows fail.
Suggested shape
Add a value-shape pre-check beside firstConstraintViolation, rendering the same code / message the engine's own rejection renders, so the two read identically. Scope it to the same "engine's OWN list" discipline the bounds check already follows — only the types validateRecord actually shape-checks — so it does not start false-alarming on types the write accepts.
Not the same thing
Composing an address object from separate Street / City / Postcode columns is a feature the mapping spec does not have (no object-building transform), and that is a defensible gap, not this bug. This issue is only about the dry run and the write disagreeing on the payload a caller did send.
裁决后状态(2026-08-06,cli 车道 PM 按裁决 D 拆分): 本单收缩为 cli 消费半边(rest/import 走新 validate-only 操作 + import-coerce.ts 手抄预检镜像退役);契约半边(DataProtocol 新增 validate-only 操作,spec 先行)已拆出 #6037。
Blocked-by: #6037
Found while authoring HotCRM's first
mappingartifacts (objectstack-ai/hotcrm#603). Not blocking that work — filing so the divergence is recorded rather than rediscovered.What happens
A CSV cell aimed at a structured
address(json-backed) field passes the dry run and fails the real write.Measured on 17.0.0-rc.1,
POST /api/v1/data/crm_account/import, mappingBilling Addresstobilling_address(aField.address):dryRun: true, runAutomations: false
{ "total": 1, "ok": 1, "errors": 0, "created": 1, "results": [ { "row": 1, "ok": true, "action": "created" } ] }the same payload, dryRun omitted
{ "total": 1, "ok": 0, "errors": 1, "created": 0, "results": [ { "row": 1, "ok": false, "action": "failed", "code": "VALIDATION_FAILED", "error": "Billing Address has an invalid address value: Invalid input: expected object, received string" } ] }Nothing is corrupted — the engine rejects the string per row, loudly and readably. The problem is only that the dry run promised otherwise.
Why it matters
The dry run's stated contract is that it predicts the verdict the real write produces.
import-coerce.tssays so twice, and bothfirstMissingRequiredFieldandfirstConstraintViolationexist specifically to close that gap:> Mirrors the numeric-range and string-length rules of the engine's
validateRecord— same type applicability, same comparison, samecodeandmessagetext — so the import's dry run predicts the verdict the real write produces (framework#3956). Before this, a dry run only reported coercion failures … so-500in amin: 0column was reported valid and then rejected by the write.The engine's value-shape validation (
valueShapeStrictFor/mediaValueShapeStrictFor) is a third such rule, and it has no pre-check counterpart.coerceCellroutesaddress/locationthrough its final catch-all — "Everything else (text, email, phone, json, html, single file, …): pass through" — so no verdict is formed at all.This is precisely the "false all-clear" #3956 set out to eliminate, and it lands on the user who did the right thing: dry-run a 5,000-row file, get a clean report, run it, watch a column's worth of rows fail.
Suggested shape
Add a value-shape pre-check beside
firstConstraintViolation, rendering the samecode/ message the engine's own rejection renders, so the two read identically. Scope it to the same "engine's OWN list" discipline the bounds check already follows — only the typesvalidateRecordactually shape-checks — so it does not start false-alarming on types the write accepts.Not the same thing
Composing an address object from separate
Street/City/Postcodecolumns is a feature the mapping spec does not have (no object-building transform), and that is a defensible gap, not this bug. This issue is only about the dry run and the write disagreeing on the payload a caller did send.裁决后状态(2026-08-06,cli 车道 PM 按裁决 D 拆分): 本单收缩为 cli 消费半边(rest/import 走新 validate-only 操作 +
import-coerce.ts手抄预检镜像退役);契约半边(DataProtocol 新增 validate-only 操作,spec 先行)已拆出 #6037。Blocked-by: #6037