Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/engine-refusals-stamp-httpstatus.md
Original file line number Diff line number Diff line change
@@ -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`).
24 changes: 12 additions & 12 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:11733` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11916` |
| 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:10381` |
| 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:10514`, `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:6240` |
| 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:3920`, `:3930`, `:3957` |
| 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:6939` |
| 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:12535` |
| 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:12464` |
| 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`)
Expand Down Expand Up @@ -179,8 +179,8 @@ a reader tracing where elevation travels needs them.

| # | Site | Package | What it does |
|:--|:---|:---|:---|
| 62 | `objectql/src/engine.ts:3727` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 63 | `objectql/src/engine.ts:14981` | 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 |

Expand All @@ -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:10364`–`10381` |
| "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` |
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/commands/migrate/summary-nulls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);
});
2 changes: 2 additions & 0 deletions packages/objectql/src/duplicate-record-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
22 changes: 18 additions & 4 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,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;
Expand Down Expand Up @@ -1101,6 +1108,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;
Expand Down Expand Up @@ -1245,6 +1253,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;
Expand Down Expand Up @@ -5479,7 +5488,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\` / `
Expand All @@ -5491,6 +5500,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 };
Expand All @@ -5508,7 +5518,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: '
Expand All @@ -5520,6 +5530,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);
Expand Down Expand Up @@ -5558,7 +5569,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 '
Expand All @@ -5567,6 +5578,7 @@ export class ObjectQL implements IObjectQLEngine {
);
err.code = 'INVALID_METADATA';
err.status = 422;
err.httpStatus = 422;
throw err;
}
const body = parsed.data as Record<string, unknown>;
Expand Down Expand Up @@ -7134,7 +7146,7 @@ export class ObjectQL implements IObjectQLEngine {
): Promise<Map<string, unknown>> {
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 — '
Expand All @@ -7144,6 +7156,7 @@ export class ObjectQL implements IObjectQLEngine {
);
err.code = 'INVALID_FIELD';
err.status = 400;
err.httpStatus = 400;
err.object = object;
err.field = field;
throw err;
Expand Down Expand Up @@ -12978,6 +12991,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
Expand Down
Loading
Loading