Skip to content

Commit 1af2cea

Browse files
fix(runtime,types)!: a NOT NULL refusal on a drifted column answers RestServer's body at the dispatcher and actions doors (#26171)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X8KfVViUTX7B3bKxio5V96w)_ Fixes #26031 Clause-②: yes (widening: `@objectstack/types` adds the export `driverNotNullRefusalAnswer`) Metadata-protocol: none ## What was wrong #24768 (ruling A, PR #26026, merged as `48e84a90af`) has the engine translate a driver NOT NULL refusal on a field the object's field map holds into the record validator's own `ValidationError`, which every door already answers alike. A refusal on a column no field declares — drift, the field left the metadata and its NOT NULL column stayed in the table — is outside that ruling and still leaves the engine as the driver threw it, so the doors answered it three ways. Measured on `origin/main` `40bba68c` (which carries #26026), on a real better-sqlite3 store under `driver-sql` with the real engine and protocol: | door | answer on `main` | |:--|:--| | `RestServer` `POST /data/:object` | `400 VALIDATION_FAILED`, the column's sentence, its `required` field entry and the drift `hint` (`mapDataError`'s NOT NULL arm) | | dispatcher `POST /data/:object` | the raw `SqliteError` left `dispatch()`; the Hono exit declined it, so `500 INTERNAL_ERROR` | | `POST /actions/:object/:action` | `400 VALIDATION_ERROR`, the driver's text (`insert into [statement and bound values redacted] - NOT NULL constraint failed: drift_line_26031.legacy_code`) as `message`, and `declaredCode: 'SQLITE_CONSTRAINT_NOTNULL'` | ## The fix `@objectstack/types` exports `driverNotNullRefusalAnswer(error, object?)`: `classifyDataError`'s answer when its NOT NULL arm is the arm that answered, `undefined` for every other answer. The arm's answer is now built in one place and held in a `WeakSet` the question reads by identity, so recognition is not a second reading of the driver's text and not a shape probe on a body: every arm ahead of the NOT NULL one (a declared status, a sandboxed body's throw, a unique violation, an unknown column, the record validator's `ValidationError`) still decides first, and this answers exactly where `mapDataError` would. `mapDataError`'s own answers are byte-unchanged. Both runtime exits ask it and answer the arm's body in their own envelope — the sentence as `error.message`, `VALIDATION_FAILED` as `error.code`, and `fields`, `hint` and `object` in `error.details`: - `HttpDispatcher.dispatch()`'s catch, for the `/data/:object` routes only (`routeObjectFromPath`, the same read its `PERMISSION_DENIED` arm makes, so the body's object is the route's as `RestServer`'s is). Every other throw leaves exactly as before. - the actions door's catch, asked ahead of the fault predicate, so a driver class carrying no string `code` is still the refusal it is and never the sanitised 500. ⛔ No second copy of the recognition: neither door reads a driver code or a NOT NULL phrase. The declared-field answers #26026 pinned are unchanged, and the driver's text and code reach neither wire. ## `PATCH` cannot reach such a column A `PATCH` naming the dropped field is refused before any driver runs, as an undeclared field (`400 INVALID_FIELD`), on both `/data` doors alike; a `beforeUpdate` hook writing it is refused the same way (probed); an update that leaves the column alone keeps the row's stored non-null value. So the `PATCH` case in the new pin is a control, asserted on both doors and unchanged. ## Clause-② — the measurement the order asked for Declared `yes (widening)`, not `no (narrowing)`. By `clause2-line.mjs`'s own definitions the value answers 「本卡放宽接受集或扩大公开面吗」, and this diff does widen the published face: `@objectstack/types`' entry (`packages/types/src/index.ts:98` re-exports this module) gains the export `driverNotNullRefusalAnswer`. Nothing published is removed or narrowed — no export retires, no accept set shrinks, and no request that was served is now refused (every write in the measured class was already refused at all three doors; only the status, code and body move). The `(narrowing)` arm is therefore not owed: the two wire-answer changes make a refusal **more** informative (`500 INTERNAL_ERROR` → `400 VALIDATION_FAILED`, and `VALIDATION_ERROR` with driver prose → `VALIDATION_FAILED` with the column's field entry). A consumer branching on `error.code` at those two doors is still affected, which is what the `**BREAKING**` banner in the changeset states, with the ADR-0087 disposition `not-required (no-migration-prescription)`; AGENTS.md's level rule puts a non-protocol change at `patch`, which is what ships. `Metadata-protocol: none`, as expected: no spec key, authorable spelling, object definition, stored row or stored shape moves. ## Tests Red first, on `origin/main` `40bba68c`, `packages/runtime/src/dispatcher-not-null-drift-26031.test.ts`: 2 failed / 2 passed of 4 — the dispatcher `POST` (`expected { thrown: SqliteError } to deeply equal { returned: { status: 400, … } }`) and the actions door (`"code": "VALIDATION_ERROR"` against `"VALIDATION_FAILED"`) red, the `RestServer` reference and the `PATCH` control green. On the fix, 4 passed. Each case asserts the whole body with `toEqual`, and the two dispatcher-door bodies are compared against `RestServer`'s own answer read in the same run, so the `hint`'s words are never spelled here. `packages/types/src/data-error-classification.not-null-label.test.ts` adds 8 cases for the new export (22 passed): the arm's answer equals `mapDataError`'s for all six dialect shapes and with no object, and `undefined` for the record validator's `ValidationError`, a throw declaring its own status, a sandboxed throw and node-postgres' fields-only shape — each of those carrying the NOT NULL text or code, so the pin measures the arm and not the words. Neighbours green on the fix: `dispatcher-not-null-refusal-24768.test.ts`, `domains/actions-fault-vs-rejection.test.ts`, `dispatcher-unique-violation-wire-code.test.ts`, `rest/src/data-not-null-hint-20963.test.ts`, `rest/src/data-not-null-field-label-24715.test.ts`. Full package suites at `97265b90`: `@objectstack/types` 30 files / 800 tests, `@objectstack/runtime` 459 files / 8359 tests (19 skipped), `@objectstack/rest` 327 files / 6749 tests (344 skipped), all passed. **Ablation**, one line, on the behaviour the new pins assert: `driverNotNullRefusalAnswer`'s `return NOT_NULL_ARM_ANSWERS.has(answer) ? answer : undefined;` replaced with a marker expression that always answers `undefined` (`ablation-replace --hold`, injected marker `grep -c` = 1, the anchor text `grep -c` = 0), `@objectstack/types` rebuilt, `ablation-dist-preflight @objectstack/types ABLATE_26031` exit 0 (marker live in `dist/index.js` and `dist/index.mjs`). Red: the drift pins 2/4 failed (the two door cases), the types pins 7/22 failed. Restored: `ablation-replace --restore` reports `blob == HEAD (68e3d40)`, `git diff HEAD` empty, whole-tree `git status --porcelain` empty, rebuilt, `--absent` preflight exit 0, and both files green again (4/4 and 22/22). ## Gates Local subset, the `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` list on this diff (66 commands, each exit code captured before any pipe, reconciled with `--ran`): 65 exit 0. - `node scripts/check-plugin-teardown-shape.mjs --self-test` exit 3 on the first run — a shallow checkout cannot read its pinned positive control `621a487607`. After `git fetch origin 621a487`, exit 0 (48 cases pass). - `pnpm check:dual-build-cjs-loads` exit 3: `PREREQUISITE NOT MET`, 33 packages have no `dist/`, which is a property of this container, not of the diff. A full `pnpm build` was run and the gate re-read; see the re-run line in the report. `pnpm lint` is the repo-level scan CI owns; scoped runs on the touched packages are in the report. `test:repo` is CI's (`Test Core`). The families CI runs are cited from this PR's check-runs, not re-run locally. ## Acceptance notes - The doors #26026's contract review (`6412927808`, boundary flag 2) named as not driven on a real store — import rows, the public form submit, the generic `/batch` and MCP — reach this class through `mapDataError` or `thrownRefusalEnvelope` and are unchanged by this diff; no pin is added for them here. --- _Generated by [Claude Code](https://claude.ai/code/session_01W99jaevefxLsRd8BwgvkKv)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e6dfeb0 commit 1af2cea

6 files changed

Lines changed: 397 additions & 13 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/types': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(runtime,types)!: a NOT NULL refusal on a column no field declares answers `RestServer`'s `400 VALIDATION_FAILED` body at the dispatcher `/data` and actions doors too
7+
8+
Since #24768 the engine refuses a NOT NULL violation on a field the object declares as its own `ValidationError`, which every door answers alike. A column no field declares (drift: the field left the metadata and its NOT NULL column stayed in the table) still leaves the engine as the driver threw it, and the doors answered that refusal three ways. `RestServer`'s `POST /data/:object` answered `mapDataError`'s NOT NULL arm: `400 VALIDATION_FAILED`, the column's sentence, its `required` field entry and the drift `hint`. The runtime dispatcher's `POST /data/:object` let it reach the Hono exits, which answered `500 INTERNAL_ERROR`. The actions door answered `400 VALIDATION_ERROR` with the driver's text (the physical table and column) as the message and the driver's code as `declaredCode`.
9+
10+
Clause-②: yes (widening: `@objectstack/types` adds the export `driverNotNullRefusalAnswer`)
11+
Metadata-protocol: none
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the status, code and body two HTTP doors answer for one refusal, a driver NOT NULL refusal on an undeclared column: every write that was refused is still refused and every write that was served is still served. The remedy is on the consumer's side, reading `code` and `details`; never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
14+
15+
**BREAKING** (a wire-answer change), shipped as `patch`: no metadata protocol kind moves.
16+
17+
- **`@objectstack/types`.** New export `driverNotNullRefusalAnswer(error, object?)`: `mapDataError`'s NOT NULL arm answer (`{ status, body }`) when classification answers the error with that arm, `undefined` for every other answer. Every arm ahead of it (a declared status, a sandboxed body's throw, a unique violation, an unknown column, the record validator's `ValidationError`) still decides first. `mapDataError`'s own answers are unchanged.
18+
- **`@objectstack/runtime`.** The dispatcher's `/data/:object` routes answer that refusal themselves with `400`, `error.code: 'VALIDATION_FAILED'`, the column's sentence as `error.message`, and `fields`, `hint` and `object` in `error.details` (was a thrown `500 INTERNAL_ERROR`). The actions door answers the same body (was `400 VALIDATION_ERROR` with the driver's text and `declaredCode`). The driver's text and code no longer reach either wire.
19+
- A `PATCH` cannot reach such a column: naming it is refused before the driver as an undeclared field (`400 INVALID_FIELD`), on both `/data` doors alike, as before.
20+
- The answers for a field the object declares are unchanged.
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#26031] A database NOT NULL refusal on a column the object does NOT declare
5+
* (drift: the column stayed in the table after its field left the metadata)
6+
* answers one body on every door: `RestServer`'s, `400 VALIDATION_FAILED`, the
7+
* column named, and the drift `hint`.
8+
*
9+
* ## What was wrong
10+
*
11+
* #24768 (ruling A) moved a refusal on a DECLARED field into the engine, which
12+
* now refuses it as its own `ValidationError`. A column no field declares has
13+
* no field to name, so the engine lets the driver's error out, and each door
14+
* classified it its own way. Measured on the rig below, at `origin/main`
15+
* `40bba68c` (which carries #26026):
16+
*
17+
* - `RestServer` `POST /data/:object`: `mapDataError`'s NOT NULL arm,
18+
* `400 VALIDATION_FAILED` with the column sentence and the drift `hint`;
19+
* - the dispatcher's `POST /data/:object`: the raw `SqliteError` left
20+
* `dispatch()`, and the Hono exit (`declaredEnvelopeForThrow`) declined it,
21+
* so the door answered `500 INTERNAL_ERROR`;
22+
* - `POST /actions/:object/:action`, a handler whose engine insert leaves the
23+
* column out: `400 VALIDATION_ERROR`, the driver's text as the `message`
24+
* (the physical table and column) and `declaredCode:
25+
* 'SQLITE_CONSTRAINT_NOTNULL'`.
26+
*
27+
* Both dispatcher exits now ask the arm itself (`driverNotNullRefusalAnswer`,
28+
* `@objectstack/types`), so the body is `RestServer`'s, read off one
29+
* classification, in this transport's envelope: the sentence as `message`, the
30+
* code as `code`, and the arm's `fields`, `hint` and `object` in `details`.
31+
*
32+
* ## `PATCH` cannot reach the column
33+
*
34+
* A `PATCH` naming the dropped field is refused before any driver runs, as an
35+
* undeclared field (`400 INVALID_FIELD`), on both `/data` doors alike, and so
36+
* is a `beforeUpdate` hook writing it; an update that leaves the column alone
37+
* keeps the row's stored value, which is not null. So the `PATCH` case below is
38+
* the control: one answer on both doors, unchanged.
39+
*
40+
* ## The rig
41+
*
42+
* A REAL store: better-sqlite3 `:memory:` under `driver-sql`, the real
43+
* `ObjectQL` engine and protocol. The object is registered with the column,
44+
* the table is built, and the object is registered again without it: the
45+
* metadata the doors serve has drifted from the table. The dispatcher is
46+
* reached the way `@objectstack/hono`'s catch-all reaches it, `dispatch()`
47+
* over a real Fetch `Request`, with the identity step the one stubbed seam; a
48+
* throw out of `dispatch()` is rendered by the Hono exit's own recogniser.
49+
* `RestServer` is the same stack's REST door, its routes called directly.
50+
*
51+
* Every body is asserted whole. The `hint`'s words are `RestServer`'s, read
52+
* off its answer rather than spelled here.
53+
*/
54+
55+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
56+
import { ObjectQL } from '@objectstack/objectql';
57+
import { SqlDriver } from '@objectstack/driver-sql';
58+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
59+
import { declaredEnvelopeForThrow } from '@objectstack/plugin-hono-server';
60+
import { RestServer } from '@objectstack/rest';
61+
import { HttpDispatcher } from './http-dispatcher.js';
62+
import { installDefaultOrganization } from './default-organization.testkit.js';
63+
64+
const PREFIX = '/api/v1';
65+
/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */
66+
const TEST_PACKAGE_ID = 'objectstack-test';
67+
const OBJECT = 'drift_line_26031';
68+
69+
/** The metadata the table was built from: `legacy_code` is a NOT NULL column. */
70+
const BUILT = {
71+
name: OBJECT,
72+
label: 'Drift line',
73+
fields: {
74+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
75+
name: { name: 'name', type: 'text' as const, label: 'Name' },
76+
legacy_code: { name: 'legacy_code', type: 'text' as const, label: 'Legacy code', storage: { notNull: true } },
77+
},
78+
actions: [
79+
{ name: 'add_line', label: 'Add line', type: 'script', target: 'add_line' },
80+
],
81+
};
82+
83+
/** The metadata the doors serve afterwards: `legacy_code` is no longer declared. */
84+
const SERVED = (() => {
85+
const { legacy_code: _dropped, ...fields } = BUILT.fields;
86+
return { ...BUILT, fields };
87+
})();
88+
89+
const MEMBER = { userId: 'u_member', isSystem: false };
90+
91+
let engine: ObjectQL;
92+
let dispatcher: HttpDispatcher;
93+
let rest: RestServer;
94+
95+
afterAll(async () => {
96+
try { await engine?.destroy(); } catch { /* noop */ }
97+
});
98+
99+
beforeAll(async () => {
100+
engine = new ObjectQL();
101+
engine.registerDriver(new SqlDriver({
102+
client: 'better-sqlite3',
103+
connection: { filename: ':memory:' },
104+
useNullAsDefault: true,
105+
}), true);
106+
await engine.init();
107+
engine.registry.registerObject(BUILT as any, TEST_PACKAGE_ID);
108+
await engine.syncSchemas();
109+
// A real `single` install's Default Organization (ADR-0131 D9).
110+
await installDefaultOrganization(engine);
111+
await engine.insert(OBJECT, { id: 'e1', name: 'existing', legacy_code: 'L1' } as any);
112+
engine.registry.registerObject(SERVED as any, TEST_PACKAGE_ID);
113+
(engine as any).registerAction(OBJECT, 'add_line', async (ctx: any) => {
114+
await ctx.engine.insert(OBJECT, { id: 'a1', name: 'from the action' });
115+
return { written: true };
116+
});
117+
118+
const protocol = new ObjectStackProtocolImplementation(engine as any);
119+
const services: Record<string, unknown> = { objectql: engine, data: engine, protocol };
120+
const get = (n: string) => services[n] ?? null;
121+
const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) };
122+
dispatcher = new HttpDispatcher(kernel);
123+
(dispatcher as any).timedResolveExecutionContext = async () => ({ ...MEMBER });
124+
125+
const noop = () => {};
126+
const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
127+
rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any);
128+
(rest as any).resolveExecCtx = async () => ({ userId: MEMBER.userId });
129+
rest.registerRoutes();
130+
}, 60_000);
131+
132+
/**
133+
* A request as `@objectstack/hono`'s catch-all hands it to `dispatch()`: the
134+
* answer `dispatch()` returned, or, for a throw, what the Hono exit makes of it
135+
* (`undefined` there is its bare `500`).
136+
*/
137+
async function door(method: string, path: string, body: unknown) {
138+
const request = new Request(`http://localhost${PREFIX}${path}`, {
139+
method,
140+
headers: { 'content-type': 'application/json' },
141+
body: JSON.stringify(body),
142+
});
143+
try {
144+
const res: any = await dispatcher.dispatch(method, path, body, {}, { request } as any, PREFIX);
145+
return { returned: res?.response as { status: number; body: any } | undefined };
146+
} catch (thrown) {
147+
return { thrown, hono: declaredEnvelopeForThrow(thrown) };
148+
}
149+
}
150+
151+
/** A `RestServer` route, called as its host calls it. */
152+
async function restDoor(method: string, path: string, body: unknown, params: Record<string, string>) {
153+
const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path);
154+
expect(route, `${method} ${path}`).toBeDefined();
155+
const res: any = {
156+
write: () => true, end: () => {},
157+
header: () => res,
158+
status: (code: number) => { res._status = code; return res; },
159+
json: (json: any) => { res._json = json; return res; },
160+
};
161+
await route!.handler({ params, body, query: {}, headers: {} } as any, res);
162+
return { status: res._status ?? 200, body: res._json };
163+
}
164+
165+
/** `RestServer`'s answer for the drifted column, asserted whole. */
166+
async function restAnswer() {
167+
const answer = await restDoor('POST', `${PREFIX}/data/:object`, { id: 'r1', name: 'through rest' }, { object: OBJECT });
168+
expect(answer.status, JSON.stringify(answer.body)).toBe(400);
169+
expect(answer.body).toEqual({
170+
error: 'legacy_code is required',
171+
code: 'VALIDATION_FAILED',
172+
fields: [{ field: 'legacy_code', code: 'required', message: 'legacy_code is required' }],
173+
hint: expect.any(String),
174+
object: OBJECT,
175+
});
176+
return answer.body as Record<string, unknown>;
177+
}
178+
179+
/** The dispatcher envelope carrying `RestServer`'s body: the sentence, the code, the rest in `details`. */
180+
function dispatcherEnvelopeOf(restBody: Record<string, unknown>) {
181+
const { error: message, code, ...details } = restBody;
182+
return { success: false, error: { code, message, httpStatus: 400, details } };
183+
}
184+
185+
function expectNoDriverText(body: unknown) {
186+
expect(JSON.stringify(body)).not.toMatch(/constraint failed|SQLITE_|insert into/);
187+
}
188+
189+
const rowCount = async (id: string) => (await engine.find(OBJECT, { where: { id } })).length;
190+
191+
describe('[#26031] a NOT NULL refusal on a drifted column answers RestServer\'s body on every door', () => {
192+
it('`RestServer` `POST /data/:object`: the arm\'s body, the reference', async () => {
193+
const body = await restAnswer();
194+
expectNoDriverText(body);
195+
expect(await rowCount('r1')).toBe(0);
196+
});
197+
198+
it('the dispatcher `POST /data/:object`: the same body, returned, not a thrown 500', async () => {
199+
const reference = await restAnswer();
200+
const answer = await door('POST', `/data/${OBJECT}`, { id: 'n1', name: 'through the dispatcher' });
201+
expect(answer, JSON.stringify(answer)).toEqual({
202+
returned: { status: 400, body: dispatcherEnvelopeOf(reference) },
203+
});
204+
expectNoDriverText(answer);
205+
expect(await rowCount('n1')).toBe(0);
206+
});
207+
208+
it('`POST /actions/:object/:action`, a handler whose insert leaves the column out: the same body', async () => {
209+
const reference = await restAnswer();
210+
const answer = await door('POST', `/actions/${OBJECT}/add_line/e1`, {});
211+
expect(answer, JSON.stringify(answer)).toEqual({
212+
returned: { status: 400, body: dispatcherEnvelopeOf(reference) },
213+
});
214+
expectNoDriverText(answer);
215+
expect(await rowCount('a1')).toBe(0);
216+
});
217+
218+
it('the control: a `PATCH` naming the dropped field is refused before the driver, alike on both `/data` doors', async () => {
219+
const viaRest = await restDoor('PATCH', `${PREFIX}/data/:object/:id`, { legacy_code: null }, { object: OBJECT, id: 'e1' });
220+
expect(viaRest).toEqual({
221+
status: 400,
222+
body: { error: `Unknown field 'legacy_code' on object '${OBJECT}'`, code: 'INVALID_FIELD', field: 'legacy_code', object: OBJECT },
223+
});
224+
const viaDispatcher = await door('PATCH', `/data/${OBJECT}/e1`, { legacy_code: null });
225+
expect((viaDispatcher as { hono?: unknown }).hono).toEqual({
226+
status: 400,
227+
body: {
228+
success: false,
229+
error: {
230+
code: 'INVALID_FIELD',
231+
message: `Unknown field 'legacy_code' on object '${OBJECT}'`,
232+
details: { field: 'legacy_code', object: OBJECT },
233+
},
234+
},
235+
});
236+
const stored = await engine.findOne(OBJECT, { where: { id: 'e1' } }) as any;
237+
expect(stored?.name).toBe('existing');
238+
});
239+
});

‎packages/runtime/src/domains/actions.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ import {
6565
// generic replacement text — the same two `@objectstack/rest` composes into
6666
// `isScriptFaultMessage` / `UNCLASSIFIED_FAULT`, so this door's crash reading
6767
// cannot drift from the `/data` door's. ⛔ Never re-inline either.
68-
import { isNativeErrorName, INTERNAL_ERROR_MESSAGE, sandboxBusinessMessage } from '@objectstack/types';
68+
import { isNativeErrorName, INTERNAL_ERROR_MESSAGE, sandboxBusinessMessage, driverNotNullRefusalAnswer } from '@objectstack/types';
6969
// [ADR-0029 D9.6] The ONE "does a code package ship this?" reading, which the
7070
// activation write below asks to name the ledger row's package.
7171
import { isCodeArtifactBody, isTenantAuthored } from '@objectstack/metadata-core';
@@ -1019,6 +1019,24 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string
10191019
return { handled: true, response: deps.errorFromThrown(err, 500) };
10201020
}
10211021

1022+
// [#26031] A driver's NOT NULL refusal that the engine let out (a
1023+
// column no field declares, i.e. drift) is the caller's write refused,
1024+
// and `RestServer` answers it `400 VALIDATION_FAILED` with the column's
1025+
// `required` entry and the drift hint. This door relayed it through the
1026+
// terminal below instead: the driver's text as the message (the
1027+
// physical table and column), the status-derived `VALIDATION_ERROR`,
1028+
// and the driver's code as `declaredCode`. The body is now the
1029+
// classification's own NOT NULL arm (`driverNotNullRefusalAnswer`),
1030+
// the one the dispatcher's `/data` exit answers too, in this
1031+
// transport's envelope; nothing here reads the driver's text. Asked
1032+
// before the fault predicate so a driver class with no string `code`
1033+
// is still the refusal it is, never the sanitised 500.
1034+
const notNullRefusal = driverNotNullRefusalAnswer(err, objectName);
1035+
if (notNullRefusal) {
1036+
const { error: refusal, ...details } = notNullRefusal.body;
1037+
return { handled: true, response: deps.error(String(refusal), notNullRefusal.status, details) };
1038+
}
1039+
10221040
// [#3913 follow-up] …and it does not cover an UNEXPECTED FAULT either.
10231041
// "A failed action is a normal outcome" is a statement about the action
10241042
// REJECTING — a business rule saying no. A `TypeError` in a handler, a

‎packages/runtime/src/http-dispatcher.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
AuthzStoreUnavailableError,
1818
type AuthGate,
1919
} from '@objectstack/core';
20-
import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, demotedDeclaredCode, declaredUserMessage, inProcessSessionReadInput, isEngineDuplicateRecordEnvelope, structuredCodeAnswer } from '@objectstack/types';
20+
import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, demotedDeclaredCode, declaredUserMessage, inProcessSessionReadInput, isEngineDuplicateRecordEnvelope, structuredCodeAnswer, driverNotNullRefusalAnswer } from '@objectstack/types';
2121
import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability';
2222
import { CoreServiceName, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system';
2323
import type { AuthSessionApi, IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts';
@@ -71,6 +71,7 @@ import {
7171
import {
7272
permissionDeniedErrorDetails,
7373
describeDeniedDiagnostics,
74+
routeObjectFromPath,
7475
} from './security/permission-denied-envelope.js';
7576

7677
// randomUUID moved to ./domains/auth.ts with its only consumer (D11③ PR-7).
@@ -3167,6 +3168,24 @@ export class HttpDispatcher {
31673168
),
31683169
};
31693170
}
3171+
// [#26031] A driver's NOT NULL refusal that the engine let out (a
3172+
// column no field declares, i.e. drift) is answered HERE, with
3173+
// `RestServer`'s body for it. Thrown on, it reached the Hono exits,
3174+
// which serve only a throw that declares its status and code, and
3175+
// `dispatcher-plugin`'s, which reads the validation shape: both
3176+
// answered `500` where `RestServer` answers
3177+
// `400 VALIDATION_FAILED`. The body is the classification's own
3178+
// NOT NULL arm (`driverNotNullRefusalAnswer`), never a reading of
3179+
// the driver's text here, and its object is the route's, as
3180+
// `RestServer`'s is. Only the `/data/:object` routes ask: they are
3181+
// the routes `RestServer` answers this arm for, and every other
3182+
// throw leaves exactly as before.
3183+
const routeObject = routeObjectFromPath(cleanPath);
3184+
const notNullRefusal = routeObject !== undefined ? driverNotNullRefusalAnswer(e, routeObject) : undefined;
3185+
if (notNullRefusal) {
3186+
const { error: message, ...details } = notNullRefusal.body;
3187+
return { handled: true, response: this.error(String(message), notNullRefusal.status, details) };
3188+
}
31703189
throw e;
31713190
}
31723191
}

0 commit comments

Comments
 (0)