Skip to content

fix(core): an undeclared hook abort is HOOK_ABORTED on the import row too - #29996

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-c8ltvj
Nov 4, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-c8ltvj

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #29978

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:钩子用自由文本中止写入且未声明错误码时,POST /api/v1/data/:object/import 与异步导入作业的行错误码现在是 HOOK_ABORTED,不再是 IMPORT_ROW_FAILED,与 /data、/createMany、/batch 和每个 /data/:object/batch 行一致。
  • 为什么改:error codes: an undeclared hook onError abort answers the data door with a code-less 400; give it a ledgered ADR-0112 code (#27508 code half) #27597 给这种中止定了唯一线上码 HOOK_ABORTED,PR fix(metadata-protocol): an undeclared hook abort is HOOK_ABORTED on the per-object batch row too #29977 补上了批量行;导入行仍答 IMPORT_ROW_FAILED,导入客户端无法把业务规则拒绝与其它失败行区分开。
  • 风险与回滚:只换这一种中止的行码(行文案本就是钩子原话,不变);钩子声明了码的、钩子崩溃的、声明 5xx 的、驱动故障与畸形行都照旧。回滚即 revert 本 PR。
  • 先看:packages/core/src/utils/import-runner.ts 的 toFailedResult 那一行 code 与新函数 isUndeclaredHookAbort;新 pin 在 packages/qa/dogfood/test/batch-row-hook-aborted.dogfood.test.ts 的 §4。
  • 两分钟验证:pnpm --filter @objectstack/dogfood exec vitest run test/batch-row-hook-aborted.dogfood.test.ts,预期 23 passed。
  • 需要你做的:无,席位落地。

What changes

  • @objectstack/core (patch). toFailedResult, the core import runner's row mapper, now answers UNDECLARED_HOOK_REFUSAL_CODE (HOOK_ABORTED, from @objectstack/types) where it used to fall back to IMPORT_ROW_FAILED, when the failure is a sandboxed hook body's refusal that declared no code. The precedence is unchanged above it: a field finding's code, then the code the thrown error declared (registered or not), and only then the new arm, before IMPORT_ROW_FAILED. The row's error was already the hook's sentence (sandboxBusinessMessage, [finding] import: a sandboxed hook's refusal reaches an import row as the debug wrapper (hook NAME threw: Error: SENTENCE), the last REST face of #22588's family and the home of its REST write-route enumeration pin #22694) and does not move.
  • The judge. The new private isUndeclaredHookAbort(err) asks resolveThrownHttpError(err, 400).code === UNDECLARED_HOOK_REFUSAL_CODE. That resolver is where the constant is declared and the one place that answers it for exactly this throw: a sandbox business refusal, no code spelled, served at a 4xx. The 400 fallback is the status the data doors serve an undeclared sandbox refusal at.
  • Why not import isUndeclaredHookAbort from metadata-protocol (the dispatch's mechanism assumption, measured and falsified): it is a module-private function in @objectstack/metadata-protocol, which depends on @objectstack/core (packages/metadata-protocol/package.json), so core importing it is a cycle. Exporting it from @objectstack/types would add a public export (Clause-②: yes (widening)) and move a second package. Its own doc comment says it is "the resolver's own test, restated over the row's inputs", so the resolver is the shared judge both rows reach, and no second reading of innerMessage, code or the status is written here.
  • Why not adopt mapDataError's verdict (the other candidate, measured): its wrapper fallback answers HOOK_ABORTED for a plain Error that merely carries the hook 'x' threw: text with no innerMessage. The row deliberately relays that error's own text (§4 control in import-runner-sandbox-refusal-row.test.ts), and the batch row's isUndeclaredHookAbort does not count it as an abort either. Adopting the door's verdict would have moved that population too.
  • Import row-code vocabulary, measured before the change. ImportRowResultSchema.code (packages/spec/src/api/export.zod.ts) is z.string().optional(), an open string: the row already carries field-level codes (required, min_value), a hook's declared code verbatim, UNIQUE_VIOLATION, INVALID_FIELD, DUPLICATE_MATCH_KEY and IMPORT_ROW_FAILED. No published enum moves, and HOOK_ABORTED is already a ledger row (@objectstack/types, error-code-ledger.zod.ts). Hand-written docs (content/docs outside references/, skills/) name IMPORT_ROW_FAILED nowhere. So Metadata-protocol: none, Clause-②: no, patch, as the claim declared.

Tests

  • packages/core/src/utils/import-runner-sandbox-refusal-row.test.ts: §1's four write paths (inline createData, batched createManyData degraded to per row, partial-success insertManyData, the update half of an upsert) now expect UNDECLARED_HOOK_REFUSAL_CODE. New §6 pins door-to-row agreement (the row's code IS mapDataError(err).body.code, with an anti-vacuity check that the door says HOOK_ABORTED), a declared 4xx with no code, and six controls that keep their own code: a declared registered code, a declared unregistered code, a crashed body, a declared 5xx with no code, a plain error carrying only the wrapper text, and a driver fault.
  • packages/qa/dogfood/test/batch-row-hook-aborted.dogfood.test.ts §4, beside PR fix(metadata-protocol): an undeclared hook abort is HOOK_ABORTED on the per-object batch row too #29977's batch-row pin and on the same boot: the card's fixture (beforeInsert throwing free text) through POST /data/:object/import and POST /data/:object/import/jobs. Each door: the abort is HOOK_ABORTED with the hook's sentence; a declared RECORD_LOCKED wins; a crashed body keeps IMPORT_ROW_FAILED; a malformed row (blank required name) keeps its cell code. Then nothing was written.

Acceptance notes

  • The malformed-row control pins required, not IMPORT_ROW_FAILED. The dispatch expected a malformed row to keep IMPORT_ROW_FAILED. Measured live on both doors, a blank required cell answers { code: 'required', field: 'name', error: 'Name is required' } before and after this change, so that is what is pinned. IMPORT_ROW_FAILED is the fallback for a write failure that names no code at all; the crashed-body control holds that population.
  • The §4 pin lives inside the file's one top-level describe so it shares the boot (one bootStack per file) instead of booting a second stack.
  • pnpm lint (repo-wide eslint) was not run locally: .claude/agents/os-dev.md puts repo scans on CI and wins over the dispatch where they conflict. eslint with --no-inline-config on the three touched source files exited 0, and CI's Lint & Repo Gates runs the full scan.

Verification

All readings below are on head 91ca524d (objectstack-ai/objectstack), taken in this branch's own worktree.

Build, suites, typecheck, eslint
  • pnpm --filter '@objectstack/core...' build under os-verify-lock: VERDICT command-exit 0.
  • pnpm --filter @objectstack/core test: exit 0, Test Files 133 passed (133), Tests 2952 passed (2952).
  • pnpm --filter @objectstack/core typecheck: exit 0 (check:test-typecheck: OK, 4/4 pinned debt signatures held).
  • pnpm --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood exec vitest run test/batch-row-hook-aborted.dogfood.test.ts: VERDICT command-exit 0, Tests 23 passed (23).
  • pnpm --filter @objectstack/dogfood typecheck (8 GB heap): exit 0.
  • pnpm --filter '@objectstack/rest^...' build then the rest import and hook-refusal files (rest-write-route-hook-refusal-sentence.ledger, import-undo-controls, rest-hook-refusal-code-parity, import-runner-unique-violation-row, import-row-report-field-20701, import-row-schema-drift-20701): Tests 657 passed (657). No rest pin asserted the import row's IMPORT_ROW_FAILED for an abort, so none flips there.
  • eslint --no-inline-config on the three touched files: exit 0.
Ablation (once, the mapping reverted)

Mutation by scripts/ablation-replace.mjs: the anchor (isUndeclaredHookAbort(err) ? UNDECLARED_HOOK_REFUSAL_CODE : 'IMPORT_ROW_FAILED') (x1 → x0) replaced by a string-marked 'IMPORT_ROW_FAILED' (x0 → x1), blob 6780021e → e8a5bddf. pnpm --filter @objectstack/core build exit 0; ablation-dist-preflight.mjs @objectstack/core 'ablated-29978': marker present in dist/index.js and dist/index.cjs.

  • dogfood file: exit 1, Tests 2 failed | 21 passed (23). The two reds are sync and jobs: "an undeclared abort is HOOK_ABORTED with the hook's sentence", expected 'IMPORT_ROW_FAILED' to be 'HOOK_ABORTED', the row reading {"row":1,"ok":false,"action":"failed","error":"A QA budget line needs an approver before it can be recorded.","code":"IMPORT_ROW_FAILED"}. Every control stayed green.
  • core unit file: exit 1, 6 failed: §1's four write paths and §6's two abort cases, each "code": "IMPORT_ROW_FAILED" received. §6's six controls stayed green.

Restore: git checkout HEAD -- packages/core/src/utils/import-runner.ts, git diff HEAD empty, git hash-object = HEAD blob 6780021ee723, core rebuilt, ablation-dist-preflight … --absent: marker absent from all 14 built files, working tree clean.

Derived gates (dispatch-gates --commands, 64 commands)

node scripts/pm/dispatch-gates.mjs --ran on 91ca524d: 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED. Two exited 3 (prerequisite) on the first pass and were rerun after the prerequisite was met: check-plugin-teardown-shape.mjs --self-test after git fetch --unshallow origin main, and check:dual-build-cjs-loads after building the seven packages it named (studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, service-cluster-redis, service-knowledge). Both then exited 0. Every recorded exit code:

node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-adoption.mjs :: exit 0
node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-dev-prereqs.mjs --self-test :: exit 0
node scripts/check-dts-emitted.mjs --self-test :: exit 0
node scripts/check-empty-changeset.mjs --base origin/main :: exit 0
node scripts/check-empty-changeset.mjs --self-test :: exit 0
node scripts/check-issue-citations.mjs :: exit 0
node scripts/check-plugin-teardown-shape.mjs :: exit 0
node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0
node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0
node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0
node scripts/docs-audit/check-affected-docs.mjs :: exit 0
node scripts/docs-audit/check-drift-comment.mjs :: exit 0
node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0
node scripts/release-pending-publish.mjs --self-test :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm --filter @objectstack/spec run check:empty-state :: exit 0
pnpm --filter @objectstack/spec run check:liveness :: exit 0
pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0
pnpm --filter @objectstack/spec run check:variant-docs :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:ci-filter-parity :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:dispatcher-error-vocabulary :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:dts-closure :: exit 0
pnpm check:engine-double-contract :: exit 0
pnpm check:error-code-casing :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:issue-citations :: exit 0
pnpm check:kernel-hook-pairs :: exit 0
pnpm check:keyed-text-bounds :: exit 0
pnpm check:lean-entry-closure :: exit 0
pnpm check:logger-receiver-detach :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:objectql-double-limit :: exit 0
pnpm check:org-identifier :: exit 0
pnpm check:page-declaration-shape :: exit 0
pnpm check:platform-object-tenancy-census :: exit 0
pnpm check:published-files :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:registry-log-declared :: exit 0
pnpm check:rest-log-spy-declared :: exit 0
pnpm check:slot-lookup :: exit 0
pnpm check:sourcemap-no-sources-content :: exit 0
pnpm check:system-context-census :: exit 0
pnpm check:test-source-alias :: exit 0
pnpm check:tier-file-adoption :: exit 0
pnpm check:type-check-coverage :: exit 0
pnpm check:type-check-debt :: exit 0
pnpm check:undeclared-dep-imports :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0
node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0
pnpm check:dual-build-cjs-loads :: exit 0

Generated by Claude Code

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 4, 2026
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 39 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 0b99c2b1645b101459b1dd9fd4fecdf996194a76 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0e556386c35c537b20754638ebeab7317010b6dc — the merge of head 91ca524d3722ea5152e8ddf558c8aae5915334ea into base 0b99c2b1645b101459b1dd9fd4fecdf996194a76, 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 0e556386c35c537b20754638ebeab7317010b6dc && git checkout 0e556386c35c537b20754638ebeab7317010b6dc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0b99c2b1645b101459b1dd9fd4fecdf996194a76 91ca524d3722ea5152e8ddf558c8aae5915334ea && git checkout -B drift-repro 0b99c2b1645b101459b1dd9fd4fecdf996194a76 && git merge --no-ff 91ca524d3722ea5152e8ddf558c8aae5915334ea

node scripts/docs-audit/affected-docs.mjs --json 0b99c2b1645b101459b1dd9fd4fecdf996194a76

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 91ca524d3722ea5152e8ddf558c8aae5915334ea
Local-runs: none

Face: declaration (Clause-②: no, Metadata-protocol: none). No published schema and no governed path in the committed tree (4 files). Read: the full diff, card #29978 and its triage audit 6616449470, the twin PR #29977 (88cd0ab5, toRowApiError's isUndeclaredHookAbort), resolveThrownHttpError in packages/types/src/thrown-http-error.ts on main, and the dev report 6619974265.

① Derived judgments

  • One judge, the resolver's own. toFailedResult now falls back to UNDECLARED_HOOK_REFUSAL_CODE only where resolveThrownHttpError(err, 400).code says so. That limb answers HOOK_ABORTED exactly when no code was declared (declaredCode absent: no .code, no validation shape, no unevaluable hook condition), the status lands in 4xx (declared, or the 400 fallback the data doors serve an undeclared sandbox refusal at), and sandboxBusinessMessage answers. It is the same three conditions the batch row's private isUndeclaredHookAbort (refusal && no code && 4xx served) restates, so the two rows cannot drift apart. The order's assumption (import the batch row's predicate) was falsified correctly: that helper is module-private in metadata-protocol, which sits above core. Exporting it would have been a widening.
  • Precedence kept. first?.code ?? thrownCode ?? …: a finding's code and any code the body declared (registered or not) still win before the new limb is asked. A crashed body (isSandboxOwnFault, no business message), a declared 5xx, a plain Error carrying only wrapper text, and a driver fault all keep IMPORT_ROW_FAILED; each is pinned as a control in unit §6.
  • Every write path. The mapper is the one row builder for sync import, the async job's stored results, both halves of upsert, and the single/batched/partial-success paths. Unit §1 moves its four existing pins to the new code; dogfood §4 drives both real doors (/data/:object/import and /import/jobs) over the data batch: POST /api/v1/data/:object/batch reports an undeclared hook refusal as row code VALIDATION_ERROR, while /data, /createMany and /batch answer the same refusal HOOK_ABORTED #29894 fixture, with nothing written.
  • Deviation accepted. A malformed row answers its cell code (required, field: name) on both doors, not IMPORT_ROW_FAILED as the order guessed. That is today's behaviour, pinned as measured, not changed by this diff.

② Semver level

patch on @objectstack/core, Clause-②: no, Metadata-protocol: none on PR body and changeset. ImportRowResultSchema.code is an open z.string().optional(), so no schema moves. HOOK_ABORTED is already in the ledger (no new error code). resolveThrownHttpError and UNDECLARED_HOOK_REFUSAL_CODE were already exported from @objectstack/types; the new predicate is module-private. A value moving on an open string field is the precedent #29894/#27672 shipped as no + patch. Correct.

③ Boundary flags

  • Author objectstack-fleet[bot]: no non-fleet login to name.
  • Dev open_questions: none. Ablation as reported: the fallback restored, dogfood 2 red (sync, jobs) and unit 6 red, controls green; restored to the HEAD blob.
  • CI at review time: 12 success, 8 skipped, 21 in progress, 0 failures. Landing waits for every check on this head.

Implemented-by: claude/project-thread-c8ltvj
Reviewed-by: session_01WGufzibcN9oHa8kDbTTnL5

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5

席位核对

Verdict: ACCEPT at head 91ca524d3722ea5152e8ddf558c8aae5915334ea · 2026-11-04T01:27Z

  • Shape: 4 files: the core import runner's toFailedResult plus a private predicate asked of resolveThrownHttpError, its unit cases, the data batch: POST /api/v1/data/:object/batch reports an undeclared hook refusal as row code VALIDATION_ERROR, while /data, /createMany and /batch answer the same refusal HOOK_ABORTED #29894 dogfood file's new §4 over both import doors, and one patch changeset. No published schema, no governed path ⇒ not Tier H. TIER_DEFAULT review PASS 6620003261 at this head.
  • Premise measured: both import doors answered IMPORT_ROW_FAILED for the undeclared abort. With the fallback ablated, dogfood went 2 red (sync, jobs) and unit 6 red, controls green.
  • Gates: core 2952/2952, dogfood file 23/23, rest import/hook files 657/657, core + dogfood typecheck 0, eslint on touched files 0, dispatch-gates 64/64 exit 0 (--ran 0 NOT-MEASURED). Landing waits for every check on this head to be green or an expected skip.
  • Accepted deviations: harness-pinned branch claude/project-thread-c8ltvj. The order's "import the batch row's predicate" was falsified (module-private, and metadata-protocol sits above core); the shared resolver is the judge instead. The malformed-row control pins its measured cell code required, not IMPORT_ROW_FAILED. The signed trailer is appended by the seat through the relay.
  • Landing: on all-green at this head, the seat sends pr_ready + automerge_enable through the relay.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 4, 2026 02:16
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 4, 2026 02:16
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit f19fbb8 Nov 4, 2026
69 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-c8ltvj branch November 4, 2026 02:42
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/m tests tooling

Projects

None yet

1 participant