Skip to content

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

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22694-import-row-hook-sentence
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22694-import-row-hook-sentence

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22694

Clause-②: no

An import row now answers a sandboxed hook's refusal in the hook's own words, the sentence POST /data/:object and /createMany already 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: toFailedResult builds the row's error from sandboxBusinessMessage(err) (from @objectstack/types, the read the other doors make) and falls back to sanitizeRowError(e.message) only when that read declines. There is no local unwrap. The code a body declared still rides. The async /import/jobs worker runs the same runImport, so both doors take the same path.
  • packages/core/src/utils/import-runner-sandbox-refusal-row.test.ts: 14 cases over the real runImport. They cover each write path that can meet a hook: the inline createData path, createManyData degraded to per-row writes, insertManyData outcomes, and the update half of an upsert. They also cover the declared code, door-to-row parity against mapDataError, and four controls.
  • packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts: the enumeration pin over REST_ROUTE_LEDGER's 45 POST/PUT/PATCH/DELETE rows. Both import doors run through the real handlers and the real runImport, with the crash control on each.
  • .changeset/22694-import-row-hook-sentence.md: @objectstack/core patch.

Measured at the public doors

A stack booted with @objectstack/verify's bootStack (sqlite-wasm). Each object has one beforeInsert sandboxed hook. The scratch script is not committed.

hook body door main d8830c2 this branch
throw new Error('Locked rows cannot be created by import.') POST /data/:object, /createMany 400, the sentence unchanged
same POST /data/:object/import 200, row hook 'mz_lock_insert' threw: Error: Locked rows …, IMPORT_ROW_FAILED 200, row Locked rows cannot be created by import., IMPORT_ROW_FAILED
same POST /data/:object/import/jobs, the stored result row the wrapper the sentence
a refusal declaring code: 'RECORD_LOCKED', status: 409 /import, /import/jobs the wrapper, RECORD_LOCKED This row is frozen., RECORD_LOCKED
throw new Error('Update the cost centre before importing this row.') /import, /import/jobs the wrapper the sentence, verbatim
CONTROL: throw new TypeError('boom') (a crash) /import, /import/jobs row hook 'mz_crash_insert' threw: TypeError: boom unchanged

Why the sentence is not passed through sanitizeRowError

The card left this question open. sanitizeRowError cleans 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 through sanitizeRowError, a plausible remedy sentence such as Update the cost centre before importing this row. comes back as The 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 which sanitizeRowError would rewrite. Its anti-vacuity case shows that it does.

The REST write-route pin: 45 rows, one disposition each

  • 24 answer the hook's sentence (§2). For each row, the downstream the handler awaits rejects with the refusal shape, and the test asserts that this downstream was called. A row that some earlier refusal answers, such as a validation 400 or a capability 403, therefore cannot pass. Rows: the five /meta writes, the data CRUD writes and query, clone, both import doors, the job cancel and undo lookups, forms/:slug/submit, analytics/dataset/query, security/explain, both record-share routes, /batch, and the four bulk routes.
  • 20 measured, not repaired: they answer the debug wrapper (§3). Each is pinned at today's status and wrapper text, so a repair goes red and moves the row to §2. These are hand-built error arms that relay .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.
    • Sharing rules (3): POST /sharing/rules, DELETE /sharing/rules/:idOrName and POST …/evaluate answer 500.
    • Security (3): suggested-bindings confirm, dismiss and permission-sets/:id/discard-overlay answer 500.
    • Approvals (9): approve, reject, recall, revise, resubmit, reassign, remind, request-info and comment answer 500.
    • POST /packages/publish answers 500 with INTERNAL_ERROR and the wrapper as its message.
    • External datasources (4): draft, import, refresh-catalog and validate answer 400.
    • Reach, read and not measured at a public door: these services write platform objects through the engine without catching the failure. The approvals service updates sys_approval_request. SharingRuleService.defineRule updates sys_sharing_rule. confirmAudienceBindingSuggestion inserts into sys_position_permission_set. So a sandboxed hook reaches them only when it is bound to * or to that sys_* object. Reach for the package and external-datasource services was not traced.
  • 1 exempted: POST /email/send. No hook refusal can reach this answer. Every engine write on EmailService.send's path, the sys_email persist 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.
  • The cancel and undo rows 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 under skipAutomations, which the engine reads to skip every metadata-bound hook, and it counts its failures into failed.

Reverse verification

The fix was committed first (a9691c77a). The mutation went through scripts/ablation-replace.mjs (WRAP mode, restore armed) and replaced sandboxBusinessMessage(err) ?? sanitizeRowError(e?.message) with sanitizeRowError(e?.message). Anchor x1 to x0, blob f5ce06c616f7 to 9040aeb1b1ff. Then @objectstack/core was rebuilt. ablation-dist-preflight.mjs @objectstack/core '(err) ?? sanitizeRowError(e?.message)' --absent reported the marker absent from all 14 built files. The rest pin resolves @objectstack/core through dist/.

suite predicted measured
core import-runner-sandbox-refusal-row.test.ts (14) 9 red: §1 x4, §2, §3 x4. 5 green: §3 anti-vacuity, §4 controls x4 Tests 9 failed, 5 passed (14), the predicted nine
rest ledger pin (47) 2 red: §2 /import, /import/jobs. 45 green Tests 2 failed, 45 passed (47), those two

Restore: blob after restore equals HEAD (f5ce06c616f7), git diff HEAD is empty and git status --porcelain is clean. After a rebuild, the preflight found the marker present in dist/index.js and dist/index.cjs. Both suites were green again: 14/14 and 47/47.

Tests and gates, run at HEAD 82bebf3b4

  • pnpm --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 typecheck and pnpm --filter @objectstack/rest typecheck: exit 0. The rest test layer reads 0 file(s) / 0 error(s) held in its debt ledger.
  • The dispatch gate list (53 lines) plus the 14 the re-derivation added, each exit code captured before any pipe. dispatch-gates --ran reads 65 derived, 64 run, 1 NOT-MEASURED, 0 UNRUN.
  • NOT MEASURED: check:dual-build-cjs-loads. Reason: PREREQUISITE NOT MET. The gate reads every package's dist/, and 19 packages outside the built closure have none.
  • pnpm lint, narrowed and proven:
    • The population is the diff against the base: 4 files. eslint's own config (--print-config) puts the 3 TypeScript files in its population and the changeset outside it.
    • eslint --no-inline-config --format json on those 3 files: files 3, 0 errors, 0 warnings.
    • Invariance: eslint.config.mjs never enables type-aware linting (no parserOptions.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

  • A crashed hook body's row still reads hook 'NAME' threw: TypeError: …, while POST /data/:object and /createMany answer 500 INTERNAL_ERROR with no fault text. Measured above. Unchanged here, as the card's control requires, and reported to the seat as a finding.
  • A sandboxed refusal that declares a 5xx status: the doors withhold its prose, but the import row has always relayed it, before with the wrapper and now without. Not measured at a public door.
  • The branch is 6 commits behind origin/main (c63028e5b). No commit in that range touches import-runner.ts, rest-server.ts or rest-route-ledger.ts.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/core, touching 3 documentable anchor(s).

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via data.create (sdk, the route ledger binds it to POST /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to POST /data/:object), data.find (sdk, the route ledger binds it to GET /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to GET /data/:object))
  • content/docs/api/data-flow.mdx (via data.create (sdk, the route ledger binds it to POST /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to POST /data/:object))
  • content/docs/api/environment-routing.mdx (via data.find (sdk, the route ledger binds it to GET /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to GET /data/:object))
  • content/docs/api/error-catalog.mdx (via data.create (sdk, the route ledger binds it to POST /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to POST /data/:object))
  • content/docs/deployment/troubleshooting.mdx (via data.find (sdk, the route ledger binds it to GET /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to GET /data/:object))
  • content/docs/kernel/runtime-services/data-service.mdx (via data.create (sdk, the route ledger binds it to POST /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to POST /data/:object), data.find (sdk, the route ledger binds it to GET /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to GET /data/:object))
  • content/docs/permissions/authentication.mdx (via data.find (sdk, the route ledger binds it to GET /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to GET /data/:object))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via data.create (sdk, the route ledger binds it to POST /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to POST /data/:object))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: /data/:object (route, 67 pages)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c63028e5bfb63aba438ed8de9febad86f448de91 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b9af936bcdc3774ea7abd734abb4ed8f21531210 — the merge of head 82bebf3b45f4adc676324e54331c8b01e7d67b18 into base c63028e5bfb63aba438ed8de9febad86f448de91, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c63028e5bfb63aba438ed8de9febad86f448de91 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 10, 2026 19:40
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 10, 2026 19:40
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit 91f46d0 Oct 10, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22694-import-row-hook-sentence branch October 10, 2026 20:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants