Skip to content

Commit 8bc7836

Browse files
fix(types): the data door answers the sandbox's own fault as a fault, not as an unknown object (#22868) (#22882)
Fixes #22868 Clause-②: no ## What was wrong The QuickJS runner raises some `SandboxError`s itself, with no `innerMessage`: a budget, a denied capability, an unavailable `ctx.api`, a missing `ctx.api` method, a failed install or marshal. Two of those texts name the object the hook body addressed: - the `ctx.api.object(...)` capability gate (`quickjs-runner.ts:1173`, `throwSandboxFault`): `capability 'api.read' not granted to hook 'H' (called ctx.api.object('OBJ').find)`; - the missing-method arm (`quickjs-runner.ts:1199`): `ctx.api.object('OBJ').find not implemented`. `classifyDataError`'s unknown-object limb answers `404 OBJECT_NOT_FOUND` ("Object 'OBJ' is not registered") for a message holding the quoted requested object and `not`. So when the body addressed the request's own object, `mapDataError`, and every door that reaches it (`POST /api/v1/data/:object`, `resolveErrorResponse`'s fall-through, the import rows), answered a sandbox fault as "this object does not exist". ## The fix: one statement in `packages/types/src/data-error-classification.ts` `classifyDataError` asks `isSandboxOwnFault` (PR #22867) at its top, beside the `isSandboxCrash` terminal, and answers `UNCLASSIFIED_FAULT()`: `500`, `code: INTERNAL_ERROR`, `error: Internal server error`. No second rule: the predicate reads the producer's identity (`name === 'SandboxError'` and no `innerMessage`), never the text. The `isSandboxOwnFault` docblock's "mapDataError asks nothing here" paragraph now says that it does. ### Where it is asked, measured `mapDataError(SandboxError(text), 'mz_locked')` on the source through `tsx`, with the fix line ablated (`scripts/ablation-replace.mjs`, restored, blob == HEAD) and with the fix: | runner text | fix ablated | with the fix | |---|---|---| | capability gate, requested object | 404 OBJECT_NOT_FOUND | 500 INTERNAL_ERROR | | missing method, requested object | 404 OBJECT_NOT_FOUND | 500 INTERNAL_ERROR | | capability gate, another object | 500 INTERNAL_ERROR | 500 INTERNAL_ERROR | | capability gate, body-chosen object name `no such table` | 500 DATABASE_ERROR | 500 INTERNAL_ERROR | | capability gate, body-chosen object name `unknown object` | 404 OBJECT_NOT_FOUND | 500 INTERNAL_ERROR | | capability gate, body-chosen object name `has no column named x` | 400 INVALID_FIELD | 500 INTERNAL_ERROR | | `ctx.title` gate, body-chosen field `unique constraint` | 500 DATABASE_ERROR | 500 INTERNAL_ERROR | | `capability 'log' not granted to hook 'g'`, request object named `log` | 404 OBJECT_NOT_FOUND | 500 INTERNAL_ERROR | | CPU budget | 500 INTERNAL_ERROR | 500 INTERNAL_ERROR | | nested crash relayed as a fault (`hook 'g' threw: TypeError: boom`) | 500 INTERNAL_ERROR | 500 INTERNAL_ERROR | The runner interpolates the body's own arguments (`ctx.api.object(name)`, `ctx.title(field)`) into its texts, so the missing-relation, `unknown object`, missing-column and leak limbs can each misread one too. An exclusion beside the substring limb alone would leave them. Asked at the top, the answer is the terminal the other runner faults already reached, so only misread answers move. Own faults carry no `code` or `status`, so the code-gated arms between the top and the message limbs never matched them. ## The pin: the producer enumeration, not the REST write-route ledger The triage named the ledger #22764 rides (`packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts`) as the family's enumeration. Measured, these two texts cannot live there inside this card: - I added the two texts to its §6 `SANDBOX_FAULTS` on the fixed tree: **46 failed, 229 passed**. The 21 rows that answer through the classification passed. **23 hand-built rows** relay the runner's text verbatim: analytics dataset query, security explain, record shares (2), sharing rules (3), suggested bindings (2), approvals (9), package publish, external datasources (4). For example: `Received: "capability 'api.read' not granted to hook 'mz_guard' (called ctx.api.object('mz_locked').find)"`. Their `sandboxFaultAnswer` (`packages/rest/src/error-response.ts`) answers only for a sandbox ORIGIN (`innerMessage` set), so an own fault falls to each family's own terminal. That is a separate defect in `packages/rest`, outside this claim's file surface. It is reported for filing, not fixed here, and the probe was reverted. - This member does fit in `packages/core/src/utils/import-runner-sandbox-fault-row.test.ts` (PR #22867). That file parses `quickjs-runner.ts` with the TypeScript compiler and renders every own-fault site. It holds each site to `mapDataError`'s `500 INTERNAL_ERROR` body and holds the import row to that sentence. Its sample object was deliberately NOT the import's object, and its header called this door defect out of scope. This PR makes the sample the requested object, so the two sites that name it are pinned at `500` from the producer. It also adds: - a census floor: at least one capability-denial site and one missing-method site name the requested object (no wording pinned); - CONTROL: a real SQLite or Postgres unknown-object text on the requested object still answers `404 OBJECT_NOT_FOUND`; - CONTROL: the same object-naming texts on a plain `Error` (not the sandbox) keep the heuristic's `404`, which proves the door asks the producer, not the words. The `@objectstack/core` suite aliases `@objectstack/types` to its source (`packages/core/vitest.config.ts`), so this pin reads the fixed classification directly, with no `dist/` in between. ## Evidence, at HEAD `31b25500d1` - Reverse verification, with the fix committed first (`d457a70fbd`): `node scripts/ablation-replace.mjs --file packages/types/src/data-error-classification.ts --anchor " if (isSandboxOwnFault(error)) return UNCLASSIFIED_FAULT();" --replacement " /* ABLATED-22868 */" -- pnpm --filter @objectstack/core exec vitest run --maxWorkers=2 src/utils/import-runner-sandbox-fault-row.test.ts`. The mutation landed (anchor 1 to 0). Result: **2 failed, 26 passed**, exactly `quickjs-runner.ts:1173 throwSandboxFault(…)` and `quickjs-runner.ts:1199 new SandboxError(…)`, both `expected 404 to be 500`. Every control and every other site stayed green. After the restore, blob == HEAD and `git diff HEAD` is empty. With the fix: 28 of 28. - `pnpm --filter @objectstack/types test`: 27 files, 754 passed. `pnpm --filter @objectstack/types typecheck`: exit 0. - `pnpm --filter @objectstack/core exec vitest run --maxWorkers=2 src/utils/import-runner-sandbox-fault-row.test.ts src/utils/import-runner-sandbox-refusal-row.test.ts`: 2 files, 43 passed. `pnpm --filter @objectstack/core typecheck`: exit 0. The test file is in `tsconfig.test.json`'s program (`--listFiles` count 1) and has no errors. - `@objectstack/rest`, against the rebuilt `@objectstack/types` dist: 21 files that exercise the sandbox and unknown-object answers, 938 passed. They include `rest-unknown-object-heuristic`, `rest-hook-script-fault-envelope`, the unchanged write-route ledger and `rest.test.ts`. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived 65. All 65 ran: 64 exit 0, and 1 NOT MEASURED. `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET: it needs a whole-workspace build, and 44 packages had no `dist/`. Declared narrow substitute: `@objectstack/types`' CJS and ESM `dist` both load and answer the fixed `500`. `--ran` reconciliation: 65 accounted, 0 UNRUN. - Lint, as a proven narrowing: `eslint --no-inline-config --format json` over the two touched TS files reports 2 files, 0 errors, 0 warnings. There is no "file ignored" warning, so both files are in the config's `packages/**/*.{ts,…}` population. The config never enables type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict in an untouched file. The `.md` changeset is outside eslint's population. ## Public surface The one code line is a statement inside the non-exported `classifyDataError`. No declaration moved, so `@objectstack/types`' type surface is unchanged: the only non-comment changed line in `packages/types/src` is the new `if`. The runtime answer of `mapDataError` moves for an error named `SandboxError` with no `innerMessage`. Changeset: `@objectstack/types` `patch`. ## Acceptance notes - The REST hand-built write routes that relay the runner's own-fault text (above) are reported to the dispatching seat for filing, with the probe. They are not addressed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b1d701b commit 8bc7836

3 files changed

Lines changed: 84 additions & 14 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/types': patch
3+
---
4+
5+
fix(types): the data door answers the sandbox's own fault as a fault, not as "this object does not exist" (#22868)
6+
7+
Clause-②: no
8+
9+
**What was wrong.** The QuickJS runner raises some `SandboxError`s itself. These carry no `innerMessage`. Two of them name the object the hook body addressed: a denied capability on `ctx.api.object(...)` (`capability 'api.read' not granted to hook 'guard' (called ctx.api.object('account').find)`) and a `ctx.api` method the host does not implement (`ctx.api.object('account').find not implemented`). When that object was the one the request named, `mapDataError` read the quoted object name plus the word `not` as an unknown object. `POST /api/v1/data/account` then answered `404 OBJECT_NOT_FOUND` with `Object 'account' is not registered`. Every other runner fault already answered `500 INTERNAL_ERROR`, which is what the runner's contract says these errors are.
10+
11+
**What changes.** `mapDataError` checks `isSandboxOwnFault` first, next to the existing check for a sandboxed body that crashed. An error the sandbox raised itself now answers `500 INTERNAL_ERROR` with `Internal server error`, whatever its text names. The runner's text goes to the server log only. The sync `/import` row and the `/import/jobs` stored row read that same sentence.
12+
13+
**Unchanged.** A driver's unknown-object text (`no such table: account`, `relation "account" does not exist`) still answers `404 OBJECT_NOT_FOUND`. So does the same wording thrown by anything other than the sandbox: the check reads the error's producer, not its words. A hook body's refusal keeps its sentence and status, and a crash keeps its `500`.

‎packages/core/src/utils/import-runner-sandbox-fault-row.test.ts‎

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,13 @@
4848
* not raise. `import-runner-sandbox-refusal-row.test.ts` §4 is the driver-text
4949
* control #22718's ablation B pinned, and it is not edited here.
5050
*
51-
* The sample object name in a rendered message is deliberately NOT the
52-
* import's object. `mapDataError`'s unknown-object heuristic answers `404
53-
* OBJECT_NOT_FOUND` for a text that names the requested object and contains
54-
* `not`, which a capability denial on that object does. That is a door
55-
* defect outside this card; the row does not copy a 4xx sentence.
51+
* [#22868] The sample object name in a rendered message IS the import's
52+
* object. Two sites name it — the `ctx.api.object(…)` capability gate and the
53+
* missing-method arm — and `mapDataError`'s unknown-object heuristic answered
54+
* those `404 OBJECT_NOT_FOUND` (a quoted requested-object name plus `not`)
55+
* until the door asked `isSandboxOwnFault` first. So §1 holds each site that
56+
* names the requested object to the door's `500`, and §3 holds the controls
57+
* that keep the heuristic's other answers where they were.
5658
*/
5759

5860
import { readFileSync } from 'node:fs';
@@ -68,10 +70,12 @@ const HERE = dirname(fileURLToPath(import.meta.url));
6870
const RUNNER = resolve(HERE, '../../../runtime/src/sandbox/quickjs-runner.ts');
6971
const SOURCE = ts.createSourceFile(RUNNER, readFileSync(RUNNER, 'utf8'), ts.ScriptTarget.Latest, true);
7072

73+
const OBJECT = 'mz_locked';
74+
7175
/**
7276
* One sample per interpolation the runner's fault messages use. A message
7377
* naming an expression not listed here is unrenderable, and §1's census says
74-
* which. `objectName` is not the import's object (see the header).
78+
* which. `objectName` is the import's object (see the header).
7579
*/
7680
const SAMPLES: Readonly<Record<string, string>> = {
7781
'args.origin.kind': 'hook',
@@ -82,7 +86,7 @@ const SAMPLES: Readonly<Record<string, string>> = {
8286
wallCeiling: '30000',
8387
pumps: '3',
8488
'formatErr(msg)': 'SyntaxError: unexpected token',
85-
objectName: 'mz_other',
89+
objectName: OBJECT,
8690
method: 'find',
8791
required: 'api.read',
8892
fieldName: 'owner',
@@ -219,6 +223,11 @@ const census = (() => {
219223

220224
const PRODUCER_NAME = producerName();
221225

226+
/** [#22868] The fault sites whose rendered message quotes the requested object. */
227+
function namingTheObject(): Site[] {
228+
return census.faults.filter((s) => s.message.includes(`'${OBJECT}'`));
229+
}
230+
222231
/** The producer's own fault: its declared `name`, and no `innerMessage`. */
223232
function ownFault(message: string): Error {
224233
const err = new Error(message);
@@ -228,8 +237,6 @@ function ownFault(message: string): Error {
228237

229238
type CreateArgs = Parameters<ImportProtocolLike['createData']>[0];
230239

231-
const OBJECT = 'mz_locked';
232-
233240
const baseOpts = {
234241
objectName: OBJECT,
235242
metaMap: new Map<string, ExportFieldMeta>([['name', { name: 'name', type: 'text' }]]),
@@ -280,6 +287,11 @@ describe('[#22741] §1 — every fault the runner raises itself reads the door\'
280287
}
281288
// §2's population: the constructions that set `innerMessage` (crash and refusal sites).
282289
expect(census.crashes.length).toBeGreaterThanOrEqual(4);
290+
// [#22868] The sites that name the requested object: the `ctx.api.object(…)`
291+
// capability gate and the missing-method arm, each held to `500` below.
292+
const named = namingTheObject();
293+
expect(named.filter((s) => /^capability '/.test(s.message)).length, 'capability denial').toBeGreaterThanOrEqual(1);
294+
expect(named.filter((s) => /not implemented$/.test(s.message)).length, 'missing ctx.api method').toBeGreaterThanOrEqual(1);
283295
});
284296

285297
it.each(census.faults.map((s) => [s.label, s.message] as const))('%s', async (_label, message) => {
@@ -331,6 +343,28 @@ describe('[#22741] §3 — controls: what the predicate must NOT change', () =>
331343
expect((await rowFor(err)).error).toBe(sanitizeRowError(budget()));
332344
});
333345

346+
it.each([
347+
['SQLite', `SQLITE_ERROR: no such table: ${OBJECT}`],
348+
['Postgres', `relation "${OBJECT}" does not exist`],
349+
])('CONTROL [#22868] — a real %s unknown-object text on the requested object still answers 404', (_driver, text) => {
350+
const door = mapDataError(new Error(text), OBJECT);
351+
expect(door.status).toBe(404);
352+
expect(door.body.code).toBe('OBJECT_NOT_FOUND');
353+
});
354+
355+
it('CONTROL [#22868] — the same object-naming texts, thrown by anything but the sandbox, keep the heuristic\'s 404', () => {
356+
// The door asks the producer's identity, never the words: no second rule.
357+
const named = namingTheObject();
358+
expect(named.length).toBeGreaterThanOrEqual(2);
359+
for (const { label, message } of named) {
360+
const err = new Error(message);
361+
expect(isSandboxOwnFault(err), label).toBe(false);
362+
const door = mapDataError(err, OBJECT);
363+
expect(door.status, label).toBe(404);
364+
expect(door.body.code, label).toBe('OBJECT_NOT_FOUND');
365+
}
366+
});
367+
334368
it('CONTROL — a code-defined hook\'s native TypeError keeps its text, as the filing says', async () => {
335369
const text = "Cannot read properties of undefined (reading 'owner')";
336370
const err = () => new TypeError(text);

‎packages/types/src/data-error-classification.ts‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -795,11 +795,12 @@ function isSandboxOrigin(error: any): boolean {
795795
* runner's row report (`@objectstack/core`) sits below `@objectstack/runtime`
796796
* and never sees the class.
797797
*
798-
* ⛔ Not a withholding rule. {@link mapDataError} asks nothing here: it answers
799-
* these errors from their message like any other unclassified throw, which for
800-
* the runner's fault texts is its sanitised `500 INTERNAL_ERROR` terminal. This
801-
* predicate lets the import row recognise the error so it can take that door's
802-
* sentence, as it does for a crash; the verdict stays the door's.
798+
* [#22868] {@link classifyDataError} asks it at its top, beside
799+
* {@link isSandboxCrash}, and answers the sanitised `500 INTERNAL_ERROR`
800+
* terminal — the answer it already gave these errors from their message,
801+
* except where a text named the request's own object and the unknown-object
802+
* heuristic answered `404`. The import row still takes that door's sentence
803+
* through this predicate, as it does for a crash; the verdict stays the door's.
803804
*/
804805
export function isSandboxOwnFault(error: unknown): boolean {
805806
const name = (error as { name?: unknown } | null | undefined)?.name;
@@ -1336,6 +1337,28 @@ function classifyDataError(error: any, object?: string): { status: number; body:
13361337
// ⛔ The terminal is not duplicated — it MOVED here from inside the unwrap
13371338
// door below, which is why that door now reads a body that REPORTED.
13381339
if (isSandboxCrash(error)) return UNCLASSIFIED_FAULT();
1340+
// [#22868] …and so is the sandbox's OWN fault ({@link isSandboxOwnFault}):
1341+
// a capability denial, an unavailable `ctx.api`, a missing `ctx.api`
1342+
// method, a budget, an install or marshal failure. Its producer's contract
1343+
// calls it a fault, and it carries no `code`, no `status` and no
1344+
// `innerMessage`, so nothing below could ever read it as anything else but
1345+
// its message TEXT. Two of those texts name the object the body addressed
1346+
// — `capability 'api.read' not granted to hook 'H' (called
1347+
// ctx.api.object('OBJ').find)` and `ctx.api.object('OBJ').find not
1348+
// implemented` — and when OBJ was the request's own object the
1349+
// unknown-object substring limb (a quoted object name plus `not`) answered
1350+
// `404 OBJECT_NOT_FOUND`, telling the caller the object does not exist.
1351+
//
1352+
// Asked HERE rather than beside that limb, because every message limb
1353+
// below reads text the runner interpolates from the body's own arguments
1354+
// (`ctx.api.object(name)`, `ctx.title(field)`), so a limb-local exclusion
1355+
// would fix one limb and leave the others free to misread the same fault.
1356+
// The answer is the terminal the other runner faults already reached, so
1357+
// only the misread ones move; the nested crash the pump loop relays as a
1358+
// fault reached it too, through the wrapper fallback below. ⛔ Not a second
1359+
// rule: the predicate reads the producer's identity, never the text, so
1360+
// the same words thrown by anything else keep the heuristic's answer.
1361+
if (isSandboxOwnFault(error)) return UNCLASSIFIED_FAULT();
13391362
// [commit 6d178a408] The bespoke structured arms first, exactly as they were inline
13401363
// here — same arms, same order, same position — now stated once so
13411364
// {@link resolveErrorResponse} can ask them before ITS passthrough too.

0 commit comments

Comments
 (0)