diff --git a/.changeset/system-write-organization-stamp.md b/.changeset/system-write-organization-stamp.md new file mode 100644 index 0000000000..474f00a09e --- /dev/null +++ b/.changeset/system-write-organization-stamp.md @@ -0,0 +1,79 @@ +--- +"@objectstack/objectql": patch +"@objectstack/spec": patch +--- + +fix(engine-core): a system-context insert on a tenant-scoped object resolves the install's organization the way a session write does, or is refused — the runtime producer of the autonumber fork #8686's backfill cannot reach (#8844) + + + +#8686 fixed **one** producer of untenanted rows — the seed loader — and shipped +a one-shot backfill for what it had already written. This card is the **other +producer, which is still running**: an ordinary application write made under a +system execution context (a hook, a scheduled job, a custom endpoint, a +`runAs: system` flow). A backfill cannot reach it, because it mints a fresh +duplicate on every tick — which makes #8686's repair **self-undoing on any +install with server-side automation**, i.e. every business app. + +**Measured on 17.0.0 GA**, a single-tenant EHR/MES install with ~44 autonumbered +objects: two records, same object, same install, the **same** value on a field +the app declared `unique`, with no error and no warning. The `notification` case +shows both producers side by side — `NT-00002 .. NT-00011` each existing twice, +copy A written by the "maintenance overdue" cron job, copy B by a user action. + +**Mechanism.** A session write carries the caller's active organization, the SQL +driver stamps it onto the row (`injectTenantOnInsert`), and the autonumber +counter reads it back off the row (`fillAutoNumberFields`, resolving +`row[tenantField] ?? options.tenantId ?? null`). A system-context write carries +none, so the column lands `NULL` and the counter files the row under the +`__global__` pseudo-tenant. One object then runs two counters that cannot see +each other, each correct within its own scope, and the partitioned unique index +— `(COALESCE(organization_id, '__global__'), )`, ADR-0120 D3 — cannot see +across the two partitions either. + +⛔ **Not a counter bug**, and not fixed by making the allocator smarter: both +counters are already correct within their own scopes (the reasoning #8686 +recorded, unchanged). The defect is upstream of the counter. + +**The fix, per the 2026-08-15 maintainer ruling (Option 1)** — a system-context +write resolves the install's organization the way a session write does, at the +engine's stamp resolution, so every driver is covered at the source (which +matters here because `fillAutoNumberFields` is duplicated in `driver-sql` and +`driver-turso`; neither driver changed): + +- **Single-tenant, exactly one organization ⇒ derive and stamp.** The + `__global__` fork stops being minted by hooks, cron and system endpoints. +- **Multi-organization ⇒ carry an explicit organization or be REFUSED LOUDLY**, + never silently defaulted. A walled posture (`group` / `isolated`), or a + `single` posture whose data holds several organizations, has no derivable + answer — the refusal is `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (500, + registered in the ADR-0112 ledger), thrown before anything reaches the driver, + and its message names the condition, what would otherwise have been written, + and both remedies. +- **Already-minted duplicates are reported, never rewritten** — the #8686 + posture, ruled again here. Nothing in this change renumbers anything. + +**Three populations are outside the rule by construction, not by exemption**, so +that the refusal cannot break unattended automation that was never at risk: +objects with no organization column, objects declaring `tenancy: { enabled: +false }` (ADR-0066 — the *declared* way to hold org-less rows, rather than a +per-write bypass flag) and federated objects (ADR-0015); the platform namespaces +`sys_` / `cloud_` / `ai_`, whose rows are deliberately global (#8672's reasoning, +which this ruling confirms holds for platform objects and does **not** generalize +to application objects); and any write that already carries an organization — on +the execution context, on the record, or stamped by a `beforeInsert` hook. + +**First boot is untouched:** before any organization exists there is nothing to +derive and no second partition to fork away from, so those rows still land +org-less for #8686's `sys_organization`-insert handoff to adopt. + +Scoped to **insert**, deliberately: the ruling's yardstick is "the way a session +write does", and stamping the organization is an insert-side mechanism — an +update neither stamps it nor can fork a counter. diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index 17119a4bfd..19ebb4a134 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -44,7 +44,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; title?: string; measures: object[]; dimensions: object[] }[]` | ✅ | Available cubes, each as the `CubeMeta` discovery projection — the cube name, its title, and the measures/dimensions a client may name in a query. A bare array: there is no `cubes` wrapper object, and no cube `sql` is published. | @@ -79,7 +79,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ rows: Record[]; fields: object[]; sql?: string }` | ✅ | | @@ -93,7 +93,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sql: string; params: any[] }` | ✅ | | diff --git a/content/docs/references/api/auth.mdx b/content/docs/references/api/auth.mdx index e751c063d9..a83481d89b 100644 --- a/content/docs/references/api/auth.mdx +++ b/content/docs/references/api/auth.mdx @@ -117,7 +117,7 @@ const result = AuthProvider.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ session: object; user: object; token?: string }` | ✅ | | @@ -153,7 +153,7 @@ const result = AuthProvider.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; email: string; emailVerified: boolean; name: string; … }` | ✅ | | diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 29cb938927..0e216cd56e 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -119,7 +119,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The created flow definition | @@ -144,7 +144,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; deleted: boolean }` | ✅ | | @@ -187,7 +187,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | Full flow definition | @@ -213,7 +213,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; flowName: string; flowVersion?: integer; status: Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| … +2 more>; … }` | ✅ | Full execution log with step details | @@ -241,7 +241,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ flows: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -269,7 +269,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ runs: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -295,7 +295,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; enabled: boolean }` | ✅ | | @@ -325,7 +325,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; output?: any; error?: string; durationMs?: number }` | ✅ | | @@ -351,7 +351,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The updated flow definition | diff --git a/content/docs/references/api/batch.mdx b/content/docs/references/api/batch.mdx index a4cfe24ab5..9e011611ce 100644 --- a/content/docs/references/api/batch.mdx +++ b/content/docs/references/api/batch.mdx @@ -55,7 +55,7 @@ const result = BatchConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if operation succeeded | | **success** | `boolean` | ✅ | Whether this record was processed successfully | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back (#7539). | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back (#7539). | | **data** | `Record` | optional | Full record data (if returnRecords=true) | | **index** | `number` | optional | Index of the record in the request array | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | @@ -122,7 +122,7 @@ const result = BatchConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index bbf306f1fb..7c967088f6 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +262 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +263 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **message** | `string` | ✅ | Readable error message | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | @@ -145,6 +145,7 @@ const result = ApiErrorSchema.parse(data); * `ERR_FILE_REFERENCE_COPY` * `ERR_READONLY_FIELD_REJECTED` * `ERR_SUMMARY_RECOMPUTE` +* `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` * `EXECUTION_ERROR` * `EXPIRED_OR_REVOKED` * `EXPIRY_IN_PAST` @@ -316,7 +317,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | @@ -355,7 +356,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id?: string; success: boolean; errors?: object[]; index?: number; … }[]` | ✅ | Results for each item in the batch | @@ -397,7 +398,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **id** | `string` | ✅ | ID of the deleted record | @@ -449,7 +450,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record[]` | ✅ | Array of matching records | | **pagination** | `{ total?: number; limit?: number; offset?: number; cursor?: string; … }` | ✅ | Pagination info | @@ -465,7 +466,7 @@ const result = ApiErrorSchema.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if processed | | **success** | `boolean` | ✅ | | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | | **index** | `number` | optional | Index in original request | | **data** | `any` | optional | Result data (e.g. created record) | @@ -504,7 +505,7 @@ Key-value map of record data | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record` | ✅ | The requested or modified record | diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 5092fa4385..a400339cfe 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -247,6 +247,7 @@ const result = ErrorCode.parse(data); * `ERR_FILE_REFERENCE_COPY` * `ERR_READONLY_FIELD_REJECTED` * `ERR_SUMMARY_RECOMPUTE` +* `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` * `EXECUTION_ERROR` * `EXPIRED_OR_REVOKED` * `EXPIRY_IN_PAST` diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index bf2f78dc3f..95f82398e3 100644 --- a/content/docs/references/api/export.mdx +++ b/content/docs/references/api/export.mdx @@ -57,7 +57,7 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; estimatedRecords?: integer; createdAt: string }` | ✅ | | @@ -157,7 +157,7 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; format: Enum<'csv' \| 'json' \| 'jsonl' \| 'xlsx' \| 'parquet'>; totalRecords?: integer; … }` | ✅ | | @@ -231,7 +231,7 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; downloadUrl: string; fileName: string; fileSize: integer; … }` | ✅ | | @@ -449,7 +449,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ totalRecords: integer; validRecords: integer; invalidRecords: integer; duplicateRecords: integer; … }` | ✅ | | @@ -488,7 +488,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobs: object[]; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -546,7 +546,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; name: string; enabled: boolean; nextRunAt?: string; … }` | ✅ | | diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 86ed440646..62096708dd 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -51,7 +51,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }` | ✅ | Full App Configuration | @@ -65,7 +65,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; icon?: string; description?: string }[]` | ✅ | List of available concepts (Objects, Apps, Flows) | @@ -92,7 +92,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ total: integer; succeeded: integer; failed: integer; errors?: object[] }` | ✅ | Bulk operation result | @@ -117,7 +117,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; name: string }` | ✅ | | @@ -131,7 +131,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sourceType: string; sourceName: string; targetType: string; targetName: string; … }[]` | ✅ | Items this item depends on | @@ -145,7 +145,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sourceType: string; sourceName: string; targetType: string; targetName: string; … }[]` | ✅ | Items that depend on this item | @@ -159,7 +159,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record` | optional | Effective metadata with all overlays applied | @@ -173,7 +173,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ exists: boolean }` | ✅ | | @@ -200,7 +200,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `any` | ✅ | Exported metadata bundle | @@ -228,7 +228,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ total: integer; imported: integer; skipped: integer; failed: integer; … }` | ✅ | Import result | @@ -242,7 +242,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; name: string; definition: Record }` | ✅ | Metadata item | @@ -256,7 +256,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record[]` | ✅ | Array of metadata definitions | @@ -270,7 +270,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `string[]` | ✅ | Array of metadata item names | @@ -284,7 +284,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; baseType: string; baseName: string; packageId?: string; … }` | optional | Overlay definition, undefined if none | @@ -348,7 +348,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ items: object[]; total: integer; page: integer; pageSize: integer }` | ✅ | Paginated query result | @@ -406,7 +406,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; label: string; description?: string; filePatterns: string[]; … }` | optional | Type info | @@ -420,7 +420,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `string[]` | ✅ | Registered metadata type identifiers | @@ -446,7 +446,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ valid: boolean; errors?: object[]; warnings?: object[] }` | ✅ | Validation result | @@ -460,7 +460,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }` | ✅ | Full Object Schema | diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index d727d02ce1..18f1242266 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -57,7 +57,7 @@ Get installed package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … }` | ✅ | Installed package details | @@ -89,7 +89,7 @@ List installed packages response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ packages: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -143,7 +143,7 @@ Install package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ package: object; dependencyResolution?: object; namespaceConflicts?: object[]; message?: string }` | ✅ | | @@ -185,7 +185,7 @@ Rollback package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; restoredVersion?: string; message?: string }` | ✅ | | @@ -220,7 +220,7 @@ Upgrade package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; phase: string; plan?: object; snapshotId?: string; … }` | ✅ | | @@ -250,7 +250,7 @@ Resolve dependencies response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ dependencies: object[]; canProceed: boolean; requiredActions: object[]; installOrder: string[]; … }` | ✅ | Dependency resolution result with topological sort | @@ -277,7 +277,7 @@ Uninstall package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ packageId: string; success: boolean; message?: string }` | ✅ | | @@ -309,7 +309,7 @@ Upload artifact response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; artifactRef?: object; submissionId?: string; message?: string }` | ✅ | | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index f7421cc141..91e3a846ac 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -280,7 +280,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | @@ -428,7 +428,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | @@ -1528,7 +1528,7 @@ Uninstall package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | diff --git a/content/docs/references/api/storage.mdx b/content/docs/references/api/storage.mdx index 4682356ca0..ef0049be76 100644 --- a/content/docs/references/api/storage.mdx +++ b/content/docs/references/api/storage.mdx @@ -46,7 +46,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ fileId: string; key: string; size: integer; mimeType: string; … }` | ✅ | | @@ -72,7 +72,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ url: string }` | ✅ | | @@ -101,7 +101,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ path: string; name: string; size: integer; mimeType: string; … }` | ✅ | Uploaded file metadata | @@ -147,7 +147,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadId: string; resumeToken: string; fileId: string; totalChunks: integer; … }` | ✅ | | @@ -161,7 +161,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadUrl: string; downloadUrl?: string; fileId: string; method: Enum<'PUT' \| 'POST'>; … }` | ✅ | | @@ -175,7 +175,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ key: string }` | ✅ | | @@ -202,7 +202,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ chunkIndex: integer; eTag: string; bytesReceived: integer }` | ✅ | | @@ -216,7 +216,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadId: string; fileId: string; filename: string; totalSize: integer; … }` | ✅ | | diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 191e787edf..ad3381c6dc 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -115,6 +115,21 @@ import { isMissingTableError } from '@objectstack/metadata/errors'; import { isUniqueViolationError, uniqueViolationColumn } from '@objectstack/types'; // [#8682] The write-path loggers' redaction — bound values never reach the log. import { redactBoundStatement } from './driver-fault-redaction.js'; +// [#8844] The runtime half of #8686's ruling: a system-context write on a +// tenant-scoped object resolves the install's organization the way a session +// write does, or is refused rather than filed under the `__global__` +// pseudo-tenant. The decision is a pure function of measured facts; this file +// supplies the facts and turns a `refuse` into the throw. +import { + resolveSystemWriteOrganization, + resolveTenantFieldName, + isPlatformNamespaceObject, + carriesOrganization, + SystemWriteOrganizationRequiredError, + ORGANIZATION_OBJECT, +} from './tenancy/system-write-organization.js'; +import { resolveTenancyPosture } from '@objectstack/types'; +import { normalizeTenancyPosture, type TenancyPosture } from '@objectstack/spec/security'; /** * Per-row outcome of {@link ObjectQL.insertMany} (framework#3172). One entry @@ -2009,6 +2024,11 @@ export class ObjectQL implements IObjectQLEngine { // absent = equality scoping (fail toward isolation). private tenancyPostureProvider?: () => string | undefined; + // [#8844] The install's organizations, capped at two — memoised across system + // writes and dropped whenever `sys_organization` is written through this + // engine. See `probeInstallOrganizations` for the stale window this accepts. + private organizationProbeMemo?: readonly string[]; + // Per-engine SchemaRegistry instance. // // Historically SchemaRegistry was a process-wide singleton of static state, @@ -2947,6 +2967,141 @@ export class ObjectQL implements IObjectQLEngine { return opts; } + /** + * [#8844] The tenancy posture IN FORCE for this engine, by the same two-step + * `app-plugin.organizationWallActive` uses and for the same reason: the + * injected provider is the `tenancy` service's answer (what is actually + * ENFORCED, degraded boots included), and the env resolution is what the + * operator ASKED for — the best fact available to a lean embedding that + * mounts no SecurityPlugin. + * + * ⛔ Never `resolveMultiOrgEnabled()`: ADR-0105 D1 demoted that boolean to a + * back-compat INPUT of `resolveTenancyPosture()`, so it reads `false` on a + * deployment configured the documented way and would put a fully walled + * install on the single-tenant branch below — the third recurrence of that + * shape is what `app-plugin` documents. + * + * Read live per call, never cached: nothing here may freeze a verdict a later + * boot phase can still change. `resolveTenancyPosture()` THROWS on an + * unrecognized `OS_TENANCY_POSTURE` (deliberately — a typo'd posture must not + * silently remove the wall); a write is not the place that refusal belongs, + * and an unreadable posture is exactly the case where guessing `single` would + * be worst, so it degrades to the walled reading and the write is refused. + */ + private resolveEnginePosture(): TenancyPosture { + const injected = normalizeTenancyPosture(this.tenancyPostureProvider?.()); + if (injected) return injected; + try { + return resolveTenancyPosture(); + } catch { + return 'isolated'; + } + } + + /** + * [#8844] The install's organizations, capped at two — only "none / exactly + * one / several" changes any decision. + * + * Memoised because a cron loop inserting N rows would otherwise pay N probes + * for an answer that changes only when `sys_organization` itself is written, + * and every such write passes through this engine's own write faces, which + * clear it ({@link invalidateOrganizationProbe}). The stale window is + * therefore an organization created by ANOTHER process against the same + * database — and on the `single` posture this memo serves, a second + * organization appearing is the ambiguous topology the next boot refuses + * anyway. + * + * `isSystem` on the read: resolving the install's organization must not + * depend on the caller's own reach, and this runs on writes that have no + * caller reach at all. + */ + private async probeInstallOrganizations(): Promise { + if (this.organizationProbeMemo) return this.organizationProbeMemo; + let ids: readonly string[] = []; + try { + const rows = await this.find(ORGANIZATION_OBJECT, { + fields: ['id'], + limit: 2, + context: { isSystem: true }, + }); + ids = (Array.isArray(rows) ? rows : []) + .map((r: any) => (r?.id ?? r?._id)) + .filter((id: unknown) => id != null && String(id) !== '') + .map((id: unknown) => String(id)); + } catch { + // `sys_organization` may not be registered at all (a lean embedding, a + // bare-kernel test). No organizations is the honest reading, and it is + // the branch that changes nothing. + ids = []; + } + this.organizationProbeMemo = ids; + return ids; + } + + /** [#8844] Drop the memo — any write that can change the organization set. */ + private invalidateOrganizationProbe(): void { + this.organizationProbeMemo = undefined; + } + + /** + * [#8844] Resolve the organization a SYSTEM-context insert carries, per the + * 2026-08-15 ruling (Option 1) — or refuse it. + * + * Returns the organization id to thread as `DriverOptions.tenantId` (the same + * knob a session write sets, so the driver stamps the column and the counter + * scopes by it), or `undefined` when there is nothing to resolve. Throws + * {@link SystemWriteOrganizationRequiredError} on the multi-organization + * branch. + * + * Called AFTER the beforeInsert hooks on purpose: a hook that stamps the + * organization itself has carried it, and must not then be refused for a + * value it just supplied. + * + * Every early return below is a population the refusal must not touch — see + * the module header for why each is outside the rule by construction rather + * than by exemption. They are ordered cheapest-first, so an ordinary write + * pays two property reads and a regexp. + */ + private async resolveSystemInsertOrganization( + object: string, + execCtx: ExecutionContext | undefined, + rows: readonly Record[], + ): Promise { + // Already carrying an organization on the context — a session write, or a + // system write that threaded one. Nothing to resolve; this is the shape the + // ruling asks every system write to reach. + if (carriesOrganization(execCtx?.tenantId)) return undefined; + // Platform namespaces stay global by design (#8672's reasoning, which the + // #8844 ruling confirms does not generalize to application objects). + if (isPlatformNamespaceObject(object)) return undefined; + const objectSchema = this._registry.getObject(object) as any; + // A federated object's schema is the REMOTE's (ADR-0015); the platform's + // injected column says nothing about it, which is the same reason + // `buildDriverOptions` withholds `tenantId` there. + if (objectSchema?.external != null) return undefined; + const tenantField = resolveTenantFieldName(objectSchema); + if (!tenantField) return undefined; + // A row that names its own organization has carried one explicitly. Only a + // row still missing it can fork a counter, so a batch where every row is + // stamped resolves nothing — and a batch where some are not is decided by + // the ones that are not. + if (rows.every((row) => carriesOrganization(row?.[tenantField]))) return undefined; + + const posture = this.resolveEnginePosture(); + const decision = await resolveSystemWriteOrganization({ + posture, + probeOrganizations: () => this.probeInstallOrganizations(), + }); + if (decision.kind === 'derived') return decision.organizationId; + if (decision.kind === 'no-organization-yet') return undefined; + throw new SystemWriteOrganizationRequiredError( + object, + posture, + decision.reason, + decision.organizationCount, + ); + } + /** * Does the open transaction `tx` actually cover the driver `object` resolves * to? — the same-origin question, asked by instance IDENTITY (#5351). @@ -8103,6 +8258,10 @@ export class ObjectQL implements IObjectQLEngine { object = this.resolveObjectName(object); this.logger.debug('Insert operation starting', { object, isBatch: Array.isArray(data) }); this.assertWriteAllowed(object, 'insert'); + // [#8844] The organization set is about to change. Dropped BEFORE the write + // rather than after it, so a concurrent read cannot repopulate the memo from + // a snapshot this insert is in the middle of invalidating. + if (object === ORGANIZATION_OBJECT) this.invalidateOrganizationProbe(); const driver = this.getDriver(object); // [#5351/#5696] Same-origin gate: refuse a cross-driver BUSINESS write, // carve an append-only system ledger out of the transaction. Before any @@ -8238,7 +8397,33 @@ export class ObjectQL implements IObjectQLEngine { // Base the merge on the first row context's options: hooks share the // same underlying options object (in-place mutations are visible), and // for single inserts this is exactly the pre-#2922 behaviour. - const driverOptions = this.buildDriverOptions(object, opCtx.context, rowHookContexts[0]?.input.options as any); + // + // [#8844] The system-context organization resolution runs HERE — after + // the beforeInsert hooks (a hook that stamped the organization has + // carried it) and before anything reads the resolved options: the + // credential channel below, `applyAutonumbers`, and the driver dispatch + // itself. It resolves to the SAME knob a session write sets + // (`DriverOptions.tenantId`), so the driver stamps the column via + // `injectTenantOnInsert` and its counter scopes by the real organization + // instead of the `__global__` pseudo-tenant — one mechanism, both write + // paths, and no driver had to change (which matters twice over here: + // `fillAutoNumberFields` is duplicated in `driver-sql` and + // `driver-turso`). `buildDriverOptions` preserves an explicitly-supplied + // `base.tenantId`, so passing it in as the base is how the derived value + // survives the merge. + const derivedOrganizationId = await this.resolveSystemInsertOrganization( + object, + opCtx.context, + rowHookContexts.map((rowCtx) => rowCtx.input.data as Record), + ); + const optionsBase = rowHookContexts[0]?.input.options as any; + const driverOptions = this.buildDriverOptions( + object, + opCtx.context, + derivedOrganizationId !== undefined + ? { ...(optionsBase && typeof optionsBase === 'object' ? optionsBase : {}), tenantId: derivedOrganizationId } + : optionsBase, + ); for (const rowCtx of rowHookContexts) { rowCtx.input.options = driverOptions; } @@ -8629,6 +8814,9 @@ export class ObjectQL implements IObjectQLEngine { object = this.resolveObjectName(object); this.logger.debug('Update operation starting', { object }); this.assertWriteAllowed(object, 'update'); + // [#8844] See the insert face — an organization row that changes identity + // invalidates the memo just as one that appears does. + if (object === ORGANIZATION_OBJECT) this.invalidateOrganizationProbe(); const driver = this.getDriver(object); // [#5351/#5696] Same-origin gate: refuse a cross-driver BUSINESS write, // carve an append-only system ledger out of the transaction. Before any @@ -9981,6 +10169,9 @@ export class ObjectQL implements IObjectQLEngine { object = this.resolveObjectName(object); this.logger.debug('Delete operation starting', { object }); this.assertWriteAllowed(object, 'delete'); + // [#8844] See the insert face — deleting the install's only organization + // must not leave the memo answering with it. + if (object === ORGANIZATION_OBJECT) this.invalidateOrganizationProbe(); const driver = this.getDriver(object); // [#5351/#5696] Same-origin gate: refuse a cross-driver BUSINESS write, // carve an append-only system ledger out of the transaction. Before any diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 9697dc11c6..8256dc8b59 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -244,6 +244,25 @@ export type { AuditAbortSignal, } from './integrity/dangling-reference-audit.js'; +// [#8844] System-context write organization resolution — the runtime half of +// #8686's ruling. The refusal class is exported because a caller that catches +// it identifies it by `code`, and the decision function because it is the +// ruling's five binding points as one pure, directly-testable verdict. +export { + resolveSystemWriteOrganization, + resolveTenantFieldName, + isPlatformNamespaceObject, + carriesOrganization, + SystemWriteOrganizationRequiredError, + ORGANIZATION_OBJECT, + GLOBAL_TENANT, + DEFAULT_TENANT_FIELD, +} from './tenancy/system-write-organization.js'; +export type { + SystemWriteOrganizationDecision, + SystemWriteRefusalReason, +} from './tenancy/system-write-organization.js'; + // Export MetadataFacade export { MetadataFacade } from './metadata-facade.js'; diff --git a/packages/objectql/src/system-write-organization.test.ts b/packages/objectql/src/system-write-organization.test.ts new file mode 100644 index 0000000000..f5ca9c02b1 --- /dev/null +++ b/packages/objectql/src/system-write-organization.test.ts @@ -0,0 +1,363 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ── A system-context write resolves the install's organization, or is refused (#8844) ── +// +// The runtime twin of #8686. That card fixed the SEED producer of untenanted +// rows and shipped a backfill for what it had already written; this one is the +// producer that is still running — a hook, a cron job, a custom endpoint or a +// `runAs: system` flow creating an ordinary record. A backfill cannot reach it: +// it mints a fresh duplicate on every tick, which is what makes #8686's repair +// self-undoing on any install with server-side automation. +// +// ## The seam under test is `DriverOptions.tenantId`, not SQL +// +// `@objectstack/objectql` cannot import `@objectstack/driver-sql` (the +// dependency runs the other way), so this file pins the exact input both driver +// mechanisms key off — the same seam `engine-external-tenant-scope.test.ts` +// pins for the read wall. `injectTenantOnInsert` stamps the tenant column only +// when `options.tenantId` is present, and `fillAutoNumberFields` resolves its +// counter scope as `row[tenantField] ?? options.tenantId ?? null` with `null` +// collapsing to `__global__`. So "`tenantId` in DriverOptions" is precisely +// "the row is stamped and its counter is the organization's", and resolving it +// at the ENGINE is what makes the fix cover every driver at once — which +// matters twice over here, because `fillAutoNumberFields` is duplicated in +// `driver-sql` and `driver-turso`. The end-to-end consequence — one counter +// instead of two, on a real SQL database — is pinned in +// `packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts`. +// +// ## Both directions, in the same cases, deliberately +// +// A silent default is the defect; a refusal that is too broad is a different +// defect that breaks every system write on a walled install — including hooks +// and cron that run unattended, where a loud failure surfaces as a stalled +// automation rather than a 4xx someone reads. A file that pinned only the +// refusal would stay green if the engine refused EVERYTHING, and one that +// pinned only the stamp would stay green if it stamped a guess on a walled +// install. So each case below carries its discriminating control. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { ObjectQL } from './engine.js'; +import { resolveSystemWriteOrganization } from './tenancy/system-write-organization.js'; + +const ORG_ID = 'org_msokm9oaz0cal87q'; +const SECOND_ORG_ID = 'org_second'; + +/** The card's shape: a hook / cron write, elevated, carrying no organization. */ +const SYSTEM_CTX: ExecutionContext = { isSystem: true } as ExecutionContext; +/** A signed-in planner in the Console — the write path that already works. */ +const SESSION_CTX: ExecutionContext = { userId: 'u_planner', tenantId: ORG_ID }; + +interface ObservedCall { + object: string; + method: string; + data: any; + options: Record | undefined; +} + +function makeDriver(observed: ObservedCall[], organizations: string[]) { + const record = (object: string, method: string, data: any, options: any) => { + observed.push({ object, method, data, options }); + }; + return { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, _ast: any, options: any) { + record(object, 'find', undefined, options); + return object === 'sys_organization' ? organizations.map((id) => ({ id })) : []; + }, + async findOne() { return null; }, + async count() { return 0; }, + async create(object: string, data: any, options: any) { + record(object, 'create', data, options); + return { id: 'r_1', ...data }; + }, + async update(object: string, id: string, data: any, options: any) { + record(object, 'update', data, options); return { id, ...data }; + }, + async delete() { return true; }, + async bulkCreate(object: string, rows: any[], options: any) { + record(object, 'bulkCreate', rows, options); + return rows.map((r, i) => ({ id: `r_${i + 1}`, ...r })); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async syncSchema() {}, + } as any; +} + +const PACKAGE_ID = '#8844'; + +/** + * The card's own shape: an application object with an autonumber declared + * `unique` at organization scope. `unique: 'organization'` is what makes a real + * driver materialize the NULL-safe partitioned index whose two partitions is + * where the duplicates hide. + */ +const DISPATCH_ORDER = { + name: 'dispatch_order', + fields: { + subject: { type: 'text' }, + document_no: { type: 'autonumber', format: 'WI-{00000}', unique: 'organization' }, + }, +} as any; + +/** A platform-namespace object: its rows are deliberately org-less (#8672). */ +const SYS_LEDGER = { + name: 'sys_audit_entry', + fields: { subject: { type: 'text' } }, +} as any; + +/** ADR-0066: the DECLARED way to say "rows of this object belong to no org". */ +const PLATFORM_GLOBAL = { + name: 'billing_license', + tenancy: { enabled: false }, + fields: { subject: { type: 'text' } }, +} as any; + +const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as any; + +async function makeEngine(opts: { posture?: string; organizations?: string[] } = {}) { + const observed: ObservedCall[] = []; + const engine = new ObjectQL(); + engine.registerDriver(makeDriver(observed, opts.organizations ?? [ORG_ID]), true); + await engine.init(); + for (const o of [DISPATCH_ORDER, SYS_LEDGER, PLATFORM_GLOBAL, ORG_OBJECT]) { + engine.registry.registerObject(o, PACKAGE_ID); + } + if (opts.posture) engine.setTenancyPostureProvider(() => opts.posture); + return { engine, observed }; +} + +/** What the driver was handed for the LAST write of `object`. */ +const lastWrite = (observed: ObservedCall[], object: string) => + [...observed].reverse().find((c) => c.object === object && c.method !== 'find'); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('#8844 the decision — the ruling as one pure function', () => { + const probe = (ids: string[]) => () => Promise.resolve(ids); + + it('[binding point 1] single-tenant with exactly one organization derives it', async () => { + expect(await resolveSystemWriteOrganization({ posture: 'single', probeOrganizations: probe([ORG_ID]) })) + .toEqual({ kind: 'derived', organizationId: ORG_ID }); + }); + + it.each(['group', 'isolated'] as const)( + '[binding point 2] the walled posture %s refuses, without asking the database anything', + async (posture) => { + const probeOrganizations = vi.fn(async () => [ORG_ID]); + expect(await resolveSystemWriteOrganization({ posture, probeOrganizations })) + .toEqual({ kind: 'refuse', reason: 'walled-posture' }); + // The count could not change the verdict on a walled install, so it is + // not read — a refusal must not cost a query per write. + expect(probeOrganizations).not.toHaveBeenCalled(); + }, + ); + + it('[binding point 2] a `single` posture whose DATA holds several organizations refuses too', async () => { + // The posture is what the deployment asked for; the count is what the data + // is. Where they disagree there is no unambiguous answer, so the topology + // falls under the refusal rather than getting a guessed default — the same + // line #8686's backfill draws as `skipped-ambiguous-organization`. + expect(await resolveSystemWriteOrganization({ + posture: 'single', + probeOrganizations: probe([ORG_ID, SECOND_ORG_ID]), + })).toEqual({ kind: 'refuse', reason: 'ambiguous-organization', organizationCount: 2 }); + }); + + it('[first boot] no organization yet is NOT a refusal — there is nothing to fork away from', async () => { + // Seeds land during `start()`; the admin, and with them the first + // organization, arrive by a later sign-up POST. Refusing here would refuse + // first boot itself, and there is no second partition to fork from anyway — + // #8686's `sys_organization`-insert handoff adopts exactly these rows. + expect(await resolveSystemWriteOrganization({ posture: 'single', probeOrganizations: probe([]) })) + .toEqual({ kind: 'no-organization-yet' }); + }); +}); + +describe('#8844 single-tenant — the system write is stamped like a session write', () => { + it('derives the install organization for a system-context insert, and a session write is unchanged', async () => { + const { engine, observed } = await makeEngine(); + + // POSITIVE CONTROL — the card's producer: a hook / cron create with no + // organization anywhere. It now reaches the driver carrying one. + await engine.insert('dispatch_order', { subject: 'rework order' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'dispatch_order')?.options?.tenantId).toBe(ORG_ID); + + // DISCRIMINATING CONTROL — the write path that already worked. If the fix + // had changed what a session write resolves, this is where it would show. + await engine.insert('dispatch_order', { subject: 'production order' }, { context: SESSION_CTX } as any); + expect(lastWrite(observed, 'dispatch_order')?.options?.tenantId).toBe(ORG_ID); + + // Both producers now resolve the SAME organization, which is the whole + // point: one counter scope, so the two cannot mint the same number. + const tenants = observed + .filter((c) => c.object === 'dispatch_order' && c.method === 'create') + .map((c) => c.options?.tenantId); + expect(tenants).toEqual([ORG_ID, ORG_ID]); + }); + + it('resolves once per batch and stamps a batch insert', async () => { + const { engine, observed } = await makeEngine(); + await engine.insert( + 'dispatch_order', + [{ subject: 'a' }, { subject: 'b' }, { subject: 'c' }], + { context: SYSTEM_CTX } as any, + ); + expect(lastWrite(observed, 'dispatch_order')?.method).toBe('bulkCreate'); + expect(lastWrite(observed, 'dispatch_order')?.options?.tenantId).toBe(ORG_ID); + }); + + it('probes `sys_organization` once across many system writes, and re-probes after one is created', async () => { + const { engine, observed } = await makeEngine(); + for (let i = 0; i < 5; i++) { + await engine.insert('dispatch_order', { subject: `n${i}` }, { context: SYSTEM_CTX } as any); + } + const probes = () => observed.filter((c) => c.object === 'sys_organization' && c.method === 'find').length; + expect(probes()).toBe(1); + + // The one event that can change the answer drops the memo — the sign-up + // that brings an organization into existence is exactly this insert. + await engine.insert('sys_organization', { id: SECOND_ORG_ID, name: 'Second' }, { context: SYSTEM_CTX } as any); + await engine.insert('dispatch_order', { subject: 'after' }, { context: SYSTEM_CTX } as any); + expect(probes()).toBe(2); + }); + + it('[first boot] leaves the write untenanted when the install has no organization yet', async () => { + const { engine, observed } = await makeEngine({ organizations: [] }); + await engine.insert('dispatch_order', { subject: 'seeded' }, { context: SYSTEM_CTX } as any); + // Nothing to stamp and nothing refused: the row lands org-less exactly as + // before, for #8686's handoff to adopt when the organization appears. + expect(lastWrite(observed, 'dispatch_order')?.options?.tenantId).toBeUndefined(); + }); +}); + +describe('#8844 multi-organization — the write is REFUSED, never defaulted', () => { + it.each(['isolated', 'group'] as const)( + 'refuses a system-context insert on the %s posture with the ADR-0112 envelope', + async (posture) => { + const { engine, observed } = await makeEngine({ posture }); + + const refusal = await engine + .insert('dispatch_order', { subject: 'rework order' }, { context: SYSTEM_CTX } as any) + .catch((e) => e); + + // The envelope, not just the throw: a bare `toThrow()` would stay green + // for any error the engine happens to raise on this path. + expect(refusal.code).toBe('ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'); + expect(refusal.status).toBe(500); + // Loud: the condition, what would have been written, and the remedy. + expect(refusal.message).toContain('dispatch_order'); + expect(refusal.message).toContain('__global__'); + expect(refusal.message).toContain('tenantId'); + expect(refusal.message).toContain('tenancy: { enabled: false }'); + + // ⛔ NOT defaulted and ⛔ not silently skipped — nothing reached the driver. + expect(observed.filter((c) => c.object === 'dispatch_order')).toEqual([]); + }, + ); + + it('refuses when the posture says single but the data holds several organizations', async () => { + const { engine } = await makeEngine({ organizations: [ORG_ID, SECOND_ORG_ID] }); + const refusal = await engine + .insert('dispatch_order', { subject: 'ambiguous' }, { context: SYSTEM_CTX } as any) + .catch((e) => e); + expect(refusal.code).toBe('ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'); + expect(refusal.reason).toBe('ambiguous-organization'); + expect(refusal.message).toContain('2 organizations'); + }); + + it('reads the posture from the ENV when no provider is injected', async () => { + // A lean embedding mounts no SecurityPlugin, so nothing injects the live + // `tenancy` service's answer. The operator's declared posture is then the + // best fact available — and it must still be read, or a walled deployment + // without plugin-security silently falls back to the stamping branch. + vi.stubEnv('OS_TENANCY_POSTURE', 'isolated'); + const { engine } = await makeEngine(); + const refusal = await engine + .insert('dispatch_order', { subject: 'env-walled' }, { context: SYSTEM_CTX } as any) + .catch((e) => e); + expect(refusal.code).toBe('ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'); + expect(refusal.reason).toBe('walled-posture'); + }); + + it('a write that CARRIES an organization is never refused on a walled install', async () => { + // The other half of binding point 2 — "carry an explicit organization OR be + // refused". Without this case the refusal could be unconditional and the + // suite would not notice. + const { engine, observed } = await makeEngine({ posture: 'isolated' }); + + // (a) carried on the execution context + await engine.insert( + 'dispatch_order', + { subject: 'explicit ctx' }, + { context: { isSystem: true, tenantId: SECOND_ORG_ID } as ExecutionContext } as any, + ); + expect(lastWrite(observed, 'dispatch_order')?.options?.tenantId).toBe(SECOND_ORG_ID); + + // (b) carried on the record itself + await engine.insert( + 'dispatch_order', + { subject: 'explicit row', organization_id: ORG_ID }, + { context: SYSTEM_CTX } as any, + ); + expect(lastWrite(observed, 'dispatch_order')?.data?.organization_id).toBe(ORG_ID); + + // (c) a signed-in caller — the ordinary path, untouched on every posture + await engine.insert('dispatch_order', { subject: 'session' }, { context: SESSION_CTX } as any); + expect(lastWrite(observed, 'dispatch_order')?.options?.tenantId).toBe(ORG_ID); + }); + + it('a beforeInsert hook that stamps the organization has carried it', async () => { + // The resolution runs AFTER the hooks on purpose: a hook that supplies the + // organization itself must not then be refused for the value it just wrote. + const { engine, observed } = await makeEngine({ posture: 'isolated' }); + engine.on('beforeInsert', 'dispatch_order', async (ctx: any) => { + ctx.input.data.organization_id = SECOND_ORG_ID; + }); + await engine.insert('dispatch_order', { subject: 'hook-stamped' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'dispatch_order')?.data?.organization_id).toBe(SECOND_ORG_ID); + }); +}); + +describe('#8844 the exclusions — populations the refusal must not touch', () => { + it.each(['isolated', 'single'] as const)( + 'a platform-namespace object stays org-less on the %s posture', + async (posture) => { + // #8672 measured this primitive on `sys_permission_set` and filed it as an + // observation because an org-less row is defensible there. The #8844 + // ruling confirms that reasoning holds for platform objects and does NOT + // generalize to application objects — which is exactly the boundary here. + const { engine, observed } = await makeEngine({ posture }); + await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'sys_audit_entry')?.options?.tenantId).toBeUndefined(); + }, + ); + + it.each(['isolated', 'single'] as const)( + 'an object declaring `tenancy.enabled: false` stays org-less on the %s posture', + async (posture) => { + // ADR-0066 is the DECLARED way to hold deliberately org-less rows — stated + // once on the object and checkable, rather than a per-write bypass flag, + // which is the lenient-consumer accommodation PD #12 forbids. + const { engine, observed } = await makeEngine({ posture }); + await engine.insert('billing_license', { subject: 'lic' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'billing_license')?.options?.tenantId).toBeUndefined(); + }, + ); + + it('never probes `sys_organization` for a write it does not judge', async () => { + // The cost half of the exclusions: an ordinary write must pay two property + // reads and a regexp, not a query. + const { engine, observed } = await makeEngine({ organizations: [ORG_ID] }); + await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any); + await engine.insert('billing_license', { subject: 'lic' }, { context: SYSTEM_CTX } as any); + await engine.insert('dispatch_order', { subject: 'session' }, { context: SESSION_CTX } as any); + expect(observed.filter((c) => c.object === 'sys_organization')).toEqual([]); + }); +}); diff --git a/packages/objectql/src/tenancy/system-write-organization.ts b/packages/objectql/src/tenancy/system-write-organization.ts new file mode 100644 index 0000000000..e0f0c4b7e3 --- /dev/null +++ b/packages/objectql/src/tenancy/system-write-organization.ts @@ -0,0 +1,291 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * System-context write organization resolution (#8844) — the RUNTIME twin of + * #8686's seed-path fix, one layer up. + * + * ## The defect + * + * A session write carries the caller's active organization + * (`ExecutionContext.tenantId`), and the driver stamps it onto the row + * (`injectTenantOnInsert`) before the autonumber counter reads it + * (`fillAutoNumberFields`, which resolves `row[tenantField] ?? options.tenantId + * ?? null`). A SYSTEM-context write — a hook, a scheduled job, a custom + * endpoint, a `runAs: system` flow — carries no organization at all, so the + * column lands NULL and the counter files the row under the `__global__` + * pseudo-tenant. One object then runs TWO counters that cannot see each other, + * each correct within its own scope, and both hand out the same number: + * + * _objectstack_sequences, one object, one scope key: + * tenant_id = 'org_msokm9oaz0cal87q' last_value = 2 <- REST / Console + * tenant_id = '__global__' last_value = 2 <- system context + * + * The partitioned unique index — `(COALESCE(organization_id, '__global__'), + * )`, ADR-0120 D3 — cannot see across the two partitions either, so a + * field the app declared `unique` holds the same value twice with no error and + * no warning. Measured on 17.0.0 GA across five objects on one install. + * + * ⛔ This is NOT a counter bug and must never be "fixed" by making the allocator + * smarter — the reasoning `seed-tenancy-backfill.ts` records applies unchanged: + * both counters are already correct within their own scope. The defect is + * upstream of the counter, in who resolves the organization for the write. + * #8686's backfill cannot reach it either: a backfill repairs rows that exist, + * while this producer mints a fresh duplicate on every hook and every cron tick + * — which is what makes the backfill self-undoing on any install with + * server-side automation, i.e. every business app. + * + * ## The ruling (maintainer, 2026-08-15, #8844 — Option 1) + * + * A system-context write on a tenant-scoped object resolves the install's + * organization THE WAY A SESSION WRITE DOES. Three binding points shape this + * module: + * + * 1. **Single-tenant: derivable ⇒ derive and stamp.** The `__global__` fork + * must stop being minted by hooks, cron and system endpoints. + * 2. **Multi-tenant: carry an explicit organization or be REFUSED LOUDLY.** + * ⛔ Never silently default to `__global__`. A walled install has no + * derivable answer to "which organization owns this row", and guessing one + * writes a row into a tenant it may not belong to — strictly worse than + * refusing. + * 3. **Already-minted duplicates are REPORTED, never rewritten** — #8686's + * posture. Nothing here renumbers anything; this module only decides what a + * write ABOUT to happen resolves to. + * + * ## The three exclusions, and why the refusal is not broader + * + * A refusal that fires too widely breaks every system write on a walled install + * — including the hooks and cron that run unattended, where a loud failure + * surfaces as a stalled automation rather than as a 4xx someone reads. So the + * decision runs only where the harm is real, and three populations are outside + * it BY CONSTRUCTION rather than by exemption: + * + * - **Objects with no organization column, `tenancy.enabled: false` objects + * (ADR-0066), and federated objects (ADR-0015).** There is no tenant column + * to fork a counter by. The declared way for an object to hold deliberately + * org-less rows is `tenancy: { enabled: false }` — a metadata declaration, + * loud and checkable — never a per-write bypass flag, which is exactly the + * lenient-consumer accommodation Prime Directive #12 forbids. + * - **The platform namespaces `sys_` / `cloud_` / `ai_`** ({@link + * isPlatformNamespaceObject}). Their rows are deliberately global / + * cross-organization: this is #8672's reasoning ("an org-less row is + * defensible for `sys_permission_set`"), which the #8844 ruling confirms + * holds for platform objects and does NOT generalize to application objects. + * The same regexp is the seed loader's own rule for which seeds it will + * stamp, and #8686's backfill re-spells it for the same reason this module + * does: the three write paths have to agree about the platform namespace, or + * one of them manufactures a new disagreement while claiming to remove one. + * - **Writes that already carry an organization** — on the execution context + * or on the row itself. That IS "carrying an explicit organization"; the + * ruling asks for nothing more. + * + * ## INSERT only, deliberately + * + * The ruling's yardstick is "the way a session write does", and a session write + * stamps the organization on INSERT — `injectTenantOnInsert` is the insert-side + * mechanism, and no write path stamps `organization_id` onto an update. An + * update also cannot fork a counter: the number is minted once, at insert. So + * extending this to update would not be following the session write, it would + * be inventing a second rule. + */ + +import type { TenancyPosture } from '@objectstack/spec/security'; +import { SystemObjectName } from '@objectstack/spec/system'; + +/** + * The table the "how many organizations does this install have?" probe counts — + * the one protocol-level name, not a re-spelling (`SystemObjectName`). + */ +export const ORGANIZATION_OBJECT = SystemObjectName.ORGANIZATION; + +/** + * The NULL-organization sentinel the SQL driver's counter and partitioned + * unique index collapse a missing organization to (`GLOBAL_TENANT` in + * `driver-sql`, ADR-0120 D3). Named here only to make the refusal message say + * what it is refusing to write; nothing in this module produces the value. + */ +export const GLOBAL_TENANT = '__global__'; + +/** + * The tenant column the kernel injects into every tenant-scoped object + * (`TENANT_SCOPE_FIELD_DEF`, `registry.ts`), and the fallback every layer + * assumes when an object declares no `tenancy.tenantField`. + */ +export const DEFAULT_TENANT_FIELD = 'organization_id'; + +/** + * Platform namespaces whose rows are deliberately global / cross-organization + * and must never be adopted into one. + * + * The seed loader's rule verbatim (`/^(sys_|cloud_|ai_)/` in `seed-loader.ts`), + * re-spelled here rather than imported for the reason `seed-tenancy-backfill.ts` + * records about its own copy: the layers differ, the rule must not. This is the + * third write path to carry it, and the three have to stay in step — a runtime + * stamp that adopted a namespace the loader deliberately leaves global would + * reopen the seed/runtime disagreement from the other end. + */ +const PLATFORM_NAMESPACE = /^(sys_|cloud_|ai_)/; + +/** Is this object in a platform namespace whose rows stay org-less by design? */ +export function isPlatformNamespaceObject(object: string): boolean { + return PLATFORM_NAMESPACE.test(object); +} + +/** + * Resolve the column an object is tenant-scoped by, or `null` when it is not + * tenant-scoped at all. + * + * The twin of `SqlDriver.computeTenantField`, same precedence and same reading: + * an explicit `tenancy.enabled: false` opt-out wins over any column-presence + * heuristic (ADR-0066 / `isTenancyDisabled`), then a declared + * `tenancy.tenantField` that the object really has, then the kernel-injected + * `organization_id`. It is re-spelled rather than imported because the engine + * must not depend on a driver — but it has to stay the same rule: a disagreement + * here would mean the engine resolves an organization for a column the driver + * does not scope by, or refuses a write the driver would have filed globally. + */ +export function resolveTenantFieldName(schema: unknown): string | null { + const s = schema as + | { fields?: Record; tenancy?: { enabled?: boolean; tenantField?: unknown } } + | null + | undefined; + if (s?.tenancy?.enabled === false) return null; + const fields = s?.fields; + if (!fields || typeof fields !== 'object') return null; + const declared = s?.tenancy?.tenantField; + if (typeof declared === 'string' && declared !== '' && + Object.prototype.hasOwnProperty.call(fields, declared)) { + return declared; + } + if (Object.prototype.hasOwnProperty.call(fields, DEFAULT_TENANT_FIELD)) return DEFAULT_TENANT_FIELD; + return null; +} + +/** Does this value count as an organization actually supplied? */ +export function carriesOrganization(value: unknown): boolean { + return value !== undefined && value !== null && value !== ''; +} + +/** + * What a system-context write's organization resolves to. + * + * `refuse` is a decision, not a throw: the caller turns it into + * {@link SystemWriteOrganizationRequiredError} so the decision stays a pure, + * directly-testable function of the facts. + */ +export type SystemWriteOrganizationDecision = + /** + * Nothing to resolve — the install has no organization yet, which is the + * normal state of a fresh boot (seeds land during `start()`; the admin, and + * with them the first organization, arrive by a later sign-up POST). No + * organization exists, so nothing can be stamped and — crucially — nothing is + * forked either: there is no second partition to fork away from. #8686's + * `sys_organization`-insert handoff adopts exactly these rows the moment the + * answer becomes derivable. ⛔ Refusing here would refuse first boot itself. + */ + | { kind: 'no-organization-yet' } + /** Binding point 1: exactly one organization, so the answer is derivable. */ + | { kind: 'derived'; organizationId: string } + /** Binding point 2: refuse loudly rather than default to `__global__`. */ + | { kind: 'refuse'; reason: SystemWriteRefusalReason; organizationCount?: number }; + +export type SystemWriteRefusalReason = + /** + * A walled posture (`group` / `isolated`). Organizations are a real boundary + * here, so "the install's organization" is not a thing that exists. + */ + | 'walled-posture' + /** + * The posture says `single` but the data holds several organizations. The + * posture is what the deployment ASKED for; the count is what the data + * actually is, and where they disagree there is no unambiguous answer — the + * topology falls under binding point 2 rather than getting a guessed default. + * #8686's backfill draws the same line (`skipped-ambiguous-organization`). + */ + | 'ambiguous-organization'; + +/** + * Resolve the organization a system-context write should carry. + * + * `probeOrganizations` is called ONLY on the single-tenant branch — a walled + * posture is refused without asking the database anything, because the answer + * could not change the verdict. + */ +export async function resolveSystemWriteOrganization(args: { + posture: TenancyPosture; + /** Organization ids, capped at 2 by the caller — only "0 / 1 / several" matters. */ + probeOrganizations: () => Promise; +}): Promise { + if (args.posture !== 'single') { + return { kind: 'refuse', reason: 'walled-posture' }; + } + const ids = await args.probeOrganizations(); + if (ids.length === 0) return { kind: 'no-organization-yet' }; + if (ids.length === 1) return { kind: 'derived', organizationId: ids[0] }; + return { kind: 'refuse', reason: 'ambiguous-organization', organizationCount: ids.length }; +} + +/** + * Compose the refusal message. + * + * "Loudly" is a property of what the message SAYS, not of how loudly it is + * logged: it has to name the condition, what was about to be written, why that + * is worse than failing, and every way the author can fix it — because the + * reader is usually an automation author looking at a stalled cron job, not an + * HTTP client reading a response body. + */ +function buildRefusalMessage( + object: string, + posture: TenancyPosture, + reason: SystemWriteRefusalReason, + organizationCount: number | undefined, +): string { + const condition = + reason === 'walled-posture' + ? `this install runs the '${posture}' tenancy posture, where organizations are an enforced ` + + `boundary and there is no single "install organization" to derive` + : `this install declares the 'single' tenancy posture but holds ` + + `${organizationCount ?? 'several'} organizations, so which one owns the row is not derivable ` + + `(exactly 1 is required to adopt one without guessing)`; + return ( + `Insert on '${object}' was REFUSED: a system-context write on a tenant-scoped object must carry an ` + + `organization, and ${condition}. Writing it anyway would store ` + + `${DEFAULT_TENANT_FIELD} = NULL, which the autonumber counter and the partitioned unique index ` + + `(COALESCE(${DEFAULT_TENANT_FIELD}, '${GLOBAL_TENANT}'), ) both collapse to the ` + + `'${GLOBAL_TENANT}' pseudo-tenant — a second counter that cannot see the organization's own, so a ` + + `field declared unique silently gets the same value twice (#8844). Nothing was written. Fix it by ` + + `carrying the organization the way a session write does: pass it on the execution context ` + + `({ context: { isSystem: true, tenantId: '' } }), or set ${DEFAULT_TENANT_FIELD} ` + + `on the record itself. If rows of '${object}' are genuinely platform-global and belong to no ` + + `organization, declare that on the OBJECT — tenancy: { enabled: false } (ADR-0066) — so it is ` + + `stated once and checkable, rather than decided per write.` + ); +} + +/** + * Binding point 2's refusal. + * + * Identified by `code` rather than `instanceof`, the convention every engine + * error here follows so the check survives crossing a package boundary where + * two copies of this module can exist. `status` is 500 deliberately: the write + * is refused because SERVER-SIDE code (a hook, a scheduled job, a custom + * endpoint) did not thread an organization — an HTTP client that happened to + * trigger it did nothing wrong, and answering 4xx would blame the wrong party + * and mark the fault `isExpectedDataStatus`, which stops it being logged at all. + * The declared 5xx withholds the prose from the wire (#5437) and keeps the + * machine-readable `code`; the prose reaches the person who can act on it + * through the engine's own ERROR log. + */ +export class SystemWriteOrganizationRequiredError extends Error { + readonly code = 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED' as const; + readonly status = 500; + + constructor( + public readonly object: string, + public readonly posture: TenancyPosture, + public readonly reason: SystemWriteRefusalReason, + public readonly organizationCount?: number, + ) { + super(buildRefusalMessage(object, posture, reason, organizationCount)); + this.name = 'SystemWriteOrganizationRequiredError'; + } +} diff --git a/packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts b/packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts new file mode 100644 index 0000000000..03a4ba432a --- /dev/null +++ b/packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts @@ -0,0 +1,252 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8844 — a SYSTEM-context write landed untenanted at RUNTIME, so one + * single-tenant install kept re-forking the autonumber scope and minting + * duplicate business identifiers that #8686's backfill could not reach. + * + * ## Why this file runs REAL implementations end to end + * + * The same reason `seed-tenancy-autonumber-split.integration.test.ts` does, one + * layer up: every part of this defect lives in the seam BETWEEN components, and + * each component is individually correct. + * + * - the engine correctly declines to invent an organization for a caller that + * supplied none; + * - the SQL driver correctly keys its counter by `organization_id` + * (`__global__` when NULL), and each counter is correct within its scope; + * - the unique index correctly enforces `(COALESCE(organization_id, + * '__global__'), )`, which is what the declaration asks for. + * + * Three correct components, one broken outcome — so this file uses a real + * `ObjectQL` engine over a real `SqlDriver` on better-sqlite3, and asserts on + * the DATABASE: the stored rows, `_objectstack_sequences`, and the duplicate + * count. `packages/objectql`'s own `system-write-organization.test.ts` pins the + * decision and the `DriverOptions` seam; this file pins the CONSEQUENCE, which + * is the thing the card actually reports. + * + * ## What is pinned + * + * The 2026-08-15 maintainer ruling settled **Option 1** — a system-context + * write on a tenant-scoped object resolves the install's organization the way a + * session write does. Its two operative binding points are separate cases here + * because a fix satisfying one would be indistinguishable from a fix satisfying + * both: + * + * 1. single-tenant ⇒ derive and stamp; the `__global__` fork stops being + * minted by hooks, cron and system endpoints; + * 2. multi-organization ⇒ carry an explicit organization or be REFUSED + * LOUDLY; ⛔ never silently default to `__global__`. + * + * ⛔ Deliberately NOT asserted anywhere below: anything about how the allocator + * picks its next number. Both counters are already correct within their own + * scopes — the defect is upstream of them, and #6249's counter-side remedy is + * ruled out for exactly that reason. Every assertion here is about which + * PARTITION a row is in and which counter describes it. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; + +const ORG_ID = 'org_msokm9oaz0cal87q'; +const SECOND_ORG_ID = 'org_second'; +const GLOBAL_TENANT = '__global__'; + +/** + * The card's own object: the base table every dispatch category lands in, with + * an autonumber declared `unique` at organization scope. `unique: 'organization'` + * is what makes the driver materialize the NULL-safe partitioned index (ADR-0120 + * D3) — the index whose two partitions is where the duplicates hide. + */ +const DISPATCH_ORDER = { + name: 'dispatch_order', + fields: { + subject: { type: 'text' }, + organization_id: { type: 'text' }, + document_no: { type: 'autonumber', format: 'WI-{00000}', unique: 'organization' }, + }, +} as any; + +const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as any; + +const openDrivers: SqlDriver[] = []; + +async function bootInstall() { + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + openDrivers.push(driver); + const engine = new ObjectQL(); + engine.registerDriver(driver as any, true); + await engine.init(); + engine.registry.registerObject(DISPATCH_ORDER, '#8844'); + engine.registry.registerObject(ORG_OBJECT, '#8844'); + await driver.initObjects([DISPATCH_ORDER, ORG_OBJECT]); + return { driver, engine }; +} + +/** The sign-up that first brings the install's organization into existence. */ +const createOrganization = (engine: ObjectQL, id = ORG_ID) => + engine.insert('sys_organization', { id, name: id }, { context: { isSystem: true } } as any); + +/** + * The card's first producer: a quality-verdict HOOK / the "maintenance overdue" + * CRON job. Elevated, unattended, and carrying no organization at all. + */ +const systemWrite = (engine: ObjectQL, subject: string) => + engine.insert('dispatch_order', { subject }, { context: { isSystem: true } } as any); + +/** + * The card's second producer: a PLANNER in the Console. A signed-in session + * carries the active organization on the execution context — nothing is set on + * the row, exactly as a REST create does it. + */ +const sessionWrite = (engine: ObjectQL, subject: string) => + engine.insert( + 'dispatch_order', + { subject }, + { context: { userId: 'u_planner', tenantId: ORG_ID } } as any, + ); + +const readSequences = async (driver: any) => + (await driver.knex('_objectstack_sequences').select('tenant_id', 'last_value')).map((r: any) => ({ + tenant: String(r.tenant_id), + lastValue: Number(r.last_value), + })); + +const readDuplicates = async (driver: any) => + driver + .knex('dispatch_order') + .select('document_no') + .count({ holders: '*' }) + .groupBy('document_no') + .having(driver.knex.raw('count(*) > 1')); + +const countUntenanted = async (driver: any) => + Number((await driver.knex('dispatch_order').whereNull('organization_id').count({ n: '*' }))[0].n); + +afterEach(async () => { + while (openDrivers.length) { + const d = openDrivers.pop(); + try { + await d?.disconnect(); + } catch { + /* a test that already disconnected is not a failure */ + } + } + vi.unstubAllEnvs(); +}); + +describe('#8844 system-context writes — the runtime autonumber fork', () => { + it('[binding point 1] a single-tenant install runs ONE counter across both producers', async () => { + const { driver, engine } = await bootInstall(); + await createOrganization(engine); + + // The exact interleaving the card measured on `notification`: a cron job and + // a user action creating on the same object, alternately. + const numbers: string[] = []; + for (let i = 0; i < 5; i++) { + numbers.push((await systemWrite(engine, `cron ${i}`)).document_no); + numbers.push((await sessionWrite(engine, `planner ${i}`)).document_no); + } + + // Every number distinct, in one unbroken run — the two producers are drawing + // from the same counter instead of two that cannot see each other. + expect(new Set(numbers).size).toBe(numbers.length); + expect(numbers).toEqual([ + 'WI-00001', 'WI-00002', 'WI-00003', 'WI-00004', 'WI-00005', + 'WI-00006', 'WI-00007', 'WI-00008', 'WI-00009', 'WI-00010', + ]); + + // ONE sequence row, under the real organization. The `__global__` fork the + // card reports is simply not minted any more. + expect(await readSequences(driver)).toEqual([{ tenant: ORG_ID, lastValue: 10 }]); + + // The system-written rows carry the organization exactly as the session ones + // do — the ruling's "the way a session write does", asserted on the data. + expect(await countUntenanted(driver)).toBe(0); + + // The card's headline symptom: two records, same value on a field the app + // declared unique, no error and no warning. + expect(await readDuplicates(driver)).toEqual([]); + }); + + it('[binding point 2] a walled install REFUSES the untenanted system write instead of forking', async () => { + const { driver, engine } = await bootInstall(); + await createOrganization(engine); + await createOrganization(engine, SECOND_ORG_ID); + + // The posture the boot banner prints, read from the one protocol-level + // source. `isolated` is a walled (multi-organization) shape, where "the + // install's organization" is not a thing that exists. + vi.stubEnv('OS_TENANCY_POSTURE', 'isolated'); + + const refusal = await systemWrite(engine, 'unattended cron').catch((e) => e); + + // The ADR-0112 envelope, not merely "it threw": a bare throw assertion would + // stay green for any unrelated failure on this path. + expect(refusal.code).toBe('ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'); + expect(refusal.status).toBe(500); + expect(refusal.message).toContain('dispatch_order'); + expect(refusal.message).toContain(GLOBAL_TENANT); + + // ⛔ Not defaulted: no row, and — the assertion that distinguishes "refused" + // from "wrote it under `__global__` and then threw" — no `__global__` + // counter was allocated either. + expect(await countUntenanted(driver)).toBe(0); + expect(await readSequences(driver)).toEqual([]); + + // DISCRIMINATING CONTROL, on the same walled install: a write that CARRIES + // an organization is not refused. Without this the refusal could be + // unconditional and every assertion above would still pass. + const carried = await engine.insert( + 'dispatch_order', + { subject: 'explicit org' }, + { context: { isSystem: true, tenantId: SECOND_ORG_ID } } as any, + ); + expect(carried.document_no).toBe('WI-00001'); + expect(await readSequences(driver)).toEqual([{ tenant: SECOND_ORG_ID, lastValue: 1 }]); + }); + + it('[first boot] before any organization exists nothing is refused, and #8686 still adopts the rows', async () => { + // Seeds and boot-time system writes land before the admin signs up, so there + // is no organization to derive — and no second partition to fork away from + // either. Refusing here would refuse first boot itself. This is the case + // that keeps #8844's refusal from swallowing #8686's handoff seam. + const { driver, engine } = await bootInstall(); + + await systemWrite(engine, 'boot-time'); + expect(await countUntenanted(driver)).toBe(1); + expect(await readSequences(driver)).toEqual([{ tenant: GLOBAL_TENANT, lastValue: 1 }]); + + // The sign-up arrives; from here on the runtime producer is stamped, which + // is what stops the split REOPENING after #8686's backfill has closed it. + await createOrganization(engine); + await systemWrite(engine, 'after sign-up'); + expect(await countUntenanted(driver)).toBe(1); // still just the boot-time row + expect(await readSequences(driver)).toEqual([ + { tenant: GLOBAL_TENANT, lastValue: 1 }, + { tenant: ORG_ID, lastValue: 1 }, + ]); + }); + + it('[#8686 regression] the repaired install no longer re-splits on the next system write', async () => { + // The residual the card is actually about: #8686's backfill leaves a + // repaired install with ONE counter, and before this fix the very next + // hook/cron write re-created the `__global__` row and reopened the split — + // making the backfill self-undoing on any install with automation. + const { driver, engine } = await bootInstall(); + await createOrganization(engine); + for (let i = 0; i < 3; i++) await sessionWrite(engine, `seeded ${i}`); + expect(await readSequences(driver)).toEqual([{ tenant: ORG_ID, lastValue: 3 }]); + + await systemWrite(engine, 'the next cron tick'); + + // Still one counter, continuing the same run — not a second one restarting. + expect(await readSequences(driver)).toEqual([{ tenant: ORG_ID, lastValue: 4 }]); + expect(await readDuplicates(driver)).toEqual([]); + }); +}); diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 99b0cf1962..40acd2fe62 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -352,6 +352,15 @@ export const ERROR_CODE_LEDGER = { 'INVALID_METADATA', 'ERR_READONLY_FIELD_REJECTED', // strictReadonlyWrites: the write would strip caller-supplied fields, so it was refused (#5126; since #6437 that covers the primary_key strip too — one code, `drops` carries the per-reason breakdown) 'ERR_SUMMARY_RECOMPUTE', + // [#8844] A system-context insert on a tenant-scoped application object + // carried no organization on a MULTI-organization install (a walled + // posture, or a `single` posture whose data holds several organizations). + // The ruled alternative to silently filing the row under the `__global__` + // pseudo-tenant, which forks the autonumber counter and the partitioned + // unique index and mints duplicate business identifiers. Not a synonym of + // any standard member: it is neither the client's bad input (the caller is + // server-side automation) nor a missing precondition on a request. + 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED', 'VALIDATION_FAILED', ], '@objectstack/core': [