diff --git a/.changeset/publish-refuses-non-canonical-stored-type.md b/.changeset/publish-refuses-non-canonical-stored-type.md new file mode 100644 index 0000000000..e82f00878c --- /dev/null +++ b/.changeset/publish-refuses-non-canonical-stored-type.md @@ -0,0 +1,92 @@ +--- +"@objectstack/metadata-protocol": patch +"@objectstack/metadata-core": patch +"@objectstack/spec": patch +--- + +fix(metadata): a package publish refuses a draft stored under a non-canonical metadata type, and the ADR-0010 audit writer asserts its `type` instead of folding it (#8908) + + + +**Two tightenings, one card, because they are the same defect at two layers.** + +`publishPackageDrafts` reads `sys_metadata` rows **at rest**, so #7894's `/meta` +boundary fold never reached it. `promoteDraftForPublish` folds the stored +spelling through `PLURAL_TO_SINGULAR` — the *manifest-collection* map, which +legitimately omits types that are not stack collections. For those the fold is a +**no-op**: the lookup key equals the stored spelling, the draft resolves, and the +publish mints an ACTIVE row in the namespace `PUT /meta/field/…` answers +403 NOT_OVERRIDABLE for. Measured on the card with the real repository over a +stub engine: + +``` +publishPackageDrafts({ packageId: 'app.demo' }) + → { success: true, publishedCount: 1, published: [{ type: 'fields', name: 'legacy_field' }] } +active row: { type: 'fields', name: 'legacy_field', package_id: 'app.demo' } +audit row: { type: 'fields', name: 'legacy_field', outcome: 'allowed', code: 'ok' } +``` + +Every registry read and every compliance query on `field` misses an item the +platform just reported as published — the #4432 shadowing shape, minted at +publish time instead of at the URL, and the last route by which a pre-#7894 row +could be re-promoted rather than migrated. + +**1. The publish refuses it, at the pre-flight, batch-atomically.** Same shape as +the ADR-0028 namespace-prefix gate that already stands there: found before +anything is promoted, failing the whole batch (`publishedCount: 0`, +`published: []`) rather than publishing the healthy siblings around it, with one +audit row per violation. The refusal names the row, names the canonical type, and +states the re-author path; `failed[].code` is the new +`STORED_TYPE_NOT_CANONICAL`, and the audit column's spelling is +`stored_type_not_canonical`. + +The rule is **derived, not a list**: a spelling the platform's URL/registry map +folds elsewhere *and* the manifest map leaves unchanged. Against the real maps +that is **six** spellings — `fields`, `seeds`, `external_catalogs`, +`externalCatalogs`, `translations`, `email_templates` — where the card named +four; the last two would have been missing from any hand-written list, and a +newly declared type that never reaches the manifest map is covered on the day it +is declared. A manifest-**present** plural (`objects`) is deliberately *not* in +the class: it is already fail-closed at the promote (`NO_DRAFT`, batch aborted) +and keeps that verdict. + +⛔ Deliberately **not** included: migrating the row (a `_migrate-stored` / +boot-reconciliation conversion). That was the other option on the card and is +explicitly unruled — it stays available as a follow-up with its own appetite. + +**2. `recordMetadataAudit` refuses a non-canonical `type` (`AUDIT_TYPE_NOT_CANONICAL`) +instead of folding it.** The writer used to open with +`type: PLURAL_TO_SINGULAR[entry.type] ?? entry.type` — a lenient consumer, and a +**tolerant-and-incomplete** one: the fold read the same manifest map, so the +compliance trail came out canonical for the 29 types that never needed it and +non-canonical for exactly the ones that did. Ruled the same direction as the +refusal above: **fold at the boundary, assert at the writer.** Every call site +that builds a row out of an at-rest `type` — all of them on +`publishPackageDrafts` — now folds with `canonicalMetaType`; the `/meta` routes +were already canonical by the time they got there. The throw sits **outside** the +writer's best-effort `try`, because inside it the method's own `catch` would +degrade the assert into a `console.warn`. + +The assert cannot refuse a canonical type (no canonical spelling folds +elsewhere — 33 of 33, measured) nor a plugin-registered or otherwise +unrecognised kind (`canonicalMetaType` is the identity for anything the static +map does not carry), so it narrows the accept set without closing it. + +**Reachability was enumerated before the assert landed**, as the ruling required: +`recordMetadataAudit` is private to `protocol.ts` with 11 call sites, `sys_metadata` +rows have exactly one producer in the repository (`saveMetaItem` → `repo.put`, +post-fold), and no current write path can mint a non-canonical stored type. The +only non-canonical types that ever reached an audit write came from the batch +publish's at-rest rows, which is what the boundary folds now cover. + +Also fixed, as a consequence of that fold rather than as a separate change: on +the batch route `getEffectiveLock`'s overlay limb was queried with the raw stored +spelling, so an ADR-0010 `_lock` carried by the canonical active row was looked +up under a `type` no row has and came back `'none'` — the verdict "the author +declared no protection". That is the batch twin of the hole #8769 closed on +`publishMetaItem`. diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index 19ebb4a134..126ce17d6f 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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 a83481d89b..0e14344322 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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 0e216cd56e..ef8692adf0 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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 9e011611ce..43faa5cd4a 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' \| … +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). | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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 7c967088f6..4173f8bafc 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' \| … +263 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' \| … +265 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 | @@ -102,6 +102,7 @@ const result = ApiErrorSchema.parse(data); * `ATTACHMENT_DOWNLOAD_DENIED` * `ATTACHMENT_PARENT_ACCESS` * `AUDIENCE_NOT_ALLOWED` +* `AUDIT_TYPE_NOT_CANONICAL` * `AUTH_CONFIG_ERROR` * `AUTH_REQUIRED` * `AUTOMATION_UNSCOPED_RUN_DATA_ACCESS` @@ -283,6 +284,7 @@ const result = ApiErrorSchema.parse(data); * `SIGN_IN_REQUIRED` * `SSO_REGISTER_FAILED` * `SSO_REGISTER_FORBIDDEN` +* `STORED_TYPE_NOT_CANONICAL` * `STORE_UNAVAILABLE` * `SUGGESTION_CONFIRM_FAILED` * `SUGGESTION_DISMISS_FAILED` @@ -317,7 +319,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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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 | @@ -356,7 +358,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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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 | @@ -398,7 +400,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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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 | @@ -450,7 +452,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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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 | @@ -466,7 +468,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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | | **index** | `number` | optional | Index in original request | | **data** | `any` | optional | Result data (e.g. created record) | @@ -505,7 +507,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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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 a400339cfe..4fdb907e32 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -204,6 +204,7 @@ const result = ErrorCode.parse(data); * `ATTACHMENT_DOWNLOAD_DENIED` * `ATTACHMENT_PARENT_ACCESS` * `AUDIENCE_NOT_ALLOWED` +* `AUDIT_TYPE_NOT_CANONICAL` * `AUTH_CONFIG_ERROR` * `AUTH_REQUIRED` * `AUTOMATION_UNSCOPED_RUN_DATA_ACCESS` @@ -385,6 +386,7 @@ const result = ErrorCode.parse(data); * `SIGN_IN_REQUIRED` * `SSO_REGISTER_FAILED` * `SSO_REGISTER_FORBIDDEN` +* `STORED_TYPE_NOT_CANONICAL` * `STORE_UNAVAILABLE` * `SUGGESTION_CONFIRM_FAILED` * `SUGGESTION_DISMISS_FAILED` diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index 95f82398e3..6853bc0373 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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 62096708dd..d5ba419cea 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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 18f1242266..d7c1e0d162 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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 91e3a846ac..04297a4c68 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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 ef0049be76..4b4b52f82b 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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' \| … +267 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +269 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/metadata-core/src/objects/sys-metadata-audit.object.ts b/packages/metadata-core/src/objects/sys-metadata-audit.object.ts index fd7c2e3119..505f025848 100644 --- a/packages/metadata-core/src/objects/sys-metadata-audit.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata-audit.object.ts @@ -79,7 +79,19 @@ export const SysMetadataAuditObject = ObjectSchema.create({ maxLength: 128, }), - /** Metadata type (singular, e.g. `app`, `object`, `view`). */ + /** + * Metadata type (singular, e.g. `app`, `object`, `view`). + * + * [#8908] "Singular" is now ENFORCED at the writer rather than repaired by + * it. `recordMetadataAudit` used to fold its caller's spelling through the + * manifest-collection map — which omits the types that are not stack + * collections, so the trail came out canonical for the 29 types that never + * needed it and non-canonical for the handful that did. It now REFUSES a + * non-canonical `type` (`AUDIT_TYPE_NOT_CANONICAL`) and each call site folds + * at its own boundary. This column is therefore readable through + * `GET /meta/:type/:name/audit`, whose `:type` segment is folded at the + * `/meta` door — which a row filed under a plural spelling was not. + */ type: Field.text({ label: 'Metadata Type', required: true, @@ -123,9 +135,20 @@ export const SysMetadataAuditObject = ObjectSchema.create({ * - on `allowed`: `'ok'` * - on `denied`: `'not_overridable'` | `'not_creatable'` | * `'item_locked'` | `'invalid_metadata'` | `'destructive_change'` | - * `'metadata_conflict'` | `'batch_aborted'` | `'namespace_prefix'` + * `'metadata_conflict'` | `'batch_aborted'` | `'namespace_prefix'` | + * `'stored_type_not_canonical'` * - on `forced`: `'lock_override'` (Phase 3) * + * `stored_type_not_canonical` (#8908) is the second PRE-FLIGHT refusal, and + * the reason this list is worth reading beside `type` below: a package draft + * stored under a non-canonical metadata type (`fields`, `seeds`, + * `external_catalogs`, `externalCatalogs`, `translations`, + * `email_templates` — pre-#7894 second-namespace residue) is refused before + * anything is promoted, because publishing it would mint an ACTIVE row in a + * namespace no registry read and no compliance query on the canonical type + * can see. ⚠️ The row it leaves here is keyed on the CANONICAL type, with + * the stored spelling in `note` — see `type` below. + * * `namespace_prefix` (#8595) is a PRE-FLIGHT refusal of a package publish — * the ADR-0028 namespace rule rejecting an object draft's name before * anything is promoted. It is the one refusal class on that route that diff --git a/packages/metadata-protocol/src/protocol.publish-stored-type-canonical.test.ts b/packages/metadata-protocol/src/protocol.publish-stored-type-canonical.test.ts new file mode 100644 index 0000000000..b6f1f3f806 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.publish-stored-type-canonical.test.ts @@ -0,0 +1,552 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#8908] TWO tightenings, deliberately one card: the batch publish refuses a +// draft stored under a non-canonical metadata type, and the ADR-0010 audit +// writer refuses one instead of silently folding it. +// +// --------------------------------------------------------------------------- +// The defect, as measured on the card +// --------------------------------------------------------------------------- +// `publishPackageDrafts` reads `sys_metadata` rows AT REST, so #7894's `/meta` +// boundary fold never reaches it. `promoteDraftForPublish` folds the stored +// spelling through `PLURAL_TO_SINGULAR` — the MANIFEST-collection map, which +// legitimately omits types that are not stack collections. For those the fold +// is a NO-OP: the lookup key equals the stored spelling, the draft resolves, +// and the publish mints an ACTIVE row in the namespace `PUT /meta/field/…` +// answers 403 NOT_OVERRIDABLE for. Every registry read and every compliance +// query on `field` then misses an item the platform just reported as published. +// +// The ADR-0010 row recorded the same plural, because `recordMetadataAudit` +// re-folded through the same incomplete map: tolerant AND incomplete — canonical +// for the 29 types that never needed it, non-canonical for the ones that did. +// +// --------------------------------------------------------------------------- +// The class is SIX spellings, not the four the card lists +// --------------------------------------------------------------------------- +// Derived from the real maps rather than hand-listed (see +// `isNonCanonicalStoredType`): `fields`, `seeds`, `external_catalogs`, +// `externalCatalogs`, `translations`, `email_templates`. The last two are the +// reason the production rule is a predicate — a hand-written list of four would +// have shipped with them missing and nothing would have said so. This file pins +// one from the card's own list (`fields`) and one it does not name +// (`email_templates`), so a regression toward a hard-coded list is visible. +// +// --------------------------------------------------------------------------- +// What is deliberately NOT refused, and why the control matters +// --------------------------------------------------------------------------- +// A manifest-PRESENT plural (`objects`) is already fail-closed by a different +// mechanism: the promote addresses the row by its folded singular, `whereFor` +// emits that spelling with no at-rest fallback, and the batch aborts on +// `NO_DRAFT` (pinned, with its reasoning, in +// `protocol.publish-side-effects-canonical-type.test.ts`). #8908 rules ONE +// class; widening this gate over that one would change a wire-visible +// `failed[].code` for rows that are not this defect. The `objects` case below +// is the boundary marker for that decision — if a later change makes the gate +// "simpler" by dropping the manifest limb, this file says so. +// +// --------------------------------------------------------------------------- +// Ablation directions, predicted BEFORE running (results in the PR body) +// --------------------------------------------------------------------------- +// The two tightenings are independent limbs and are ablated SEPARATELY: +// +// 1. Pre-flight gate removed (writer assert intact) → the Zone A refusal +// cases go RED; every Zone B case stays GREEN. +// ⚠️ Predicted NOT as "the row publishes": with the boundary fold at the +// promote still in place the row is looked up under `field` and misses, so +// the expected red is the WRONG REFUSAL, not a success. +// MEASURED: 4 red / 8 green, and the wrong verdict is a DIFFERENT one per +// type — `fields` dies at the draftability gate +// (`[not_overridable] Metadata type 'field' is not draftable`) while +// `email_templates` dies at the row lookup +// (`[no_draft] No pending draft exists for email_template/welcome`). Both +// fail-closed, neither naming the residue the operator actually has; and +// the batch case additionally reports the HEALTHY sibling in `failed[]` +// (`['ticket', 'legacy_field']`), because the abort now happens inside the +// transaction instead of above it. That spread is the argument for the +// pre-flight placement, measured rather than asserted. +// 2. Writer assert removed, silent fold restored → the Zone B refusal +// cases go RED; Zone A stays GREEN (its rows are folded at the call site, +// which the fold's return makes redundant but not wrong). +// MEASURED: 3 red / 9 green, each red as `promise resolved "undefined" +// instead of rejecting` — i.e. the writer silently accepting the spelling, +// which is the defect verbatim. +// +// Every "refused" case is paired with a positive control in the same describe: +// a blanket rule here would break ordinary publishing, and a refusal pin with +// no control cannot show the rule is selective. + +import { describe, expect, it } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions, imported from +// `@objectstack/metadata-core` and NOT from `@objectstack/objectql`: objectql +// depends on this package, so that import would close a dependency cycle turbo +// rejects outright. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum?: string; + version?: number; +} + +interface HistoryRow { + id: string; + type: string; + name: string; + version: number; + organization_id: string | null; + operation_type: string; + recorded_at: string; +} + +/** ADR-0048 overlay key — (type, name, org, state, package_id). */ +const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; + +function matchesWhere(r: Row, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + const clauses = v as Array>; + if (!clauses.some((c) => matchesWhere(r, c))) return false; + continue; + } + if (v === undefined) continue; + if ((r as unknown as Record)[k] !== v) return false; + } + return true; +} + +type Harness = { + engine: any; + rows: Map; + /** Audit rows that LANDED (this harness's transaction really rolls back). */ + auditRows: any[]; + /** + * Every insert AIMED at `sys_metadata_audit`, never rolled back. The + * observation channel that separates "the writer refused before inserting" + * from "it inserted and the row was undone". + */ + auditAttempts: any[]; +}; + +/** + * ⚠️ NOT the usual `if (table === 'sys_metadata_audit') return { id: 'audit_skip' }` + * stub. Zone B is entirely about what the audit writer does with its input, and + * a short-circuited insert reports "no rows" identically before and after the + * fix. This engine persists audit rows like any other table. + */ +function makeStubEngine(): Harness { + const rows = new Map(); + const historyRows: HistoryRow[] = []; + const auditRows: any[] = []; + const auditAttempts: any[] = []; + let nextId = 0; + let txDepth = 0; + + const findRow = (w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + if (w.package_id !== undefined) { + const k = keyOf(w); + const r = rows.get(k); + return r ? { key: k, row: r } : null; + } + for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r }; + return null; + }; + + const matchesHistory = (h: HistoryRow, w: Record): boolean => { + if (w.organization_id !== undefined && h.organization_id !== w.organization_id) return false; + if (w.type !== undefined && h.type !== w.type) return false; + if (w.name !== undefined && h.name !== w.name) return false; + if (w.version !== undefined && h.version !== w.version) return false; + if (w.operation_type !== undefined && h.operation_type !== w.operation_type) return false; + return true; + }; + + const engine: any = { + async findOne(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_history') { + return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null; + } + if (table === 'sys_metadata_commit') return null; + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_audit') { + // The read side `auditMetaItem` uses — so this file can assert + // through the same door `GET /meta/:type/:name/audit` serves from. + return auditRows.filter((a) => { + if (opts.where?.type !== undefined && a.type !== opts.where.type) return false; + if (opts.where?.name !== undefined && a.name !== opts.where.name) return false; + return true; + }); + } + if (table === 'sys_metadata_history') { + return historyRows.filter((h) => matchesHistory(h, opts.where)); + } + if (table === 'sys_metadata_commit') return []; + return Array.from(rows.values()).filter((r) => matchesWhere(r, opts.where)); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_audit') { + auditAttempts.push(data); + nextId += 1; + const a = { id: `a_${nextId}`, ...(data as any) }; + auditRows.push(a); + return { id: a.id }; + } + if (table === 'sys_metadata_history') { + nextId += 1; + const h: HistoryRow = { id: `h_${nextId}`, ...(data as any) }; + historyRows.push(h); + return { id: h.id }; + } + if (table === 'sys_metadata_commit') return { id: `c_${(nextId += 1)}` }; + nextId += 1; + const row = { id: `r_${nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as any) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(_t: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + const found = findRow(opts.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + /** + * A transaction that REALLY ROLLS BACK — ADR-0067 D2 atomicity is half + * of what Zone A asserts, and a passthrough would make "nothing landed" + * unfalsifiable. `auditAttempts` is deliberately outside the snapshot: + * it models an observer outside the database. + */ + async transaction(cb: (ctx: any, info: { owned: boolean }) => Promise): Promise { + const owned = txDepth === 0; + const snapshot = owned + ? { rows: new Map(rows), historyRows: [...historyRows], auditRows: [...auditRows] } + : null; + txDepth += 1; + try { + return await cb(undefined, { owned }); + } catch (err) { + if (snapshot) { + rows.clear(); + for (const [k, v] of snapshot.rows) rows.set(k, v); + historyRows.length = 0; + historyRows.push(...snapshot.historyRows); + auditRows.length = 0; + auditRows.push(...snapshot.auditRows); + } + throw err; + } finally { + txDepth -= 1; + } + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + getPackage: () => undefined, + }, + }; + return { engine, rows, auditRows, auditAttempts }; +} + +function makeProtocol() { + const h = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(h.engine, () => new Map(), 'env_prod') as any; + return { protocol, h }; +} + +const PKG = 'app.demo'; + +/** An object body that clears the authoring gates a publish runs (#8308 OWD + a field). */ +const objectBody = (name: string) => ({ + name, + label: 'Ticket', + sharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' } }, +}); + +/** + * Write a draft row through the REPOSITORY, which stamps `type` exactly as + * given. The only way to produce a row whose stored spelling is non-canonical: + * `saveMetaItem` folds before it persists, and has since #7894. This is what a + * row minted through the pre-#7894 plural URL door looks like at rest — nothing + * rewrites it on upgrade. + */ +async function seedDraftRowVerbatim( + protocol: any, + args: { type: string; name: string; body: unknown; packageId: string }, +): Promise { + await protocol.ensureOverlayIndex(); + const repo = protocol.getOverlayRepo(null); + await repo.put( + { type: args.type, name: args.name, org: 'env' }, + args.body, + { + parentVersion: null, + actor: null, + source: 'test.at-rest-residue', + intent: 'runtime-only', + state: 'draft', + packageId: args.packageId, + }, + ); +} + +const activeRows = (h: Harness) => [...h.rows.values()].filter((r) => r.state === 'active'); +const deniedRows = (h: Harness) => h.auditRows.filter((a) => a.outcome === 'denied'); + +// ═══════════════════════════════════════════════════════════════════════════ +// Zone A — the promote refusal (ruling option (a)) +// ═══════════════════════════════════════════════════════════════════════════ +describe('[#8908] publishPackageDrafts refuses a draft stored under a non-canonical type', () => { + it('refuses `fields` at the pre-flight — named, actionable, nothing promoted', async () => { + const { protocol, h } = makeProtocol(); + await seedDraftRowVerbatim(protocol, { + type: 'fields', name: 'legacy_field', body: { type: 'text', label: 'Legacy' }, packageId: PKG, + }); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res.success).toBe(false); + expect(res.publishedCount).toBe(0); + expect(res.published).toEqual([]); + expect(res.failed).toHaveLength(1); + // The PRE-FLIGHT verdict, not a downstream miss. `NO_DRAFT` here would + // mean the gate never ran and the row died at the promote instead — + // fail-closed, but with the verdict the ruling rejected as unhelpful. + expect(res.failed[0]).toMatchObject({ + type: 'fields', name: 'legacy_field', code: 'STORED_TYPE_NOT_CANONICAL', + }); + // Names the row, names the canonical spelling, says what to do. + expect(res.failed[0].error).toContain("'fields/legacy_field'"); + expect(res.failed[0].error).toContain("'field'"); + expect(res.failed[0].error).toContain('_migrate-stored'); + // The whole point: no ACTIVE row in the second namespace. + expect(activeRows(h)).toEqual([]); + expect([...h.rows.values()].some((r) => r.type === 'fields' && r.state === 'active')).toBe(false); + }); + + it('the refusal leaves an audit row keyed on the CANONICAL type, with the stored spelling in `note`', async () => { + const { protocol, h } = makeProtocol(); + await seedDraftRowVerbatim(protocol, { + type: 'fields', name: 'legacy_field', body: { type: 'text', label: 'Legacy' }, packageId: PKG, + }); + + await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(deniedRows(h)).toHaveLength(1); + expect(deniedRows(h)[0]).toMatchObject({ + // `field`, NOT `fields`: `auditMetaItem` serves + // `GET /meta/:type/:name/audit` through a folded `:type`, so a row + // filed under the plural is a row no compliance query can reach. + type: 'field', + name: 'legacy_field', + operation: 'publish', + outcome: 'denied', + // adr0112-ok: D6b — persisted audit column, its own lowercase vocabulary + code: 'stored_type_not_canonical', + source: 'protocol.publishPackageDrafts', + }); + // The stored spelling is not lost — it is the actionable fact. + expect(deniedRows(h)[0].note).toContain("'fields'"); + // Readable through the door the REST `/audit` route uses. + const throughTheDoor = await h.engine.find('sys_metadata_audit', { + where: { type: 'field', name: 'legacy_field' }, + }); + expect(throughTheDoor).toHaveLength(1); + }); + + it('covers a spelling the card does not name (`email_templates`) — the rule is derived, not a list of four', async () => { + const { protocol, h } = makeProtocol(); + await seedDraftRowVerbatim(protocol, { + type: 'email_templates', name: 'welcome', body: { name: 'welcome', subject: 'Hi' }, packageId: PKG, + }); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res.failed[0]).toMatchObject({ + type: 'email_templates', name: 'welcome', code: 'STORED_TYPE_NOT_CANONICAL', + }); + expect(activeRows(h)).toEqual([]); + }); + + it('is BATCH-ATOMIC — one residue row refuses the whole package, its healthy sibling included', async () => { + const { protocol, h } = makeProtocol(); + await protocol.saveMetaItem({ + type: 'object', name: 'ticket', item: objectBody('ticket'), packageId: PKG, mode: 'draft', + }); + await seedDraftRowVerbatim(protocol, { + type: 'fields', name: 'legacy_field', body: { type: 'text', label: 'Legacy' }, packageId: PKG, + }); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res.success).toBe(false); + expect(res.publishedCount).toBe(0); + expect(res.published).toEqual([]); + // ADR-0067 D2: the healthy object did NOT half-land, and its draft is + // still a draft — the pre-flight returns above `engine.transaction()`, + // so nothing was even attempted. + expect(activeRows(h)).toEqual([]); + expect([...h.rows.values()].filter((r) => r.state === 'draft').map((r) => r.name).sort()) + .toEqual(['legacy_field', 'ticket']); + // Exactly ONE violation: the gate refuses the offender, it does not + // report the innocent sibling as one. + expect(res.failed.map((f: any) => f.name)).toEqual(['legacy_field']); + }); + + // ── the discriminating positive control ───────────────────────────────── + // + // Without this, every assertion above is satisfied by a rule that refuses + // EVERYTHING — which would break ordinary publishing outright. + it('CONTROL — a legitimate canonical draft still publishes, and audits, unchanged', async () => { + const { protocol, h } = makeProtocol(); + await protocol.saveMetaItem({ + type: 'object', name: 'ticket', item: objectBody('ticket'), packageId: PKG, mode: 'draft', + }); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res.success).toBe(true); + expect(res.publishedCount).toBe(1); + expect(res.published[0]).toMatchObject({ type: 'object', name: 'ticket' }); + expect(activeRows(h).map((r) => r.type)).toEqual(['object']); + expect(deniedRows(h)).toHaveLength(0); + expect(h.auditRows.some((a) => a.operation === 'publish' && a.outcome === 'allowed' + && a.type === 'object' && a.name === 'ticket')).toBe(true); + }); + + // ── the boundary marker for the deliberately-excluded class ───────────── + // + // A manifest-PRESENT plural is NOT this gate's business: it is already + // fail-closed at the promote, and #8908 rules one class. If a later change + // "simplifies" the predicate by dropping its manifest limb, this case is + // what says so — the code moves from `NO_DRAFT` to the pre-flight verdict. + it('CONTROL — a manifest-PRESENT plural (`objects`) keeps its existing NO_DRAFT abort', async () => { + const { protocol, h } = makeProtocol(); + await seedDraftRowVerbatim(protocol, { + type: 'objects', name: 'legacy_ticket', body: objectBody('legacy_ticket'), packageId: PKG, + }); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res.success).toBe(false); + expect(res.failed[0]).toMatchObject({ type: 'objects', name: 'legacy_ticket', code: 'NO_DRAFT' }); + expect(activeRows(h)).toEqual([]); + // Its `batch_aborted` row is keyed on the canonical type too — the + // audit writer's assert would refuse `objects`, so the batch route folds + // it at the boundary like every other at-rest row. + expect(h.auditRows.filter((a) => a.code === 'batch_aborted')).toHaveLength(1); + expect(h.auditRows.find((a) => a.code === 'batch_aborted')).toMatchObject({ + type: 'object', name: 'legacy_ticket', + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Zone B — the audit writer asserts instead of folding +// ═══════════════════════════════════════════════════════════════════════════ +// +// Called directly rather than through a route: the assert exists precisely +// because no ROUTE may reach it (every producer folds at its own boundary), so +// a route-level test could only ever measure the folds. What is pinned here is +// the writer's own accept set — what a FUTURE call site would meet. +describe('[#8908] recordMetadataAudit refuses a non-canonical `type`', () => { + it('refuses a manifest-ABSENT plural — the class the old fold silently persisted', async () => { + const { protocol, h } = makeProtocol(); + + await expect(protocol.recordMetadataAudit({ + type: 'fields', name: 'legacy_field', operation: 'publish', outcome: 'allowed', code: 'ok', + })).rejects.toMatchObject({ + code: 'AUDIT_TYPE_NOT_CANONICAL', + status: 500, + }); + // ⛔ Refused, not warned: nothing was even offered to the table. The + // best-effort `catch` inside this writer would have turned a throw from + // within the `try` into a `console.warn` — the softening the ruling + // forbids — and `auditAttempts` is what tells the two apart. + expect(h.auditAttempts).toHaveLength(0); + expect(h.auditRows).toHaveLength(0); + }); + + it('refuses a manifest-PRESENT plural too — the old fold repaired this one silently', async () => { + const { protocol, h } = makeProtocol(); + + await expect(protocol.recordMetadataAudit({ + type: 'objects', name: 'ticket', operation: 'save', outcome: 'allowed', code: 'ok', + })).rejects.toMatchObject({ code: 'AUDIT_TYPE_NOT_CANONICAL', status: 500 }); + expect(h.auditAttempts).toHaveLength(0); + }); + + it('the refusal names the offending spelling AND the canonical one', async () => { + const { protocol } = makeProtocol(); + + await expect(protocol.recordMetadataAudit({ + type: 'translations', name: 'zh_cn', operation: 'publish', outcome: 'allowed', code: 'ok', + })).rejects.toThrow(/'translations'.*'translation'/s); + }); + + // ── positive controls: the accept set is narrowed, not closed ─────────── + it('CONTROL — a canonical type still writes its row', async () => { + const { protocol, h } = makeProtocol(); + + await protocol.recordMetadataAudit({ + type: 'field', name: 'legacy_field', operation: 'publish', outcome: 'allowed', code: 'ok', + }); + + expect(h.auditRows).toHaveLength(1); + expect(h.auditRows[0]).toMatchObject({ type: 'field', name: 'legacy_field', code: 'ok' }); + }); + + it('CONTROL — an unrecognised kind is NOT refused (a plugin kind can never trip this gate)', async () => { + const { protocol, h } = makeProtocol(); + + // `canonicalMetaType` is the identity for anything the static map does + // not carry, so this holds BY CONSTRUCTION for every kind the platform + // has not heard of — the same positive-control-by-construction + // `metaUrlSpellingRefusal` is built on. The test exists anyway; + // construction and coverage are not substitutes. + await protocol.recordMetadataAudit({ + type: 'widgetz', name: 'x', operation: 'save', outcome: 'allowed', code: 'ok', + }); + + expect(h.auditRows).toHaveLength(1); + expect(h.auditRows[0]).toMatchObject({ type: 'widgetz' }); + }); + + it('CONTROL — the ordinary `/meta` save path still audits (the writer was narrowed, not broken)', async () => { + const { protocol, h } = makeProtocol(); + + // Addressed with the PLURAL url spelling, which the `/meta` boundary + // folds on the way in — so the writer is handed `object` and accepts it. + // This is the "fold at the boundary" half of the ruling, end to end. + await protocol.saveMetaItem({ + type: 'objects', name: 'ticket', item: objectBody('ticket'), packageId: PKG, mode: 'draft', + }); + + expect(h.auditRows.filter((a) => a.operation === 'save')).toHaveLength(1); + expect(h.auditRows.find((a) => a.operation === 'save')).toMatchObject({ + type: 'object', name: 'ticket', outcome: 'allowed', + }); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 9b50b1c0b5..096bebc41b 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3423,13 +3423,66 @@ function withPendingAudit(err: E, audit: MetadataAuditEntry): E * land silently; a runtime fallback would have put an unreachable value in a * documented closed set. The compiler is the enforcement. */ -type PreflightViolationCode = 'NAMESPACE_PREFIX'; +type PreflightViolationCode = 'NAMESPACE_PREFIX' | 'STORED_TYPE_NOT_CANONICAL'; const PREFLIGHT_AUDIT_CODE: Record = { // adr0112-ok: D6b — persisted audit column, its own lowercase vocabulary NAMESPACE_PREFIX: 'namespace_prefix', + // adr0112-ok: D6b — persisted audit column, its own lowercase vocabulary + STORED_TYPE_NOT_CANONICAL: 'stored_type_not_canonical', }; +/** + * [#8908] Is this AT-REST `type` a spelling the batch publish must refuse? + * + * ## The class, stated as a predicate rather than a list of four + * + * `publishPackageDrafts` reads `sys_metadata` rows, so its input is a stored + * `type` — not a URL segment, which is why #7894's boundary fold + * ({@link canonicalizeMetaRequestType}) never reaches it. `promoteDraftForPublish` + * folds the stored spelling through `PLURAL_TO_SINGULAR`, the MANIFEST-collection + * map, and that map legitimately omits types which are not stack collections. + * For those, the fold is a NO-OP: the lookup key equals the stored spelling, the + * draft resolves, and the publish mints an ACTIVE row in the very namespace + * `PUT /meta/field/…` answers 403 NOT_OVERRIDABLE for — every registry read and + * every compliance query on `field` missing an item the platform just reported + * as published. + * + * The two limbs below are that sentence, mechanically: + * + * 1. `canonicalMetaType(type) !== type` — the platform's own URL/registry map + * says this spelling names a DIFFERENT type than the one it is stored as, so + * the row lives in a second namespace for an item that has a canonical home. + * A canonical type fails this limb by construction (no canonical spelling + * folds elsewhere — 33 of 33, measured), and so does a plugin-registered or + * unrecognised kind (identity fold), which is what keeps the gate off + * everything that is not this defect. + * 2. the MANIFEST fold leaves it unchanged — i.e. `promoteDraftForPublish` + * would look the row up under its stored spelling and FIND it. + * + * ⛔ Limb 2 is not decoration and must not be dropped for "one simpler rule". + * A manifest-PRESENT plural (`objects`) is already fail-closed: the promote + * addresses the row by the folded singular, `whereFor` emits that spelling with + * no at-rest fallback, and the batch aborts on `NO_DRAFT` — pinned, with its + * reasoning, in `protocol.publish-side-effects-canonical-type.test.ts`. Widening + * this gate over that class would change a wire-visible `failed[].code` for rows + * that are NOT this defect. #8908's ruling names one class; this predicate is it. + * + * ## Why a predicate and not the four names the card lists + * + * The card names `fields`, `seeds`, `external_catalogs`, `translations`. Measured + * against the real maps, the class is SIX spellings: those four plus + * `externalCatalogs` (the camelCase limb of the same type) and `email_templates` + * (a registry type whose snake_case plural the manifest map also omits, while + * carrying `emailTemplates`). A hand-written list would have shipped with two + * members missing and no way to notice; this derivation cannot drift from the + * maps it reads, and a newly declared type that never reaches the manifest map + * is covered on the day it is declared. + */ +function isNonCanonicalStoredType(type: string): boolean { + return canonicalMetaType(type) !== type && (PLURAL_TO_SINGULAR[type] ?? type) === type; +} + /** * Implements the per-domain contracts this class ACTUALLY provides (ADR-0076 * D10 — the facade never implemented the other domains; those live in their @@ -10708,14 +10761,69 @@ export class ObjectStackProtocolImplementation implements * ADR landed) still answers normal API calls, just without the * compliance trail. Phase 2 will make the audit table a hard * dependency. + * + * ## [#8908] `type` is an ASSERTED input, not a tolerated one + * + * This method used to open with + * + * type: PLURAL_TO_SINGULAR[entry.type] ?? entry.type, + * + * — a silent fold that made every call site free to hand the compliance + * ledger whatever spelling it happened to hold. Two things were wrong with + * it, and the second is why it could not simply stay: + * + * - **Lenient consumer.** Contract-first (Prime Directive #12) puts the + * fold at the producer. A writer that repairs its callers' keys is the + * same shape `canonicalMetaType`'s header has rejected since #4432, one + * table over. + * - **Tolerant AND INCOMPLETE.** The fold read the MANIFEST-collection map, + * which omits the types that are not stack collections. So the trail was + * canonicalized for the 29 types that never needed it and left + * non-canonical for exactly the ones that did — the ledger recording a + * second namespace for the items most likely to be in one. + * + * Ruled on #8908 (auto-adjudicated 2026-08-15, with the promote refusal, as + * ONE card): fold at the boundary, assert here. The four call sites that + * build a row out of an AT-REST `type` — all of them on + * `publishPackageDrafts` — now fold with {@link canonicalMetaType}; the + * `/meta` routes were already canonical by the time they got here + * ({@link canonicalizeMetaRequestType}, applied at every entry point). + * + * ⛔ The refusal is a THROW and is deliberately OUTSIDE the `try` below. + * Inside it, the method's own best-effort `catch` would swallow it into a + * `console.warn` — which is precisely the "soften the assert to a warn" + * the ruling forbids, reached by accident rather than by decision. + * + * What it does NOT refuse, by construction: a canonical type (no canonical + * spelling folds elsewhere), and a plugin-registered or otherwise + * unrecognised kind (`canonicalMetaType` is the identity for anything the + * static map does not carry, so a kind the platform has not heard of can + * never be refused by this gate — the same positive-control-by-construction + * `metaUrlSpellingRefusal` is built on). */ private async recordMetadataAudit(entry: MetadataAuditEntry): Promise { + const canonical = canonicalMetaType(entry.type); + if (canonical !== entry.type) { + const err: any = new Error( + `[audit_type_not_canonical] Refusing to write a sys_metadata_audit row under the ` + + `non-canonical metadata type '${entry.type}' (canonical: '${canonical}') for ` + + `'${entry.name}'. ADR-0010's trail is keyed on (type, name) and read back through ` + + `the '/meta' boundary, which folds — a row filed under '${entry.type}' is a row no ` + + `compliance query can reach. Fold the type at the call site (canonicalMetaType) ` + + `rather than here; see this method's header for why the writer no longer does it.`, + ); + err.code = 'AUDIT_TYPE_NOT_CANONICAL'; + err.status = 500; + throw err; + } try { await this.engine.insert('sys_metadata_audit', { occurred_at: new Date().toISOString(), actor: entry.actor ?? 'system', source: entry.source ?? 'protocol', - type: PLURAL_TO_SINGULAR[entry.type] ?? entry.type, + // [#8908] Verbatim — the assert above is what makes this + // canonical, and a fold here would make the assert unfalsifiable. + type: entry.type, name: entry.name, organization_id: entry.organizationId ?? null, operation: entry.operation, @@ -13835,6 +13943,56 @@ export class ObjectStackProtocolImplementation implements } } + // ═══ [#8908] The AT-REST spelling gate ════════════════════════════════ + // + // The second pre-flight rule, and the one route by which the namespace + // #7894 retired could still mint ACTIVE rows. See + // {@link isNonCanonicalStoredType} for the class and for why the + // manifest-PRESENT plurals are deliberately NOT in it. + // + // Refused here, at the pre-flight, rather than by folding the stored + // spelling at the promote — that alternative was weighed and ruled + // against on #8908 (auto-adjudicated 2026-08-15, option (a)). Folding + // would make these rows behave like `objects`: unpromotable, batch + // aborted on `NO_DRAFT`, which is fail-closed but strands the operator + // with a verdict that describes a missing draft rather than the residue + // they actually have. The refusal below names the row, names the + // canonical spelling, and says what to do about it — the shape the + // namespace-prefix gate above already established, and batch-atomic for + // the same ADR-0067 D2 reason. + // + // ⛔ What this deliberately does NOT do: migrate the row. A + // `_migrate-stored` / boot-reconciliation conversion was the other + // option on the card and is explicitly NOT ruled — it stays available as + // a follow-up with its own appetite. Do not grow one here. + // + // ⚠️ Measured while writing this, and the reason the message does not + // just say "run the stored migration": `migrateStoredMetadata` scans + // with `PLURAL_TO_SINGULAR[rawType] ?? rawType` — the same manifest map + // — so for exactly these spellings it converts BODIES under the plural + // key, finds nothing to change, and reports the row `canonical`. The + // stored migration is a real, shipped door (`POST /meta/_migrate-stored`) + // that today reports "nothing to do" for this residue, so the actionable + // instruction is the re-author path, stated in full. + for (const d of drafts) { + if (!isNonCanonicalStoredType(d.type)) continue; + const canonical = canonicalMetaType(d.type); + preflightViolations.push({ + type: d.type, + name: d.name, + error: `Draft '${d.type}/${d.name}' is stored under the non-canonical metadata type ` + + `'${d.type}'; the canonical type for this item is '${canonical}'. Publishing it would ` + + `mint an ACTIVE row in a second namespace that no registry read and no compliance ` + + `query on '${canonical}' can see (#7894 closed this namespace at the '/meta' URL door; ` + + `this row predates that). Re-author the item under '${canonical}' ` + + `(PUT /meta/${canonical}/${d.name}) and drop the '${d.type}' row. Note that ` + + `POST /meta/_migrate-stored does NOT rewrite a stored type spelling — it canonicalizes ` + + `bodies, and reports rows of this class as already canonical.`, + code: 'STORED_TYPE_NOT_CANONICAL', + organizationId: d.organizationId ?? null, + }); + } + // [#5488 — RETIRED GATE, recorded overturn of PR #5279] // // `gateApiDraftsForPublish` stood here (#5206 step 2, #5040 E7 / @@ -13916,7 +14074,20 @@ export class ObjectStackProtocolImplementation implements // every site, and this one gets it by construction. for (const v of preflightViolations) { await this.recordMetadataAudit({ - type: v.type, + // [#8908] THE BOUNDARY FOLD, on the route that reads rows at + // rest. `v.type` is the STORED spelling, which for a + // `STORED_TYPE_NOT_CANONICAL` violation is by definition not + // the canonical one — and `recordMetadataAudit` now refuses a + // non-canonical `type` outright rather than folding it + // silently (see its header). Folding here is not merely what + // makes the writer's assert satisfiable: it is what makes the + // row READABLE. `auditMetaItem` serves + // `GET /meta/:type/:name/audit`, whose own `:type` segment is + // folded at the `/meta` boundary, so a row filed under the + // plural spelling is a row no operator can query for. The + // stored spelling is not lost — it is named in `note` and in + // `failed[].error`, where it is the actionable fact. + type: canonicalMetaType(v.type), name: v.name, // The draft's OWN scope — see the violation type above. organizationId: v.organizationId, @@ -14062,7 +14233,28 @@ export class ObjectStackProtocolImplementation implements if (draft?.body) seedBodies.push(draft.body); } const { singularType, result } = await this.promoteDraftForPublish({ - type: d.type, + // [#8908] The stored spelling, FOLDED — this route's + // boundary, the analogue of `canonicalizeMetaRequestType` + // on the six `/meta` entry points. Every row that + // reaches here has passed the at-rest spelling gate + // above, so the only non-canonical spellings left are + // the manifest-PRESENT plurals, for which this fold and + // the manifest fold `promoteDraftForPublish` already + // applies agree exactly — the promote resolves the same + // row it always did, and `d.type` (unfolded) still + // populates `failed[]` / `published[]`, which are wire + // shapes reporting what the operator actually stored. + // + // What it changes for those rows is the two things + // downstream of `request.type` that had NOT folded: + // `getEffectiveLock`'s overlay limb (queried the plural, + // found no row, answered `'none'` — "the author declared + // no protection" — for an item whose canonical row may + // carry `_lock`; the single-item twin of that hole is + // #8769 group C), and the `type` on the pending audit + // rows those two gates hand back, which the writer now + // refuses if it is not canonical. + type: canonicalMetaType(d.type), name: d.name, ...(draftOrgId ? { organizationId: draftOrgId } : {}), // [#8907] Promote each draft under the PACKAGE @@ -14209,7 +14401,11 @@ export class ObjectStackProtocolImplementation implements await this.recordPendingDenialAudit(e, { source: 'protocol.publishPackageDrafts' }); if (causal) { await this.recordMetadataAudit({ - type: causal.type, + // [#8908] Folded for the same two reasons as the pre-flight + // rows above — readability through `auditMetaItem`, and the + // writer's assert. `causal` is the draft row itself + // (`__batchItem`), so its `type` is the STORED spelling. + type: canonicalMetaType(causal.type), name: causal.name, organizationId: causal.organizationId ?? null, operation: 'publish', @@ -14287,7 +14483,14 @@ export class ObjectStackProtocolImplementation implements // on whether a later item's DDL happened to throw. for (const p of promoted) { await this.recordMetadataAudit({ - type: p.d.type, + // [#8908] Folded — the third and last site on this route that + // builds an audit row out of a stored `type`. Deliberately + // `canonicalMetaType(p.d.type)` and not `p.singularType`: the + // latter is the MANIFEST fold, which is a no-op for exactly the + // spellings this card is about, so it would have looked like a + // canonicalization while changing nothing (measured and closed on + // #8858 as a provable no-op). + type: canonicalMetaType(p.d.type), name: p.d.name, // The draft's OWN scope — see `PromotedDraft.draftOrgId`. organizationId: p.draftOrgId, diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 40acd2fe62..58118d60be 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -309,6 +309,7 @@ export const ERROR_CODE_LEDGER = { 'WRONG_PASSWORD', ], '@objectstack/metadata-protocol': [ + 'AUDIT_TYPE_NOT_CANONICAL', // [#8908] an ADR-0010 audit row was offered a non-canonical metadata `type` — the writer asserts, the caller folds 'BATCH_ABORTED', // sibling item in an all-or-nothing publish; it never ran 'CLONE_DISABLED', 'COMMIT_NOT_FOUND', @@ -326,6 +327,7 @@ export const ERROR_CODE_LEDGER = { 'OBJECT_OVERLAY_PACKAGE_MISMATCH', // [ADR-0029 D9.9] object overlay row bound to a package that does not own the object 'OBJECT_PACKAGE_DISABLED', // [#7557] object is registered but its owning package is disabled — data plane refuses rather than serving rows 'ROLLED_BACK', // atomic data-batch row was written, then undone by the batch rollback (#4793) + 'STORED_TYPE_NOT_CANONICAL', // [#8908] a package draft is stored under a non-canonical metadata type (pre-#7894 second-namespace residue) — refused at the publish pre-flight, batch-atomic 'TENANT_SCOPE_REQUIRED', // [#7780] destructive call named neither an organization nor an explicit cross-tenant intent; needs an explicit opt-in 'UNSUPPORTED_QUERY_PARAM', 'VALIDATION_FAILED',