Repository navigation
fix(types): an unevaluable hook condition's refusal answers 400 VALIDATION_ERROR at every data door - #24714
Conversation
…ATION_ERROR at every data door A HookConditionError whose condition compiled and then faulted on the record it judged carries no status and no code by its own contract, and no boundary classified it, so every door answered the unclassified 500 INTERNAL_ERROR and @objectstack/rest logged it as an unhandled error. One recogniser in @objectstack/types (hook-condition-refusal.ts) now answers 400 and the catalog member 400 names; resolveThrownHttpError reads it as a declaration by shape (the runtime and Hono doors, the batch rows), and structuredCodeAnswer as an arm beside its validation-predicate sibling (the REST doors). The uncompilable reason keeps its sanitised 500. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
…swers the same coded refusal The dogfood pin flipped by the previous commit drives an afterInsert hook whose condition reads previous on an insert, through the write door's unit. The same shape is now pinned in process on the REST create door: 400 VALIDATION_ERROR naming the hook, the row rolled back, no Unhandled line. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 5 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 bfc6f588c68ef14dc6906716cd190a282dff3ca1 && git checkout bfc6f588c68ef14dc6906716cd190a282dff3ca1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 73d4cc7391bb5c2dc5d40e23af8cd505721242af 910352e2182a99ac3cf1bd4cf3b5f0c99bea4a3d && git checkout -B drift-repro 73d4cc7391bb5c2dc5d40e23af8cd505721242af && git merge --no-ff 910352e2182a99ac3cf1bd4cf3b5f0c99bea4a3d
node scripts/docs-audit/affected-docs.mjs --json 73d4cc7391bb5c2dc5d40e23af8cd505721242af
|
席位核对 · ACCEPT
Accepted deviations:
Out-of-scope dispositions (one line each):
Landing: held while main-red #24700 is open (AGENTS.md: no lane PR arms the queue meanwhile; fix claim Generated by Claude Code |
Fixes #24662
Clause-②: no
A write that an authored hook
conditionrefused, because the condition compiled and then could not be evaluated on the record it was judging, now answers a coded refusal:400with codeVALIDATION_ERROR, on the REST data doors and on the runtime dispatcher's/datadoor. Before, it answered500 INTERNAL_ERROR, and@objectstack/restlogged it as[REST] Unhandled error. Fail-closed does not move: the row is still not written.What was wrong (measured on
origin/maineadea5720)The card's repro was driven through a real engine (
ObjectQLwith a sqliteSqlDriver) and a realObjectStackProtocolImplementation. The hook was bound by the engine's ownbindHooksToEngine:beforeInsertoninquiry, with the conditionrecord.name.startsWith('qa-fc') && int(record.name) > 0. The request wasPOST /api/v1/data/inquirywithname: "qa-fc-abc".RestServerPOST /data/:object(mapDataError)500 {"error":"Internal server error","code":"INTERNAL_ERROR"}plus[REST] Unhandled error: HookConditionError: ...400 {"error":"Hook 'qa_failclosed' could not evaluate its condition (runtime: int() type error: cannot convert to int) — operation aborted.","code":"VALIDATION_ERROR","object":"inquiry"}, no log lineRestServerPOST /data/:object/createMany(handleRouteError)500and the same log line400body, no log line/data(the throw leavesdispatch();@objectstack/hono's catch-all renders it throughdeclaredEnvelopeForThrow)declaredEnvelopeForThrowdeclines it, so the fallback arm answers500 INTERNAL_ERRORwith the hook's message400 {"success":false,"error":{"code":"VALIDATION_ERROR","message":"Hook 'qa_failclosed' could not evaluate ..."}}/data(dispatcher-plugin'serrorResponseBasereadsresolveThrownHttpError)500 INTERNAL_ERROR400 VALIDATION_ERRORPOST /data/:object/batchrow (toRowApiError){"code":"INTERNAL_ERROR","message":"The create of this record failed. The reason is in the server log."}{"code":"VALIDATION_ERROR","message":"Hook 'qa_failclosed' could not evaluate ...","httpStatus":400}In every row above the inquiry table held 0 rows after the request.
Why it happened:
HookConditionError(packages/objectql/src/hook-wrappers.ts) carries nostatusand nocode, on purpose. Its docblock rules that a fact on it reaches the wire through the ADR-0112 vocabulary as a decision. No boundary had decided, so the class matched nothing inclassifyDataErrorand nothing inresolveThrownHttpError, and each door fell to its unclassified 500.The fix: one recogniser, read by both rules
packages/types/src/hook-condition-refusal.ts(new).unevaluableHookConditionRefusal(err)recognises the class by its declarednameand its declaredreason: 'unevaluable', never by message text.@objectstack/typescannot import@objectstack/objectql. It answers{ status: 400, code: standardErrorCodeForHttpStatus(400) }. It is not exported from the package index, so the public surface does not move.resolveThrownHttpError(thrown-http-error.ts). The shape is a declaration of both halves there, as the validation shape already is:declaredStatus: 400,declaredCode: 'VALIDATION_ERROR'. This one change serves the dispatcher's exits, both Hono doors (whose declared-envelope gate needs a declared status and a registered code) and the batch rows.structuredCodeAnswer(data-error-classification.ts). A new arm sits beside its precedent, theVALIDATION_FAILEDarm. Both REST doors ask it first, so the single-record door and the bulk doors answer alike.The landing points widen the claim's surface in one place. The claim named
data-error-classification.tsanderror-response.ts. Measured, the runtime twin does not read the classification at all; it readsresolveThrownHttpError. Sothrown-http-error.ts, in the same package, carries the second reader, and the recogniser gets its own module.packages/rest/src/error-response.tsneeds no change: the log verdict already reads the declaration offresolveThrownHttpError(isDeclaredClientRefusal). Nothing underpackages/objectql/**orpackages/spec/**is touched.The code choice, and why it is not a new code
400: the nearest landed precedent. ADR-0058's 2026-08 addendum lists the hookcondition(hook 的condition求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775) beside the object validation predicate (Script validation rules are silently skipped when their predicate fails to evaluate — fail-open is the wrong direction for a validation #4649) as one family: an unevaluable write gate fails closed. Measured on the same door, a validation rule whose condition is the card's expression answers400(codeVALIDATION_FAILED,fields[0].constraint.reason: 'unevaluable'). Its sentence comes from the samecel-fault.tsas the hook's. The refusal turns on this record's values: another record can pass the same condition, and retrying the same request cannot.VALIDATION_ERROR, not the sibling'sVALIDATION_FAILED.VALIDATION_FAILED's contract is the per-fieldfields[]envelope that a form maps onto its inputs, and a hook's gate addresses no field.VALIDATION_ERRORis theStandardErrorCodemember that ADR-0112's status map names for a400whose producer declared no more specific code. The batch rows and the nested-envelope doors already derive it for an un-coded400refusal. No code is minted and no ledger row is added, soClause-②: noholds as the claim declared it.400: do not retry the same request, and show the message. The finer fact ("the author's expression faulted") already has a discriminator on the sibling,constraint.reason: 'unevaluable'. Measured on objectuicd4623d, no production file reads that discriminator, so no consumer branch exists that a new top-level code would serve. The existing member's meaning is unchanged, and the new module's docblock records why it covers this case.Scope: the
unevaluablereason onlyreason: 'uncompilable'(the condition never compiled) keeps its sanitised500and its[REST] Unhandled errorline, and it is pinned as a control. It does not turn on the request: every write to the object fails until an author fixes the metadata. The gate already logs it aterrorwhen the hook is bound. Its message ends withfix the expression:followed by the condition SOURCE, which no sibling refusal puts on the wire. The platform checklist records that the metadata door refuses such a condition at authoring time (422 INVALID_METADATA), so it reaches this gate only from hooks bound outside that door.What the client message names
The arm relays the producer's message, as the
VALIDATION_FAILEDarm relays its own. It names the hook, the CEL fault in one line and, when known, the key the condition read (the shareddescribeCelFaultdetail). It never names the condition source or a record value; the e2e pin asserts that neither the source norqa-fc-abcappears in the body. These are the facts the sibling validation-rule refusal already carries, by construction, because both come from one sentence builder.The log
The REST doors no longer print
[REST] Unhandled errorfor this refusal, and they print no other line. Precedent: AGENTS.md, "Degradation log levels": "a failure handed to the CALLER is not a degradation at all … Do not bolt alogger.erroronto such a site". It also followsisDeclaredClientRefusal(#23932), which reads a declaration off the thrown error rather than growing theisExpectedRouteErrorlist by a name.resolveThrownHttpErrornow reports this shape's status and code, so the verdict needs no edit. The runtime exits log only 5xx (logServerFault), so a400costs no line there either. The operator signal that remains is the uncompilable reason's bind-timeerrorline, which is unchanged. See the acceptance notes for the producer-side gap.Pins (each asserts status and code, not "it threw")
packages/rest/src/rest-hook-condition-unevaluable-refusal.test.ts, real engine and realRestServerhandlers:400with the exact body, the source and value are absent, the row is absent, and there is no[REST] Unhandled errorline;createManyanswers the same;403 PERMISSION_DENIED) answers byte for byte as before;500 INTERNAL_ERRORand exactly one[REST] Unhandled errorline. This is also the positive control for the log spy, underOS_REST_LOG=info.packages/runtime/src/domains/data-hook-condition-refusal.test.ts, the dispatcher/datadoor on thedata-write-request-gate.test.tsrig:declaredEnvelopeForThrow(imported, not restated) answers400 VALIDATION_ERROR,resolveThrownHttpErroranswers400 VALIDATION_ERROR, and the row is absent;403with an unchanged body;declaredEnvelopeForThrowand resolves to500.packages/types/src/hook-condition-refusal.test.ts: the recogniser, and agreement between the two rules that read it, on source.packages/rest/src/error-response-structured-arm-door-parity.test.ts: one more §1 case, so the single, bulk and route doors stay equal for this class. The arm is name-keyed, so the code-literal census there cannot see it.Pin sweep (repo-wide, one round)
I searched the repo for the wire answer of an unevaluable hook condition (
INTERNAL_ERRORor500paired with a hook condition,HookConditionError, andcould not evaluate its condition):packages/qa/dogfood/test/hook-after-condition-rollback.dogfood.test.ts: three assertions of{ status: 500, code: 'INTERNAL_ERROR' }for anafterInsertcondition that readspreviouson an insert now assert{ status: 400, code: 'VALIDATION_ERROR' }. The header sentence that called the refusal the sanitised 500 is corrected.docs/qa/platform-checklist/areas/records-forms.json,records-forms.object-hook-lifecycleacceptance[8] (revision 6 to 7): the verify text recorded the 500 against 18.0 round-2 sweep framework findings: a paid invoice line'samount/totalstays writable while its inputs are locked; an unevaluable hook condition answers 500 INTERNAL_ERROR;remote-tables?schema=never narrows on the SQL drivers #24619 row (b). It now states the coded refusal and scores a 500 as a FAIL, and the server-log evidence is dropped.content/docs/automation/hooks.mdx: one sentence now gives the wire answer of a refused write.packages/metadata-protocol/src/protocol.bulk-record-not-found.test.tsthrows a bareErrorcarrying the hook's text (not the class) and asserts that the text is absent;showcase-anonymous-deny-surfaces.dogfood.test.tsasserts that a message does not mention the class; the objectql unit suites pin the engine, not the wire.Ablation (one leg for the one changed behaviour)
The fix was committed first (
3ed86f196). The mutation went throughnode scripts/ablation-replace.mjsin WRAP mode, so its trap restored the file. The anchor wasif (e?.name !== 'HookConditionError' || ...and the replacement used the name'HookConditionError_ABLATED', so the recogniser matches nothing.6099c6874149tobe145e442da8.@objectstack/typeswas rebuilt, andnode scripts/ablation-dist-preflight.mjs @objectstack/types 'HookConditionError_ABLATED'exited 0 (marker indist/). The rest suites read types'dist/; the runtime suite aliases it to source.createMany, "expected 500 to be 400"), 2 passed (both controls);declaredEnvelopeForThrowansweredundefined), 2 passed (both controls).6099c6874149equals HEAD,git diff HEADis empty, and the marker count on disk is 0. After a rebuild, the dist preflight with--absentexited 0 on a clean tree. Restored leg: types 5/5, rest e2e 4/4, parity 66/66, runtime 3/3.Tests and gates (final head
910352e21)@objectstack/types:vitest run --project localgave 28 files / 764 passed, andtypecheckexited 0. These ran at3ed86f196; the one later commit touches only a rest test file.@objectstack/rest:pnpm --filter @objectstack/rest test(local project) gave 301 files, 6099 passed and 326 skipped, at3ed86f196.typecheck, includingcheck:test-typecheck, exited 0. The repo project'serror-response-structured-arm-door-parity.test.tsanderror-response-sandbox-arm-message.test.tsgave 2 files / 156 passed.rest-hook-condition-unevaluable-refusal.test.tsre-ran at910352e21: 5/5.@objectstack/runtime(targeted; the full suite is CI's): six data-door and error-envelope files gave 108 passed, and the repo project'serror-envelope.conformance.test.tsgave 53 passed.typecheck, includingcheck:test-typecheck, exited 0.@objectstack/metadata-protocol: 6 batch-row files, 94 passed;@objectstack/core: 4 import-runner files, 111 passed;@objectstack/plugin-hono-server: full suite, 34 files / 422 passed;@objectstack/hono: full suite, 11 files / 215 passed.hook-after-condition-rollback.dogfood.test.tsran against a booted stack and gave 5/5 with the flipped pins.@objectstack/dogfoodtypecheckexited 0.node scripts/pm/dispatch-gates.mjs --commandsderived 96 commands at910352e21, and all 96 exited 0 (exit codes captured before any pipe, after a fullturbo run build). The--ranreconciliation reads "96 derived famil(ies) accounted for — 96 run, 0 NOT-MEASURED".pnpm lint(eslint . --no-inline-config, the whole repo) at910352e21exited 0.origin/mainwas not merged in: the 12 commits ahead of the merge base touch none of the files this change reads or edits. The one overlap by file,metadata-protocol/src/protocol.ts, changes the exposure-gate read and not the batch-row seam. The merge queue rebuilds on currentmain.Acceptance notes
before*unevaluable condition leaves no operator line. The validation-rule sibling gets a WARN from its producer (rule-validator.ts: "predicate failed to evaluate … — write rejected"). Anafter*hook's fault is covered by the engine's warn-levelInsert operation failedline, seen in the dogfood run. AbeforeInserthook's fault raised no line in these runs: the hook wrapper logs nothing for the unevaluable reason, and the engine line did not appear. A producer-side line would belong inpackages/objectql(hook-wrappers.ts), which this dispatch holds off. Observation only; not filed./datadoor,@objectstack/hono's fallback arm answers the uncompilable reason's500with its message, which quotes the condition source. That is the standing fix(service-analytics,rest): analytics dimension 的源字段闸门 —— 不存在的 dimension 答 400 INVALID_FIELD,dataset 500 不再回显 SQL (#5520) #5667 posture ("whether an undeclared 5xx stays legible is a standing decision this arm does not reopen"), and this PR does not move it. The REST doors withhold the same message. Noted, not filed, because it is a ruled posture.Generated by Claude Code