Skip to content

Commit 3ed86f1

Browse files
committed
fix(types): an unevaluable hook condition's refusal answers 400 VALIDATION_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
1 parent eadea57 commit 3ed86f1

11 files changed

Lines changed: 641 additions & 15 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/types': patch
3+
---
4+
5+
fix(types): a write an authored hook condition refused because it could not be evaluated answers a coded `400 VALIDATION_ERROR`, not the unclassified `500 INTERNAL_ERROR`
6+
7+
Clause-②: no (the write is refused before and after, with nothing stored; only the refusal's status and code move, onto the published `StandardErrorCode` member `VALIDATION_ERROR`; no accept set moves and no code is added)
8+
9+
- **What changed on the wire.** A `HookConditionError` whose condition compiled and then faulted on the record it judged (`reason: 'unevaluable'`) is now a refusal at every data door: `400`, code `VALIDATION_ERROR`, and the hook's own message, which names the hook and the CEL fault and never the condition source or a record value. Before, it matched nothing and was answered `500 INTERNAL_ERROR` — on `@objectstack/rest`'s doors with a `[REST] Unhandled error` line, and on the runtime dispatcher's `/data` door (through `@objectstack/hono`'s catch-all) with the hook's message at status 500. A `/batch` row for the same throw now reads `VALIDATION_ERROR` with `httpStatus: 400` and the hook's message, where it read `INTERNAL_ERROR` with a withheld message.
10+
- **Why `400` and `VALIDATION_ERROR`.** The status is the one a validation rule that cannot be evaluated answers, and the two share one message builder. The code is the catalog member `400` names, because a hook's gate addresses no field and the per-field `VALIDATION_FAILED` envelope does not fit it.
11+
- **The log.** The refusal is handed to the caller, so `@objectstack/rest` no longer prints `[REST] Unhandled error` for it. Its log verdict reads the declaration from `resolveThrownHttpError`, as it does for any declared client refusal.
12+
- **Unchanged.** A condition that never compiled (`reason: 'uncompilable'`) keeps its sanitised `500` and its log line. That fault does not depend on the request, and its message quotes the condition source. A hook that refuses on purpose answers exactly as before.

‎content/docs/automation/hooks.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,10 @@ condition" carry *opposite* risks depending on the hook: swallowed into a `befor
115115
guard it silently lets the write through; swallowed into an audit hook it silently
116116
drops the record. They are now distinct outcomes, and the second **fails the write**.
117117
Hooks that have been getting by on that skip will start failing — that is how you
118-
find out they were never enforcing anything.
118+
find out they were never enforcing anything. Over the API the refused write answers
119+
`400` with the code `VALIDATION_ERROR`, and its message names the hook and the fault,
120+
never the condition's source — the same status a validation rule that cannot be
121+
evaluated answers.
119122

120123
**The condition reads the record, not the payload (#4770).** It used to evaluate
121124
against `ctx.input.data` — only the fields the current write happened to carry — so

‎docs/qa/platform-checklist/areas/records-forms.json‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3337,7 +3337,7 @@
33373337
"title": "Object lifecycle hooks fire on the write path with the right timing, gate, async and error semantics — driven over /api/v1/data/*, oracled by the record effect and the log line",
33383338
"since": "v15",
33393339
"status": "active",
3340-
"revision": 6,
3340+
"revision": 7,
33413341
"priority": "P1",
33423342
"surface": "mixed",
33433343
"personas": [
@@ -3442,8 +3442,8 @@
34423442
{
34433443
"clause": "a condition that cannot be evaluated FAILS CLOSED (#4775): the write is ABORTED, not silently skipped — and it is NOT softened by onError:'log' nor fire-and-forgotten, because the condition gate runs OUTSIDE the async/retry/onError wrappers",
34443444
"oracle": "api",
3445-
"verify": "scratch hook whose condition COMPILES but cannot be evaluated at runtime (e.g. int(record.name) > 0, written to a row whose name is not numeric); a write to that object is refused and the row does not land (hook-wrappers.ts: conditionFn throws outside runWithErrorPolicy). The 18.0 wire envelope is a generic 500 {code: 'INTERNAL_ERROR'}, with the class only in the server log ('[REST] Unhandled error: HookConditionError …') — #24619 row (b), open, tracks the coded refusal it should be. Until that lands score the clause on the refusal, the absent row and the log line, and record the 500 against #24619 rather than as a new finding. An UNCOMPILABLE condition never reaches this gate: it is refused at authoring (PUT /api/v1/meta/hook → 422 INVALID_METADATA, expression-invalid), so it cannot stand in for the runtime fail-closed probe",
3446-
"evidence": "the refusal (status + body) + the HookConditionError server-log line + a post-attempt count showing no row"
3445+
"verify": "scratch hook whose condition COMPILES but cannot be evaluated at runtime (e.g. int(record.name) > 0, written to a row whose name is not numeric); a write to that object is refused and the row does not land (hook-wrappers.ts: conditionFn throws outside runWithErrorPolicy). The wire envelope is a coded refusal: 400 {code: 'VALIDATION_ERROR'}, whose error text names the hook and the CEL fault (\"Hook 'NAME' could not evaluate its condition (FAULT) — operation aborted.\") and never the condition source, and no '[REST] Unhandled error' line is logged for it (#24662 retired the generic 500 INTERNAL_ERROR that #24619 row (b) recorded). A 500 INTERNAL_ERROR here is a FAIL. An UNCOMPILABLE condition never reaches this gate: it is refused at authoring (PUT /api/v1/meta/hook → 422 INVALID_METADATA, expression-invalid), so it cannot stand in for the runtime fail-closed probe",
3446+
"evidence": "the refusal (status 400 + code VALIDATION_ERROR + the hook named in its error text) + a post-attempt count showing no row"
34473447
},
34483448
{
34493449
"clause": "two hooks on the SAME object+event run lowest-priority-first: scratch hooks at priority 10 and 90 log in that order on one write (hook-binder passes priority to engine.registerHook; the engine orders by it, lower first)",
@@ -3516,6 +3516,12 @@
35163516
"date": "2026-10-19",
35173517
"change": "steps[0], negative[2], fixtures.requires[3], acceptance[8] (verify, evidence) and automated.ref corrected from wave anchor #24579 round 2 part 1 (6272032736) and QA runs #24598 / #24616 (ba4afcc2), re-read at objectstack f89d29e382. (1) The hook list route is GET /api/v1/meta/hook; /meta/types/hook answers 404. (2) acceptance[8]'s refusal answers 500 INTERNAL_ERROR with the class in the server log only; #24619 row (b) (open) tracks the coded refusal, so the clause states the shipped envelope and scores against #24619. (3) The ref named source files (hook-wrappers.ts, hook-binder.ts) no resolver can run; it now names four test files, each measured at base under os-verify-lock (83 passed), mapped to the clause whose mechanism it pins. (4) The run-proven scratch-hook recipe is recorded in requires[3]. Part 1's note on a `$select` in the steps does not hold: no step of this item spells `$select` (0 hits in records-forms.json)",
35183518
"ref": "#24671"
3519+
},
3520+
{
3521+
"revision": 7,
3522+
"date": "2026-10-19",
3523+
"change": "acceptance[8] (verify, evidence) follow the shipped envelope. An unevaluable hook condition's refusal is now a coded 400 VALIDATION_ERROR whose error text names the hook and the CEL fault, and no '[REST] Unhandled error' line is logged for it (#24662, split from #24619 row (b)). The 500 INTERNAL_ERROR that revision 6 recorded is retired and is now a FAIL; the server-log evidence goes with it. The clause is unchanged: the write is still refused and the row still absent",
3524+
"ref": "#24662"
35193525
}
35203526
]
35213527
},

‎packages/qa/dogfood/test/hook-after-condition-rollback.dogfood.test.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
// its handler throws; an `abort` hook with the same unbindable condition is
1414
// rolled back as #22782 leaves it. One more: an `async: true` hook whose
1515
// condition evaluates now starts inside the door's unit and writes after it
16-
// commits — its late write must still land (#22855). The refusal is REST's
17-
// sanitised `500 INTERNAL_ERROR` on both sides of the fix; what this file pins
18-
// is what is STORED.
16+
// commits — its late write must still land (#22855). What this file pins is
17+
// what is STORED, and the refusal's status and code: since #24662 an
18+
// unevaluable condition is REST's coded `400 VALIDATION_ERROR` (it was the
19+
// unclassified `500 INTERNAL_ERROR` on both sides of #22856's fix).
1920

2021
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2122
import { bootStack, type VerifyStack } from '@objectstack/verify';
@@ -72,13 +73,13 @@ describe('an after* hook whose condition cannot be evaluated rolls the write bac
7273

7374
it('onError: \'log\' — REST createData is refused and the row is absent', async () => {
7475
const name = uniq('c');
75-
expect(await refusal(await create(LOGGED, name))).toEqual({ status: 500, code: 'INTERNAL_ERROR' });
76+
expect(await refusal(await create(LOGGED, name))).toEqual({ status: 400, code: 'VALIDATION_ERROR' });
7677
expect(await stored(LOGGED, name)).toBe(0);
7778
});
7879

7980
it('async: true — the condition is evaluated before the hook detaches: refused, the row absent', async () => {
8081
const name = uniq('c');
81-
expect(await refusal(await create(DETACHED, name))).toEqual({ status: 500, code: 'INTERNAL_ERROR' });
82+
expect(await refusal(await create(DETACHED, name))).toEqual({ status: 400, code: 'VALIDATION_ERROR' });
8283
expect(await stored(DETACHED, name)).toBe(0);
8384
});
8485

@@ -90,7 +91,7 @@ describe('an after* hook whose condition cannot be evaluated rolls the write bac
9091

9192
it('CONTROL an abort hook with the same condition: refused, the row absent — as #22782 leaves it', async () => {
9293
const name = uniq('c');
93-
expect(await refusal(await create(ABORTING, name))).toEqual({ status: 500, code: 'INTERNAL_ERROR' });
94+
expect(await refusal(await create(ABORTING, name))).toEqual({ status: 400, code: 'VALIDATION_ERROR' });
9495
expect(await stored(ABORTING, name)).toBe(0);
9596
});
9697

‎packages/rest/src/error-response-structured-arm-door-parity.test.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ import { describe, it, expect } from 'vitest';
7575
import { readFileSync } from 'node:fs';
7676
import { dirname, resolve } from 'node:path';
7777
import { fileURLToPath } from 'node:url';
78-
import { DuplicateRecordError } from '@objectstack/objectql';
78+
import { DuplicateRecordError, HookConditionError } from '@objectstack/objectql';
7979
import { ConcurrentUpdateError } from '@objectstack/metadata-protocol';
8080
import {
8181
mapDataError,
@@ -436,6 +436,32 @@ const CASES: readonly Case[] = [
436436
},
437437
},
438438
},
439+
{
440+
// [#24662] An arm keyed on the CLASS (`name`) and its declared
441+
// `reason`, so the code-literal census below cannot see it; listed
442+
// here so the three doors are held together for it anyway. Its
443+
// producer declares no `status` either.
444+
covers: ['HookConditionError'],
445+
what: "objectql's HookConditionError, reason 'unevaluable' — the condition compiled and faulted on this record",
446+
error: () => new HookConditionError(
447+
"Hook 'qa_failclosed' could not evaluate its condition (runtime: int() type error: cannot convert to int) — operation aborted.",
448+
{
449+
hook: 'qa_failclosed', object: 'inquiry', event: 'beforeInsert',
450+
condition: "record.name.startsWith('qa-fc') && int(record.name) > 0",
451+
reason: 'unevaluable', fault: 'runtime: int() type error: cannot convert to int',
452+
},
453+
),
454+
object: 'inquiry',
455+
restored: [],
456+
expect: {
457+
status: 400,
458+
body: {
459+
error: "Hook 'qa_failclosed' could not evaluate its condition (runtime: int() type error: cannot convert to int) — operation aborted.",
460+
code: 'VALIDATION_ERROR',
461+
object: 'inquiry',
462+
},
463+
},
464+
},
439465
{
440466
// The third control: a 5xx ARM. Its producer declares no `status`
441467
// either, so both doors reach it today; §4 pins that it can never

0 commit comments

Comments
 (0)