Repository navigation
fix(core): an import row answers a sandboxed hook's refusal in the hook's words, and every REST write route is pinned from the ledger - #22716
Conversation
…ok's words toFailedResult built the row text from the error's .message, which for a sandboxed hook body is the debug wrapper. It now reads the caller-facing sentence through sandboxBusinessMessage, the read every other door makes, and takes it whole as the create door does. A crashed body is declined by that read and keeps its previous row text. Claude-Session: https://claude.ai/code/session_019SvPnd2bzECRNmAU9i6E4k Co-authored-by: Claude <noreply@anthropic.com>
…sal, from the route ledger Each POST/PUT/PATCH/DELETE row of the REST route ledger takes exactly one disposition: driven and answering the hook's sentence, measured and pinned at the debug wrapper it answers today, or exempted with the reason no refusal can reach its answer. The two import doors are driven through the real handlers and runImport, with the crash control on both. Claude-Session: https://claude.ai/code/session_019SvPnd2bzECRNmAU9i6E4k Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SvPnd2bzECRNmAU9i6E4k Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b9af936bcdc3774ea7abd734abb4ed8f21531210 && git checkout b9af936bcdc3774ea7abd734abb4ed8f21531210
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c63028e5bfb63aba438ed8de9febad86f448de91 82bebf3b45f4adc676324e54331c8b01e7d67b18 && git checkout -B drift-repro c63028e5bfb63aba438ed8de9febad86f448de91 && git merge --no-ff 82bebf3b45f4adc676324e54331c8b01e7d67b18
node scripts/docs-audit/affected-docs.mjs --json c63028e5bfb63aba438ed8de9febad86f448de91
|
Fixes #22694
Clause-②: no
An import row now answers a sandboxed hook's refusal in the hook's own words, the sentence
POST /data/:objectand/createManyalready answer. The REST route ledger's write rows are pinned from the ledger itself, so a write route ledgered later fails the pin until it is given a disposition.What changed
packages/core/src/utils/import-runner.ts:toFailedResultbuilds the row'serrorfromsandboxBusinessMessage(err)(from@objectstack/types, the read the other doors make) and falls back tosanitizeRowError(e.message)only when that read declines. There is no local unwrap. Thecodea body declared still rides. The async/import/jobsworker runs the samerunImport, so both doors take the same path.packages/core/src/utils/import-runner-sandbox-refusal-row.test.ts: 14 cases over the realrunImport. They cover each write path that can meet a hook: the inlinecreateDatapath,createManyDatadegraded to per-row writes,insertManyDataoutcomes, and the update half of an upsert. They also cover the declared code, door-to-row parity againstmapDataError, and four controls.packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts: the enumeration pin overREST_ROUTE_LEDGER's 45POST/PUT/PATCH/DELETErows. Both import doors run through the real handlers and the realrunImport, with the crash control on each..changeset/22694-import-row-hook-sentence.md:@objectstack/corepatch.Measured at the public doors
A stack booted with
@objectstack/verify'sbootStack(sqlite-wasm). Each object has onebeforeInsertsandboxed hook. The scratch script is not committed.maind8830c2throw new Error('Locked rows cannot be created by import.')POST /data/:object,/createManyPOST /data/:object/importhook 'mz_lock_insert' threw: Error: Locked rows …,IMPORT_ROW_FAILEDLocked rows cannot be created by import.,IMPORT_ROW_FAILEDPOST /data/:object/import/jobs, the stored result rowcode: 'RECORD_LOCKED',status: 409/import,/import/jobsRECORD_LOCKEDThis row is frozen.,RECORD_LOCKEDthrow new Error('Update the cost centre before importing this row.')/import,/import/jobsthrow new TypeError('boom')(a crash)/import,/import/jobshook 'mz_crash_insert' threw: TypeError: boomWhy the sentence is not passed through
sanitizeRowErrorThe card left this question open.
sanitizeRowErrorcleans driver text: it reads a message that starts with an SQL verb as a leaked statement, and it cuts text at 300 characters. The reference, the create door, relays the author's sentence whole. Fed throughsanitizeRowError, a plausible remedy sentence such asUpdate the cost centre before importing this row.comes back asThe database rejected this row (a value may be invalid or already in use).. So the sentence is taken whole, the same way the row already takes an adopted door verdict. Core test §3 pins row-to-door parity for four sentences, two of whichsanitizeRowErrorwould rewrite. Its anti-vacuity case shows that it does.The REST write-route pin: 45 rows, one disposition each
/metawrites, the data CRUD writes andquery,clone, both import doors, the jobcancelandundolookups,forms/:slug/submit,analytics/dataset/query,security/explain, both record-share routes,/batch, and the four bulk routes..message, so the repair is a per-family envelope decision and is not in this card's scope. They go to the seat as findings.POST /sharing/rules,DELETE /sharing/rules/:idOrNameandPOST …/evaluateanswer 500.confirm,dismissandpermission-sets/:id/discard-overlayanswer 500.approve,reject,recall,revise,resubmit,reassign,remind,request-infoandcommentanswer 500.POST /packages/publishanswers 500 withINTERNAL_ERRORand the wrapper as its message.draft,import,refresh-catalogandvalidateanswer 400.sys_approval_request.SharingRuleService.defineRuleupdatessys_sharing_rule.confirmAudienceBindingSuggestioninserts intosys_position_permission_set. So a sandboxed hook reaches them only when it is bound to*or to thatsys_*object. Reach for the package and external-datasource services was not traced.POST /email/send. No hook refusal can reach this answer. Every engine write onEmailService.send's path, thesys_emailpersist and its status updates, is non-fatal by design: it logs and continues. Its 500 arm does relay.message, so a producer added there later would ship the wrapper.cancelandundorows are driven on their job lookup, because that is what reaches their answer. The cancel's own status write swallows its failure, and the in-memory flag still stops the worker. Undo writes underskipAutomations, which the engine reads to skip every metadata-bound hook, and it counts its failures intofailed.Reverse verification
The fix was committed first (
a9691c77a). The mutation went throughscripts/ablation-replace.mjs(WRAP mode, restore armed) and replacedsandboxBusinessMessage(err) ?? sanitizeRowError(e?.message)withsanitizeRowError(e?.message). Anchor x1 to x0, blobf5ce06c616f7to9040aeb1b1ff. Then@objectstack/corewas rebuilt.ablation-dist-preflight.mjs @objectstack/core '(err) ?? sanitizeRowError(e?.message)' --absentreported the marker absent from all 14 built files. The rest pin resolves@objectstack/corethroughdist/.import-runner-sandbox-refusal-row.test.ts(14)Tests 9 failed, 5 passed (14), the predicted nine/import,/import/jobs. 45 greenTests 2 failed, 45 passed (47), those twoRestore: blob after restore equals HEAD (
f5ce06c616f7),git diff HEADis empty andgit status --porcelainis clean. After a rebuild, the preflight found the marker present indist/index.jsanddist/index.cjs. Both suites were green again: 14/14 and 47/47.Tests and gates, run at HEAD
82bebf3b4pnpm --filter @objectstack/core test:Test Files 89 passed (89),Tests 2307 passed (2307).pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2:Test Files 271 passed (271),Tests 5034 passed, 326 skipped (5360).pnpm --filter @objectstack/core typecheckandpnpm --filter @objectstack/rest typecheck: exit 0. The rest test layer reads0 file(s) / 0 error(s)held in its debt ledger.dispatch-gates --ranreads65 derived, 64 run, 1 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loads. Reason:PREREQUISITE NOT MET. The gate reads every package'sdist/, and 19 packages outside the built closure have none.pnpm lint, narrowed and proven:--print-config) puts the 3 TypeScript files in its population and the changeset outside it.eslint --no-inline-config --format jsonon those 3 files:files 3, 0 errors, 0 warnings.eslint.config.mjsnever enables type-aware linting (noparserOptions.project, no typed rules). Its local plugins are per-file AST rules whose only other inputs are two baseline JSON files this diff does not touch. So the diff cannot move the verdict on any untouched file.Acceptance notes
hook 'NAME' threw: TypeError: …, whilePOST /data/:objectand/createManyanswer500 INTERNAL_ERRORwith no fault text. Measured above. Unchanged here, as the card's control requires, and reported to the seat as a finding.origin/main(c63028e5b). No commit in that range touchesimport-runner.ts,rest-server.tsorrest-route-ledger.ts.Generated by Claude Code