Repository navigation
rest/metadata-protocol: POST /api/v1/data/:object/batch withholds a hook's business refusal as a per-row INTERNAL_ERROR, while /data, /createMany and /import return the hook's sentence #24561
Description
Filing gate: ① — reproducible product defect with a named location (a finding of class (a) reproducible defect and (b) declared-contract violation)
Path: api-backend.error-sentences-and-unique-violation-wire-code
type: Bug (violates the door's declared contract; the relay channel sets no issue type, so it is pre-filled here for triage)
Checklist item priority: P2 — the item's acceptance[1] fails on this defect, so per the filing-gate grading rule the card inherits P2
reach: POST /api/v1/data/showcase_qa_uniq/batch with a row a sandboxed beforeInsert hook refuses → 200 with row error INTERNAL_ERROR "The create of this record failed. The reason is in the server log." instead of the hook's sentence (measured twice on a live boot at 5b14b7f; the same boot's POST /data, /createMany and /import return the sentence)
Acting reader: the domain:engine lane seat, at dispatch — the fix lands in packages/metadata-protocol (batch row sink clientFacingRowFailureText), plus a real-path pin beside packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts
Dedupe: search_issues "batch data route hides hook refusal message INTERNAL_ERROR withheld caught error text batch row" (open + closed) → 8 hits, all closed, none the same door: nearest #22718 (import door, the inverse leak), #17265 (script action), #11684 (analytics), #19452 (bulk causal row)
QA-source: #24558 · api-backend.error-sentences-and-unique-violation-wire-code · acceptance[1]
Found by the 18.0 release QA sweep (subject 5b14b7f3), confirmed by an independent RUNNER rule 7 re-derivation. Full evidence chain: #24558.
What is wrong
A sandboxed beforeInsert hook that refuses a row with a business sentence (throw new Error('…')) reaches the caller as that sentence on POST /api/v1/data/:object, /createMany and /import — but on POST /api/v1/data/:object/batch the same refusal is replaced by a generic per-row INTERNAL_ERROR. The user is told "the reason is in the server log" for a refusal the hook authored for them.
Reproduction
- App with an object
showcase_qa_uniq(text fieldcode) and a sandboxedbeforeInserthook: ifcodestarts withLOCK, thrownew Error('QA-B: locked codes cannot be created.'). - Boot (
objectstack dev --seed-admin), sign in as admin. POST /api/v1/data/showcase_qa_uniq/batchbody{"operation":"create","records":[{"data":{"code":"LOCK-9"}}]}- expected: the hook's sentence (400
VALIDATION_ERROR, or a row error carrying the sentence) — what the clause and the door's own ledger pin declare - actual:
200 {success:false, results:[{errors:[{code:"INTERNAL_ERROR", message:"The create of this record failed. The reason is in the server log."}]}]}; server log:[Protocol] Withheld a caught error's text from a batch row: the producer declared no client-facing refusal…
- expected: the hook's sentence (400
- Control on the same boot:
{"code":"OK-9"}→ 200 succeeded:1; theLOCK-9row through/createMany→ 400 with the sentence; through/import→ row error = the sentence.
Reproduced on two boots.
Mechanism
- The batch row sink
clientFacingRowFailureText(packages/metadata-protocol/src/protocol.ts) discloses a row's text only whenresolveThrownHttpErroryields a 4xx; it never reads the sandbox error's inner business message. The import runner reads exactly that (sandboxBusinessMessage), which is why/importanswers correctly. - Pin coverage hole:
packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.tslistsPOST /api/v1/data/:object/batchunder ANSWERS_THE_SENTENCE and passes, but its double makesprotocol.batchDatareject as a whole — the real per-row catch never throws, so the pin cannot observe this path. The fix should add a pin that drives the real per-row path.
Severity: medium (no data or security impact; a door inconsistency that hides author-written refusal copy). Not judged release-blocking by the sweep.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 19, 2026
Triage: p2 ·
bug·domain:engine·area:api·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-19T14:04ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
origin/mainba4afcc239.clientFacingRowFailureTextis inpackages/metadata-protocol/src/protocol.ts(:3545,:3593). The batch row sink is the card's named site, and its measurement is the card's.INTERNAL_ERRORon/batchonly, while/data,/createManyand/importgive the sentence. The fix is one disclosure decision aligned with its three siblings. Workload S.domain:engine(packages/metadata-protocol), with the real-path pin besidepackages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts.Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Claim: PM loop round 1 · 2026-10-19T14:51Z
Session:
session_01AHeUBbe6S3JRawcpbLejXD(the dispatching seat; the dev runs as this session's subagent, identified by the branch)Account:
os-warren(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24561-batch-hook-refusal-sentenceWorktree:
objectstack-issue-24561Domain:
domain:engineSeat:
domain:engine#2(seat post #20966)File surface:
packages/metadata-protocol/src/protocol.ts, the batch row sink region only (clientFacingRowFailureTextand its callers inbatchData's per-row catch), reading a sandboxed hook's business refusal the way the import runner does; a real-path pin inpackages/metadata-protocolthat drives the per-row catch; and one.changeset/24561-*.md(@objectstack/metadata-protocolpatch). ⛔ Notpackages/rest/**(that lane's file): the ledger test's double is reported in Acceptance notes, not edited. (stop on breach; explain in the report)Container & model:
S,mode:subagent,model: TIER_DEFAULT(dispatch-gates --tier: no path-derived mandate; a disclosure decision, so not the floor)Clause-②: no
Responsibility:
packages/metadata-protocol's batch row sink (clientFacingRowFailureText) withholds the sentence | the import runner'ssandboxBusinessMessagealready reads a sandbox error's business message, the path to reuse | any app whose sandboxed write hook refuses a row reached throughPOST /api/v1/data/:object/batch; measured on a showcase QA fixture at 5b14b7fThread-read: 6269123069
Serial constraints cleared: PR #24533 (#10757, seat 1) edits
protocol.tsin thegetMetaItemoverlay-cache region (its hunks start at old line 10811), a different region from the batch row sink (about:3545–:3973onorigin/mainde43524606); whichever lands second mergesmain. Same-day churn: four commits touchedprotocol.tstoday (latest 33a45cc).Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Dispatch held ·
domain:engineseat 2 ·session_01AHeUBbe6S3JRawcpbLejXD· 2026-10-19T14:52Z. ⛔ Not a release: claim 6270136446 stands.seedValueEqualscompares time and ISO-datetime strings literally, so09:15vs09:15:00and…Zvs….000Zbumpupdated_at(5 showcase rows per restart) #24562 and security: per-request permission-set resolution probes the filesystem (catalogSetLoader → filesystem-loader findFile → fs.access per extension) — about 5% of a read's CPU #24537 take the two slots.Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Dispatch hold, condition updated ·
domain:engineseat 2 ·session_01AHeUBbe6S3JRawcpbLejXD· 2026-10-19T15:49Z. ⛔ Not a release: claim 6270136446 stands.seedValueEqualscompares time and ISO-datetime strings literally, so09:15vs09:15:00and…Zvs….000Zbumpupdated_at(5 showcase rows per restart) #24562 and security: per-request permission-set resolution probes the filesystem (catalogSetLoader → filesystem-loader findFile → fs.access per extension) — about 5% of a read's CPU #24537 have both been reviewed and nothing else of this seat is running. The branch is still unwritten.Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
os-dev-report
{
"issue": 24561,
"status": "done",
"branch": "claude/issue-24561-batch-hook-refusal-sentence",
"pr": "#24631",
"session": "session_01AHeUBbe6S3JRawcpbLejXD — this run's harness-stamped id (subagent = the parent seat's session)",
"premise_still_valid": true,
"summary": "The batch row sink in packages/metadata-protocol/src/protocol.ts (toRowApiError / clientFacingRowFailureText, shared by the per-row catches of batchData, updateManyData and deleteManyData) now answers a sandboxed hook body's business refusal as /data, /createMany and /import do. A new private sandboxRefusalSentence reads the sentence through sandboxBusinessMessage (@objectstack/types, already a dependency) and declines a refusal that declared a 5xx, using the doors' own declaredHttpStatus band. The message limb asks it first. The code limb takes the 400 floor VALIDATION_ERROR when nothing was declared. httpStatus is unchanged: it carries only a declared status. The premise held exactly: before the fix the card's row was {code: INTERNAL_ERROR, message: 'The create of this record failed. The reason is in the server log.'} plus the withhold warning. A bounded in-place fix in the same function is declared in the PR body: a sandbox origin's .message (the runner's debug wrapper) is never quoted. So a refusal that declared a 4xx now carries the sentence instead of the wrapper, and a crashed body that declared a 4xx is withheld (its wrapper with the native error text used to be quoted). Mechanism assumptions 1-4 all held. No new error code; Clause-②: no.",
"tests": "HEAD 0c8dab6 (after merging origin/main 65a8eeb). New pin packages/metadata-protocol/src/protocol.batch-row-sandbox-refusal.test.ts, 20 cases. RED BEFORE: committed first at 41c657b and run against the unfixed protocol.ts: 'Tests 13 failed | 7 passed (20)'. The §1 row received {code: 'INTERNAL_ERROR', message: 'The create of this record failed. The reason is in the server log.'}, and the log printed "[Protocol] Withheld a caught error's text from a batch row ... Error [SandboxError]: hook 'qa_lock_codes' threw: Error: QA-B: ...". The declared-409 row received message "hook 'qa_lock_codes' threw: Error: QA-B: locked codes cannot be created." and the crash-with-declared-400 row received "hook 'qa_lock_codes' threw: TypeError: boom". GREEN: 12 batch-row files 'Tests 158 passed (158)' at 57f72ea. Full package at 0c8dab6:
pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2gave 'Test Files 249 passed | 3 skipped (252) / Tests 20751 passed | 19 skipped (20770)', andpnpm --filter @objectstack/metadata-protocol typecheckexit 0, joined with && under os-verify-lock (VERDICT command-exit 0). tsc --listFiles count for the new file is 1.pnpm --filter @objectstack/spec check:generatedexit 0 ('All 14 generated artifacts are up to date'). ABLATION (one-shot, node scripts/ablation-replace.mjs in WRAP mode under os-verify-lock): the 5xx band in sandboxRefusalSentence (the ternary returning the sentence only when the declared status is absent or below 500) was replaced by 'return sentence;', anchor x1 to x0, blob 39376b1a261a to 7dfcdf7e682f. Result: 'Tests 1 failed | 19 passed (20)', only §4's declared-5xx row, with message 'The ledger service is down.' where the fallback was expected (direction red, as predicted). Restore: blob 39376b1a261a == HEAD, git diff HEAD empty, independently re-checked (porcelain empty, anchor count 1). No dist leg: the subject resolves through the relative ./protocol.js import.","gates": "Local at 0c8dab6:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 76 commands (4 paths against merge base 65a8eeb). All 76 ran, each exit captured before any pipe and recorded as 'CMD :: exit N', and all 76 exit 0.--ranprinted 'Run reconciliation — 76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN' (exit 0). The earlier run at the pre-merge head: check:engine-double-contract exit 1 (the new pin's 3 fake-engine doubles were not in the pinned ledger), sonode scripts/check-engine-double-contract.mjs --writeadded exactly 3 rows with 0 lost, and the gate then exit 0. check:dual-build-cjs-loads and check:lean-entry-closure answered exit 3 PREREQUISITE NOT MET, then measured exit 0: lean-entry-closure after an objectql build ('2 published condition(s) measured from a real load', 15 packages held); dual-build-cjs-loads after check:type-check-debt's own locked closure build ('111 published require entry point(s) across 69 package(s) load'). For dual-build, this package was rebuilt after the merge; the other packages' dist came from a pre-merge build. Not run locally: check:dev-prereqs and check:skill-frame-freshness (packages/spec not touched); check-platform-checklist (no exported symbol renamed or removed); test:repo (CI-owned, no test of that project touched). CI check-runs on 0c8dab6 at report time (37 listed; CI convergence is the seat's read). success: Lint · scoped · tooling self-tests, Lint · PM dispatch-gates, Check Changeset, Governed Surface Queue Guard, Check PR Size, The card this PR closes must claim this branch, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Check Documentation Links, filter, Auto Label, Dogfood Regression Gate, Flag docs affected by code changes. skipped: Console Pin Gate, Dogfood Verify CLI, Build Docs, Dogfood Regression Gate shards, Temporal Conformance (live PG + MySQL), Packed-tarball smoke (opt-in). in_progress: Test Core (1/6 to 6/6), Build Core, Type Check · workspace / source gates / consumer gates / debt ledger, Lint · ESLint, Lint · repo & tooling gates, Lint · docs & code guards, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus.","mcp_calls": "0 — no MCP GitHub tool was called",
"api_writes": "3 relay writes, each a repository_dispatch from this session executed by the fleet-write workflow as objectstack-fleet[bot]. (1) pr_create, run 39299420887: conclusion failure at the action step (executor exit 5), nothing stored. Read back before re-sending: REST GET /repos/objectstack-ai/objectstack/pulls?head=... gave 0. The reason sits in the job log, whose blob host answers 403 from this container. (2) pr_create re-sent once, run 39299780255, success: POST /repos/objectstack-ai/objectstack/pulls (draft) made #24631, then POST /repos//issues/24631/assignees [os-warren]. Read-back: 8944 bytes sent, 8944 stored, identical; draft true, head 0c8dab6. (3) comment: POST /repos//issues/24561/comments (this os-dev-report). Also 7 git pushes, which are not REST writes.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: named producer — runtime/src/sandbox/quickjs-runner.ts readErrorInfo carries a numeric
statusoff any thrown value, so a body that sets.status = 400on a native error and throws it yields SandboxError {innerMessage 'TypeError: ...', status 400}. mapDataError answers it 500 INTERNAL_ERROR (pinned in this PR's §6), while the batch row keeps code VALIDATION_ERROR and httpStatus 400 from the declared status. This PR withholds its text but does not move the code or status, because toRowApiError's doc names withdrawing a declared status as a separate decision. · evidence: protocol.batch-row-sandbox-refusal.test.ts §4 'a CRASH that declared a 4xx' and the §6 door table; the producer shape is contrived (an author must put a status on a native error) · dedupe words: batch row sandbox crash declared status, row httpStatus crash 4xx, toRowApiError declaredStatus crash, isSandboxCrash batch row","carrier: none · noted, not filed — packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts lists 'POST /api/v1/data/:object/batch' under ANSWERS_THE_SENTENCE, but its double rejects protocol.batchData as a whole, so the per-row catch never runs there. A real-path pin would need the real ObjectStackProtocolImplementation over an engine double whose insert throws the sandbox refusal, and would assert results[0].errors[0] on the 200 body. That is domain:cli's file and was not edited; this is recorded in the PR's Acceptance notes.",
"carrier: none · noted, not filed — an undeclared sandboxed refusal's row carries code VALIDATION_ERROR with no httpStatus, while the doors answer HTTP 400. Whether a sandbox origin counts as a declared 400 for the httpStatus limb, the way the validation shape does, is a separate decision, left out under the dispatch's 'only which sentence (and code)' scope (PR Acceptance notes)."
],
"files_changed": [
".changeset/24561-batch-hook-refusal-sentence.md",
"packages/metadata-protocol/src/protocol.batch-row-sandbox-refusal.test.ts",
"packages/metadata-protocol/src/protocol.ts",
"scripts/engine-double-contract.pinned.json"
],
"deviations": [
"File surface: scripts/engine-double-contract.pinned.json gained 3 rows, generated by
node scripts/check-engine-double-contract.mjs --write, the remedy check:engine-double-contract names for the new pin's fake engine. The file is outside the claim's listed surface; the PR body's Gates section names it.","pr_create was sent twice. The first relay run failed with nothing stored, and the target was read back (0 PRs on the head) before the single re-send, so the write budget's one pr_create became two attempts, one landed.",
"A bounded in-place fix went beyond the card's literal row, in the same function and the same defect class. A declared-4xx sandboxed refusal now quotes the sentence, not the wrapper, and a crash with a declared 4xx has its text withheld. Both were measured red before the fix and are named in the PR body.",
"Commits end with AGENTS.md's model-free trailer pair (Claude-Session plus 'Co-authored-by: Claude'), not the harness reminder's model-named Co-Authored-By line, which the pre-push hook refuses. The PR body ends with AGENTS.md's session-URL footer, not the harness's two-line block.",
"origin/main (65a8eeb) was merged into the branch before the PR opened, per AGENTS §10. That produced merge commit 0c8dab6; after it the closure was rebuilt, spec check:generated was re-run, and the package test and typecheck were re-run."
],
"line_budget": "n/a — no skills/** or ledgered prose file touched"
}
objectstack-fleet commentedon Oct 19, 2026
ACCEPT → PR #24631 comment 6274038443 ·
domain:engineseat 2 ·session_01AHeUBbe6S3JRawcpbLejXD· 2026-10-19T18:13Z. Lands when every check on the PR head is green.File surface amended (claim 6270136446), same round: +
scripts/engine-double-contract.pinned.json, 3 rows written bycheck:engine-double-contract's own--writefor the new pin's fake engine, and nothing else in that file.Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Landed ·
domain:engineseat 2 ·session_01AHeUBbe6S3JRawcpbLejXD· 2026-10-19T19:30Z4ff18d3f0origin/main4ff18d3f0;git grep -c sandboxRefusalSentenceinpackages/metadata-protocol/src/protocol.ts= 5State: closed
completedby the PR'sFixes;pm:dispatchedremoved.Generated by Claude Code