From 42d9291d16d68a40ffb951ccaba4fef8d4e20c1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 02:31:24 +0000 Subject: [PATCH 1/4] fix(objectql): engine refusals stamp `httpStatus` beside `status` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every producer in `packages/objectql` that stamps a numeric HTTP status on a thrown error now stamps it under both spellings. `status` is unchanged and kept: it is what every HTTP door in this repo reads. `httpStatus` is the ADR-0112 D5 spelling, and it is what a consumer holding the THROWN error reads — the CLI's `--json` error envelope was emitting `code` with no status at all for a locally thrown engine refusal. 20 producer sites: 12 inline `err.status = N` stamps (engine.ts, filter-comparand-shape.ts, summary-backfill.ts) and 8 error classes declaring `readonly status`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../objectql/src/duplicate-record-error.ts | 2 ++ packages/objectql/src/engine.ts | 22 +++++++++++++++---- .../objectql/src/filter-comparand-shape.ts | 10 ++++++--- packages/objectql/src/hook-run-as.ts | 2 ++ .../src/multi-update-hook-key-divergence.ts | 2 ++ packages/objectql/src/registry.ts | 6 +++++ packages/objectql/src/secret-fields.ts | 2 ++ packages/objectql/src/summary-backfill.ts | 4 +++- .../src/tenancy/system-write-organization.ts | 2 ++ 9 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/objectql/src/duplicate-record-error.ts b/packages/objectql/src/duplicate-record-error.ts index cffbc8b276..6b214302e4 100644 --- a/packages/objectql/src/duplicate-record-error.ts +++ b/packages/objectql/src/duplicate-record-error.ts @@ -79,6 +79,8 @@ const DUPLICATE_RECORD_STATUS = 409 as const; export class DuplicateRecordError extends Error { readonly code = DUPLICATE_RECORD_CODE; readonly status = DUPLICATE_RECORD_STATUS; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = DUPLICATE_RECORD_STATUS; /** * The driver's own error, whole. * diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 3250bf4fcf..8c06b00c4b 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -999,6 +999,13 @@ function assertOrderByIsMaterializable( // surfaces engine errors over HTTP therefore answers the same envelope on // both doors instead of turning the direct path into an unhandled 500. err.status = 400; + // …and `httpStatus`, the SAME number under ADR-0112 D5's spelling. `status` is + // what every HTTP door in this repo reads (`resolveThrownHttpError`), so it + // stays; `httpStatus` is what a consumer holding the THROWN error reads — + // the CLI's `--json` error envelope (`errorCodeFields`) is the measured one, + // and it saw `code` with no status at all until both spellings were stamped. + // Two keys, one value, written together at every producer in this package. + err.httpStatus = 400; err.code = 'INVALID_SORT'; err.field = first; err.fields = unmaterialized; @@ -1100,6 +1107,7 @@ function assertProjectionHasNoDottedPaths( // code however the caller reached it, so a host surfacing engine errors over // HTTP answers the same envelope on both doors. err.status = 400; + err.httpStatus = 400; err.code = 'INVALID_FIELD'; err.field = first; err.fields = dotted; @@ -1244,6 +1252,7 @@ function undeclaredWriteFieldErrors( if (undeclared.length === 0) continue; const err: any = new Error(`Unknown field '${undeclared[0]}' on object '${object}'`); err.status = 400; + err.httpStatus = 400; err.code = 'INVALID_FIELD'; err.field = undeclared[0]; err.fields = undeclared; @@ -5472,7 +5481,7 @@ export class ObjectQL implements IObjectQLEngine { && item.name && item.name !== itemName ) { - const err: Error & { code?: string; status?: number } = new Error( + const err: Error & { code?: string; status?: number; httpStatus?: number } = new Error( `Invalid \`views:\` container from ${sourceLabel} '${ownerId}': the container's own ` + `\`name\` is '${item.name}', which disagrees with the object key it binds to, ` + `'${itemName}' (derived from its own \`object\`, else \`list.data.object\` / ` @@ -5484,6 +5493,7 @@ export class ObjectQL implements IObjectQLEngine { ); err.code = 'VALIDATION_ERROR'; err.status = 400; + err.httpStatus = 400; throw err; } const toRegister = item.name === itemName ? item : { ...item, name: itemName }; @@ -5501,7 +5511,7 @@ export class ObjectQL implements IObjectQLEngine { // internals are well-formed stays the authoring/publish doors' // job (defineStack, `os validate`, the metadata door). if (key === 'views' && !isViewContainerShaped(toRegister)) { - const err: Error & { code?: string; status?: number } = new Error( + const err: Error & { code?: string; status?: number; httpStatus?: number } = new Error( `Invalid \`views:\` entry '${itemName}' from ${sourceLabel} '${ownerId}': the stack ` + '`views:` collection carries view CONTAINERS only. `viewKind`/`config`/inline view ' + 'config belong to a single VIEW, not to the container — wrap it: ' @@ -5513,6 +5523,7 @@ export class ObjectQL implements IObjectQLEngine { ); err.code = 'INVALID_METADATA'; err.status = 422; + err.httpStatus = 422; throw err; } this._registry.registerItem(pluralToSingular(key), toRegister, 'name' as any, ownerId); @@ -5551,7 +5562,7 @@ export class ObjectQL implements IObjectQLEngine { const parsed = AssembledViewArtifactSchema.safeParse(item); if (!parsed.success) { const itemName = resolveMetadataItemName('views', item) ?? '(unnamed)'; - const err: Error & { code?: string; status?: number } = new Error( + const err: Error & { code?: string; status?: number; httpStatus?: number } = new Error( `Invalid \`${ASSEMBLED_VIEW_ITEMS_KEY}:\` entry '${itemName}' from ${sourceLabel} '${ownerId}': ` + 'the assembled-manifest channel carries non-container view artifacts only — a ViewItem ' + 'record (`viewKind` + `config`) or a flattened list/form overlay ' @@ -5560,6 +5571,7 @@ export class ObjectQL implements IObjectQLEngine { ); err.code = 'INVALID_METADATA'; err.status = 422; + err.httpStatus = 422; throw err; } const body = parsed.data as Record; @@ -7076,7 +7088,7 @@ export class ObjectQL implements IObjectQLEngine { ): Promise> { const schema = this._registry.getObject(object); if (!collectInternalReadFields(schema).includes(field)) { - const err: Error & { code?: string; status?: number; object?: string; field?: string } = + const err: Error & { code?: string; status?: number; httpStatus?: number; object?: string; field?: string } = new Error( `Cannot resolve internal field "${object}.${field}": it is not declared \`internal: true\`. ` + 'Only fields the engine omits from the generic read path are dereferenceable here — ' @@ -7086,6 +7098,7 @@ export class ObjectQL implements IObjectQLEngine { ); err.code = 'INVALID_FIELD'; err.status = 400; + err.httpStatus = 400; err.object = object; err.field = field; throw err; @@ -12920,6 +12933,7 @@ export class ObjectQL implements IObjectQLEngine { `Delete or reassign them first, or set deleteBehavior:'cascade' on ${childName}.${fieldName}.`; err.code = 'DELETE_RESTRICTED'; err.status = 409; + err.httpStatus = 409; err.object = object; // Constraint 2's REQUIRED half — the referenced object is named // unconditionally, because "which table is blocking me" is the one diff --git a/packages/objectql/src/filter-comparand-shape.ts b/packages/objectql/src/filter-comparand-shape.ts index 4b62cde7bc..3b09a0ee56 100644 --- a/packages/objectql/src/filter-comparand-shape.ts +++ b/packages/objectql/src/filter-comparand-shape.ts @@ -76,9 +76,11 @@ function isFilterNode(value: unknown): value is Record { * (`filter-refusal.ts`), which carries the cross-driver rationale. */ export function invalidFilterError(message: string): Error { - const err = new Error(message) as Error & { code?: string; status?: number }; + const err = new Error(message) as Error & { code?: string; status?: number; httpStatus?: number }; err.code = StandardErrorCode.enum.INVALID_FILTER; err.status = 400; + // …and `httpStatus`, the same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors. + err.httpStatus = 400; return err; } @@ -239,11 +241,12 @@ export function assertFilterIsMaterializable( // vocabulary across the doors. + ` Denormalise the value onto '${object}' (a stored field, written when the source` + ' changes) and filter that.', - ) as Error & { code?: string; status?: number; field?: string; fields?: string[]; object?: string }; + ) as Error & { code?: string; status?: number; httpStatus?: number; field?: string; fields?: string[]; object?: string }; // Same identity argument as the virtual verdict below: the question is // about the NAME (its head's type), so `INVALID_FIELD`/400 — never a new // code, per the #8371 ruling's own words. dottedErr.status = 400; + dottedErr.httpStatus = 400; dottedErr.code = StandardErrorCode.enum.INVALID_FIELD; dottedErr.field = first; dottedErr.fields = judgedDotted; @@ -273,7 +276,7 @@ export function assertFilterIsMaterializable( // refused here must not be sent two different ways. + ` Denormalise the value onto '${object}' (a stored field, written when the source` + ' changes) and filter that.', - ) as Error & { code?: string; status?: number; field?: string; fields?: string[]; object?: string }; + ) as Error & { code?: string; status?: number; httpStatus?: number; field?: string; fields?: string[]; object?: string }; // `INVALID_FIELD`, not `INVALID_FILTER`, and not a new code: this verdict is // about the NAME's type, which is the question the ingress door answers with // `INVALID_FIELD` on its neighbouring `unknown` verdict and the SEARCH axis @@ -283,6 +286,7 @@ export function assertFilterIsMaterializable( // one condition keeps ONE wire code however the caller reached it, so a host // surfacing engine errors over HTTP answers the same envelope on both doors. err.status = 400; + err.httpStatus = 400; err.code = StandardErrorCode.enum.INVALID_FIELD; err.field = first; err.fields = virtual; diff --git a/packages/objectql/src/hook-run-as.ts b/packages/objectql/src/hook-run-as.ts index 45aed9b014..315739a350 100644 --- a/packages/objectql/src/hook-run-as.ts +++ b/packages/objectql/src/hook-run-as.ts @@ -98,6 +98,8 @@ export class HookUnscopedDataAccessError extends Error { override readonly name = 'HookUnscopedDataAccessError'; readonly code = HOOK_UNSCOPED_DATA_ACCESS_CODE; readonly status = HOOK_UNSCOPED_DATA_ACCESS_STATUS; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = HOOK_UNSCOPED_DATA_ACCESS_STATUS; readonly hook: string; readonly object?: string; readonly event?: string; diff --git a/packages/objectql/src/multi-update-hook-key-divergence.ts b/packages/objectql/src/multi-update-hook-key-divergence.ts index 0301b0d25b..7d070b778b 100644 --- a/packages/objectql/src/multi-update-hook-key-divergence.ts +++ b/packages/objectql/src/multi-update-hook-key-divergence.ts @@ -156,6 +156,8 @@ export class MultiUpdateHookKeyDivergenceError extends Error { override readonly name = 'MultiUpdateHookKeyDivergenceError'; readonly code = MULTI_UPDATE_HOOK_KEY_DIVERGENCE_CODE; readonly status = MULTI_UPDATE_HOOK_KEY_DIVERGENCE_STATUS; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = MULTI_UPDATE_HOOK_KEY_DIVERGENCE_STATUS; /** The object the refused batch targeted. */ readonly object: string; /** The keys whose presence differed across rows, sorted. */ diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 3e3df79da1..c5c0574678 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1291,6 +1291,8 @@ function toRecordManifest(manifest: ObjectStackManifest): ObjectStackManifest { export class NamespaceConflictError extends Error { readonly code = 'NAMESPACE_CONFLICT'; readonly status = 422; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = 422; /** The namespace both packages claim. */ readonly namespace: string; /** The installed package that already owns the namespace. */ @@ -1403,6 +1405,8 @@ function declaredOwnedObjectNames(manifest: ObjectStackManifest): string[] { export class ArtifactObjectNameConflictError extends Error { readonly code = 'DUPLICATE_ARTIFACT_OBJECT_NAME'; readonly status = 422; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = 422; /** The object name both packages claim. */ readonly objectName: string; /** The co-owning package that already owns the name. */ @@ -1462,6 +1466,8 @@ export class ArtifactObjectNameConflictError extends Error { export class ObjectOwnershipConflictError extends Error { readonly code = 'OBJECT_OWNERSHIP_CONFLICT'; readonly status = 422; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = 422; /** The fully-qualified object name both packages claim. */ readonly objectName: string; /** The package that already owns the name. */ diff --git a/packages/objectql/src/secret-fields.ts b/packages/objectql/src/secret-fields.ts index b124212284..708318ed66 100644 --- a/packages/objectql/src/secret-fields.ts +++ b/packages/objectql/src/secret-fields.ts @@ -133,6 +133,8 @@ export const EMPTY_CREDENTIAL_REFUSAL_STATUS = 400; export class EmptyCredentialWriteError extends Error { readonly code = EMPTY_CREDENTIAL_REFUSAL_CODE; readonly status = EMPTY_CREDENTIAL_REFUSAL_STATUS; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = EMPTY_CREDENTIAL_REFUSAL_STATUS; readonly object: string; readonly field: string; readonly fieldType: 'secret' | 'password'; diff --git a/packages/objectql/src/summary-backfill.ts b/packages/objectql/src/summary-backfill.ts index 899c10e8b5..51b3a78d14 100644 --- a/packages/objectql/src/summary-backfill.ts +++ b/packages/objectql/src/summary-backfill.ts @@ -293,9 +293,11 @@ function resolveRecomputeScope( `${unresolved.join(', ')}. Each entry is spelled object.field and must name a summary field owned by one ` + `of the ${candidates.length} object(s) this run walks (an object left out by \`objects\` is not walked). ` + 'Refused before any row was read; nothing was written.', - ) as Error & { code: string; status: number; field: string; fields: string[] }; + ) as Error & { code: string; status: number; httpStatus: number; field: string; fields: string[] }; err.code = 'INVALID_FIELD'; err.status = 400; + // …and `httpStatus`, the same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors. + err.httpStatus = 400; err.field = unresolved[0]; err.fields = unresolved; throw err; diff --git a/packages/objectql/src/tenancy/system-write-organization.ts b/packages/objectql/src/tenancy/system-write-organization.ts index 345138037f..6577f1e3dc 100644 --- a/packages/objectql/src/tenancy/system-write-organization.ts +++ b/packages/objectql/src/tenancy/system-write-organization.ts @@ -328,6 +328,8 @@ function buildRefusalMessage( export class SystemWriteOrganizationRequiredError extends Error { readonly code = 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED' as const; readonly status = 500; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = 500; constructor( public readonly object: string, From dc9cf0058726d4f665f8742144d9eee2aeb83e39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 02:54:51 +0000 Subject: [PATCH 2/4] test(objectql,cli): pin both status spellings at the producer and in the CLI envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `summary-backfill.test.ts`: the real-producer refusal test now asserts `httpStatus` beside `status` — this is the card's own producer, exercised through `backfillSummaryNulls` rather than mocked. - `error-http-status-spelling.test.ts` (new): constructs every engine error class that declares a status and asserts the two spellings agree. Nothing else enforces the pairing — the two keys are plain data on a thrown value, and `check:error-status-conformance`'s deriver reads `status`/`statusCode` only. Carries a bare-`Error` control so an all-undefined read cannot pass as agreement. - `summary-nulls.test.ts`: the pin the ruling names widens from `code` only to `code` + `httpStatus: 400`, and its fixture now mirrors what the producer really stamps. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../engine-refusals-stamp-httpstatus.md | 11 +++ .../commands/migrate/summary-nulls.test.ts | 12 ++- .../src/error-http-status-spelling.test.ts | 85 +++++++++++++++++++ .../objectql/src/summary-backfill.test.ts | 5 ++ 4 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 .changeset/engine-refusals-stamp-httpstatus.md create mode 100644 packages/objectql/src/error-http-status-spelling.test.ts diff --git a/.changeset/engine-refusals-stamp-httpstatus.md b/.changeset/engine-refusals-stamp-httpstatus.md new file mode 100644 index 0000000000..bd173d555f --- /dev/null +++ b/.changeset/engine-refusals-stamp-httpstatus.md @@ -0,0 +1,11 @@ +--- +'@objectstack/objectql': minor +--- + +Engine refusals now declare their HTTP status under both spellings: `httpStatus` beside the existing `status`, same number, at every producer in the package. + +`status` is unchanged and stays. It is what every HTTP door in this repo reads — `resolveThrownHttpError` (`@objectstack/types`) resolves `.status` then `.statusCode` and knows no other spelling — so nothing about what the REST or dispatcher doors answer changes. + +What changes is what a consumer holding the **thrown** error can read. ADR-0112 D5 records the destination as "the HTTP status lives on the transport and (optionally) `error.httpStatus`", and `httpStatus` is the key the client SDK already stamps on every wire failure. A consumer that caught an engine refusal locally had no status at all: `os migrate summary-nulls --json --recompute-undefined-on-empty customer.nope` emitted `{ error, code: 'INVALID_FIELD' }` with no status field, while the same refusal arriving over the wire carried `httpStatus: 400`. It now carries `httpStatus: 400` on both paths. + +Additive on thrown errors, so no caller that reads `status` needs to change. The 20 producers: the `INVALID_SORT` / `INVALID_FIELD` / `VALIDATION_ERROR` / `INVALID_METADATA` / `DELETE_RESTRICTED` refusals in `engine.ts`, the `INVALID_FILTER` / `INVALID_FIELD` refusals in `filter-comparand-shape.ts`, `resolveRecomputeScope` in `summary-backfill.ts`, and the eight error classes declaring a `readonly status` (`DuplicateRecordError`, `HookUnscopedDataAccessError`, `MultiUpdateHookKeyDivergenceError`, `EmptyCredentialWriteError`, `SystemWriteOrganizationRequiredError`, `NamespaceConflictError`, `ArtifactObjectNameConflictError`, `ObjectOwnershipConflictError`). diff --git a/packages/cli/src/commands/migrate/summary-nulls.test.ts b/packages/cli/src/commands/migrate/summary-nulls.test.ts index d288965f77..52f3c12835 100644 --- a/packages/cli/src/commands/migrate/summary-nulls.test.ts +++ b/packages/cli/src/commands/migrate/summary-nulls.test.ts @@ -129,9 +129,15 @@ describe('os migrate summary-nulls', () => { expect(options).toEqual({ apply: false, objects: undefined, recomputeUndefinedOnEmpty: undefined, maxRecordsPerObject: undefined }); }, RUN_TIMEOUT); - it('a refused scope entry (INVALID_FIELD) reaches the --json error envelope with its code, and the command exits 1', async () => { + it('a refused scope entry (INVALID_FIELD) reaches the --json error envelope with its code AND its httpStatus, and the command exits 1', async () => { + // The fixture mirrors what `resolveRecomputeScope` really stamps + // (`packages/objectql/src/summary-backfill.ts`): BOTH status spellings, + // same number. `status` is what the HTTP doors read; `httpStatus` is what a + // consumer holding the thrown error reads, and it is the one + // `errorCodeFields` forwards. Asserting `code` alone was the fossil of the + // gap this pin now covers — the envelope carried no status at all. const refusal = Object.assign(new Error('[summary-backfill] recomputeUndefinedOnEmpty names 1 roll-up(s) this run cannot find: customer.nope.'), { - code: 'INVALID_FIELD', status: 400, field: 'customer.nope', fields: ['customer.nope'], + code: 'INVALID_FIELD', status: 400, httpStatus: 400, field: 'customer.nope', fields: ['customer.nope'], }); vi.mocked(backfillSummaryNulls).mockRejectedValue(refusal); @@ -144,7 +150,7 @@ describe('os migrate summary-nulls', () => { expect((err as { oclif?: { exit?: number } }).oclif?.exit).toBe(1); const emitted = stdout.mock.calls.map((c: unknown[]) => String(c[0])).join(''); const payload = JSON.parse(emitted); - expect(payload).toMatchObject({ code: 'INVALID_FIELD' }); + expect(payload).toMatchObject({ code: 'INVALID_FIELD', httpStatus: 400 }); expect(payload.error).toContain('customer.nope'); }, RUN_TIMEOUT); }); diff --git a/packages/objectql/src/error-http-status-spelling.test.ts b/packages/objectql/src/error-http-status-spelling.test.ts new file mode 100644 index 0000000000..ae9f61140b --- /dev/null +++ b/packages/objectql/src/error-http-status-spelling.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Every refusal this package throws with a numeric HTTP status carries that +// number under BOTH spellings, and they agree. +// +// ## Why both, and why a test rather than a comment +// +// `status` is what every HTTP door in this repo reads — `resolveThrownHttpError` +// (`@objectstack/types`) resolves `.status` → `.statusCode` and nothing else, so +// removing it would change what the REST and dispatcher doors answer. It stays. +// +// `httpStatus` is ADR-0112 D5's spelling ("the HTTP status lives on the +// transport and (optionally) `error.httpStatus`"), and it is what a consumer +// holding the THROWN error reads. The measured consumer is the CLI's `--json` +// error envelope: `errorCodeFields` (`packages/cli/src/utils/format.ts`) +// forwards `code` and `httpStatus` only, so `os migrate summary-nulls --json` +// emitted `{ error, code: 'INVALID_FIELD' }` with no status at all for a +// refusal that answers 400 over the wire. +// +// The two keys are written side by side at every producer, which means nothing +// but this test stops one of them from drifting: a producer whose status +// changes in one spelling and not the other is invisible to the type system +// (both are plain data on a thrown value) and invisible to +// `check:error-status-conformance`, whose deriver reads `status` / +// `statusCode` and does not know this spelling at all. So the invariant is +// asserted over CONSTRUCTED errors — the classes are the half a unit test can +// reach without booting an engine, and they are also the half published on the +// package's barrel. +// +// ⛔ Not a source scan. A regex over the producer files would re-derive the +// pairing this file exists to check, and would go green the day its pattern +// stopped matching. Constructing the error and reading the two properties is +// the same question asked of the artefact a consumer actually receives. + +import { describe, it, expect } from 'vitest'; +import { DuplicateRecordError } from './duplicate-record-error.js'; +import { HookUnscopedDataAccessError } from './hook-run-as.js'; +import { MultiUpdateHookKeyDivergenceError } from './multi-update-hook-key-divergence.js'; +import { EmptyCredentialWriteError } from './secret-fields.js'; +import { SystemWriteOrganizationRequiredError } from './tenancy/system-write-organization.js'; +import { NamespaceConflictError, ArtifactObjectNameConflictError, ObjectOwnershipConflictError } from './registry.js'; +import { invalidFilterError } from './filter-comparand-shape.js'; + +/** + * Every status-carrying refusal this package can construct without an engine, + * with the status it declares. The expected number is written here rather than + * read off the instance, so a producer that changes its status in ONE spelling + * fails on the value as well as on the agreement. + */ +const CONSTRUCTED: Array<[string, () => unknown, number]> = [ + ['DuplicateRecordError', () => new DuplicateRecordError('customer', new Error('dup'), 'email'), 409], + ['HookUnscopedDataAccessError', () => new HookUnscopedDataAccessError({ hook: 'beforeFind', object: 'customer', event: 'beforeFind' }), 403], + ['MultiUpdateHookKeyDivergenceError', () => new MultiUpdateHookKeyDivergenceError('customer', ['a', 'b'], 2), 400], + ['EmptyCredentialWriteError', () => new EmptyCredentialWriteError('datasource', 'secret_key', 'secret'), 400], + ['SystemWriteOrganizationRequiredError', () => new SystemWriteOrganizationRequiredError('sys_job', 'group', 'walled-posture'), 500], + ['NamespaceConflictError', () => new NamespaceConflictError('crm', 'pkg_a', 'pkg_b'), 422], + ['ArtifactObjectNameConflictError', () => new ArtifactObjectNameConflictError('customer', 'pkg_a', 'pkg_b'), 422], + ['ObjectOwnershipConflictError', () => new ObjectOwnershipConflictError('customer', 'pkg_a', 'pkg_b'), 422], + ['invalidFilterError', () => invalidFilterError('bad comparand'), 400], +]; + +describe('engine refusals declare their HTTP status under both spellings', () => { + it.each(CONSTRUCTED)('%s declares its status under both spellings', (_name, make, expected) => { + const err = make() as { status?: unknown; httpStatus?: unknown }; + expect(err.status).toBe(expected); + expect(err.httpStatus).toBe(expected); + }); + + it('every constructed refusal agrees with itself — no producer drifts one spelling', () => { + const disagreed = CONSTRUCTED + .map(([name, make]) => [name, make() as { status?: unknown; httpStatus?: unknown }] as const) + .filter(([, err]) => err.status !== err.httpStatus) + .map(([name, err]) => `${name}: status=${String(err.status)} httpStatus=${String(err.httpStatus)}`); + expect(disagreed).toEqual([]); + }); + + // The control this suite needs to be worth anything: a bare `Error` declares + // NEITHER spelling. Without it, an assertion helper that silently read + // `undefined` on both sides would report every producer as "agreeing". + it('a bare Error declares neither spelling — the control', () => { + const bare = new Error('nothing declared') as { status?: unknown; httpStatus?: unknown }; + expect(bare.status).toBeUndefined(); + expect(bare.httpStatus).toBeUndefined(); + }); +}); diff --git a/packages/objectql/src/summary-backfill.test.ts b/packages/objectql/src/summary-backfill.test.ts index 55e1b05495..fd5f492797 100644 --- a/packages/objectql/src/summary-backfill.test.ts +++ b/packages/objectql/src/summary-backfill.test.ts @@ -550,6 +550,11 @@ describe('backfillSummaryNulls — pre-#6013 NULL roll-ups (#6063)', () => { expect(err, named.join(',')).toBeInstanceOf(Error); expect(err.code, named.join(',')).toBe('INVALID_FIELD'); expect(err.status, named.join(',')).toBe(400); + // Both spellings, same number (ADR-0112 D5). `status` is what the HTTP + // doors read; `httpStatus` is what a consumer holding the THROWN error + // reads, and it is the one the CLI `--json` envelope forwards — that + // envelope carried `code` with no status at all until this was stamped. + expect(err.httpStatus, named.join(',')).toBe(400); // The engine's sibling producers' shape: `field` is the first entry // that did not resolve, `fields` every one of them. expect(err.field, named.join(',')).toBe(named[named.length - 1]); From 77bcc4150ab9f0494e8b17d17fef9d1f25a4c0a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 03:13:28 +0000 Subject: [PATCH 3/4] docs(permissions): re-anchor the system-context census to engine.ts's new line numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical, by `node scripts/check-system-context-census.mjs --fix`. Stamping `httpStatus` inserted 8 lines into `engine.ts`, so every `isSystem` anchor below line 1001 shifted by the cumulative insertion count at its position (+9, +12, +13, +14 — each verified against the insert points). No prose changed; only the cited line numbers move. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- content/docs/permissions/system-context.mdx | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index ac5d133bfe..cb117b744a 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,17 +109,17 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11675` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11858` | -| 20 | **`readonly` strip bypassed — INSERT** | objectql | Same, on create — one gate over BOTH create-side passes since the 2026-09-03 ruling moved the static-`readonly` strip in beside the runtime-owned one and deleted the DataProtocol ingress copy. `isSystem` is the **only** exemption on this path: `preserveAudit` is deliberately not read on create, so a non-system historical import is still stripped | `objectql/src/engine.ts:10323` | -| 21 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10456`, `readonly-strict-errors.ts:66` | -| 22 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:6182` | -| 23 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3913`, `:3923`, `:3950` | +| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11688` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11871` | +| 20 | **`readonly` strip bypassed — INSERT** | objectql | Same, on create — one gate over BOTH create-side passes since the 2026-09-03 ruling moved the static-`readonly` strip in beside the runtime-owned one and deleted the DataProtocol ingress copy. `isSystem` is the **only** exemption on this path: `preserveAudit` is deliberately not read on create, so a non-system historical import is still stripped | `objectql/src/engine.ts:10336` | +| 21 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10469`, `readonly-strict-errors.ts:66` | +| 22 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:6194` | +| 23 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3922`, `:3932`, `:3959` | | 24 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 25 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:99` | -| 26 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6881` | -| 27 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12477` | -| 28 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12406` | +| 26 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6893` | +| 27 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12490` | +| 28 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12419` | | 29 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `security-plugin.ts:1686` | ### 3. Sharing (`plugin-sharing`) @@ -179,8 +179,8 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| -| 62 | `objectql/src/engine.ts:3720` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:14923` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 62 | `objectql/src/engine.ts:3729` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | +| 63 | `objectql/src/engine.ts:14937` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | | 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | | 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | @@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:2032` (rationale at `:1942`–`1944`, #3760), `flow.zod.ts:743` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10306`–`10323` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10319`–`10336` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1590` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | From 011dfc57f9f92c5020cc579773a4504a4e2eaef8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 03:35:49 +0000 Subject: [PATCH 4/4] docs(permissions): regenerate the system-context census from the merged tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge brought in #16087, which inserted its own lines into `engine.ts` and re-anchored this page for them. Both sides had edited this `merge=os-regen` artifact, so the driver merged it with exit 0 while silently keeping one side; `scripts/pm/os-regen-merge.sh` took main's bytes and this commit re-derives the page from the merged tree with `pnpm gen:system-context-census`. Blast radius measured, not assumed: 105 rows before and 105 after, row SET identical once integers are normalised, 12 changed lines and all 12 identical apart from line numbers — no row dropped, none added, no prose moved. The deltas (+9/+12/+13/+14) are this branch's own cumulative insertion offsets. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- content/docs/permissions/system-context.mdx | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index cb117b744a..3fc68e7986 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,17 +109,17 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11688` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11871` | -| 20 | **`readonly` strip bypassed — INSERT** | objectql | Same, on create — one gate over BOTH create-side passes since the 2026-09-03 ruling moved the static-`readonly` strip in beside the runtime-owned one and deleted the DataProtocol ingress copy. `isSystem` is the **only** exemption on this path: `preserveAudit` is deliberately not read on create, so a non-system historical import is still stripped | `objectql/src/engine.ts:10336` | -| 21 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10469`, `readonly-strict-errors.ts:66` | -| 22 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:6194` | -| 23 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3922`, `:3932`, `:3959` | +| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11746` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11929` | +| 20 | **`readonly` strip bypassed — INSERT** | objectql | Same, on create — one gate over BOTH create-side passes since the 2026-09-03 ruling moved the static-`readonly` strip in beside the runtime-owned one and deleted the DataProtocol ingress copy. `isSystem` is the **only** exemption on this path: `preserveAudit` is deliberately not read on create, so a non-system historical import is still stripped | `objectql/src/engine.ts:10394` | +| 21 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10527`, `readonly-strict-errors.ts:66` | +| 22 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:6252` | +| 23 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3929`, `:3939`, `:3966` | | 24 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 25 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:99` | -| 26 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6893` | -| 27 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12490` | -| 28 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12419` | +| 26 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6951` | +| 27 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12548` | +| 28 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12477` | | 29 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `security-plugin.ts:1686` | ### 3. Sharing (`plugin-sharing`) @@ -179,8 +179,8 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| -| 62 | `objectql/src/engine.ts:3729` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:14937` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 62 | `objectql/src/engine.ts:3736` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | +| 63 | `objectql/src/engine.ts:14995` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | | 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | | 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | @@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:2032` (rationale at `:1942`–`1944`, #3760), `flow.zod.ts:743` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10319`–`10336` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10377`–`10394` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1590` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` |