From 426cf5281e071da2fea605a5b6e7074ae0479673 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 05:43:08 +0000 Subject: [PATCH] feat(objectql,metadata-protocol)!: register a tenant object overlay as its own contributor layer (ADR-0029 D9) (#7277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 租户对 `object` 的定制此前以默认的 `own` 身份进入 SchemaRegistry;当行的 `package_id` 与代码包所有者相同时,`registerObject` 会把打包的 contributor 直接摘掉 —— 打包定义在写入时被销毁,注册表里没有第二份副本, `loadMetaFromDb` 每次启动无声重放这次销毁。 ADR-0029 D9 把层次关系显式化:第三种非拥有的 `overlay` 种类,对基础层是 替换语义,解析变成 `base = overlay ?? own`,extender 照旧叠在上面。解析 结果逐字节不变(含 `_provenance: 'org'`),变的只是注册表记得什么。 - `assertSingleOwnerPerObject` 一字未改,新增孤儿 overlay 违规类 - 基础层选择问「种类」,永不问优先级(150 只用于列举顺序) - artifact 身份改读 owner contributor 的层,`isArtifactBacked` 不再说谎 - `provisionPrimary` 的门从「是不是 own」改成「是不是基础层」 - 行上的 `package_id` 是层的来源标记:同包正常、无包哨兵接受、 异包在生产者侧以 `OBJECT_OVERLAY_PACKAGE_MISMATCH` / 422 拒绝 - 迟到安装:代码层取得所有权,租户贡献重新归类为它的 overlay 层 - 删除退化为减法:`removeObjectOverlay` 只摘层,打包 owner 原地不动 - #7012 的包绑定守卫随之退役(实测:唯一变红的是它自己那条已接受成本) Fixes #7277 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw --- ...029-d9-object-overlay-contributor-layer.md | 24 + content/docs/references/api/analytics.mdx | 6 +- content/docs/references/api/auth.mdx | 4 +- .../docs/references/api/automation-api.mdx | 18 +- content/docs/references/api/batch.mdx | 4 +- content/docs/references/api/contract.mdx | 15 +- .../docs/references/api/error-code-ledger.mdx | 1 + content/docs/references/api/export.mdx | 12 +- content/docs/references/api/metadata.mdx | 38 +- content/docs/references/api/package-api.mdx | 16 +- content/docs/references/api/protocol.mdx | 6 +- content/docs/references/api/storage.mdx | 16 +- content/docs/references/data/object.mdx | 1 + .../0005-metadata-customization-overlay.md | 2 +- packages/metadata-protocol/src/protocol.ts | 333 ++++++---- packages/objectql/src/plugin.ts | 33 +- ...elete-object-package-binding-guard.test.ts | 455 -------------- .../src/protocol-object-overlay-layer.test.ts | 581 ++++++++++++++++++ .../src/registry-object-overlay-layer.test.ts | 499 +++++++++++++++ packages/objectql/src/registry.ts | 333 +++++++++- .../spec/src/api/error-code-ledger.zod.ts | 1 + packages/spec/src/data/object.zod.ts | 33 +- scripts/adr-anchors.json | 5 +- 23 files changed, 1746 insertions(+), 690 deletions(-) create mode 100644 .changeset/adr0029-d9-object-overlay-contributor-layer.md delete mode 100644 packages/objectql/src/protocol-delete-object-package-binding-guard.test.ts create mode 100644 packages/objectql/src/protocol-object-overlay-layer.test.ts create mode 100644 packages/objectql/src/registry-object-overlay-layer.test.ts diff --git a/.changeset/adr0029-d9-object-overlay-contributor-layer.md b/.changeset/adr0029-d9-object-overlay-contributor-layer.md new file mode 100644 index 0000000000..36ca4b0906 --- /dev/null +++ b/.changeset/adr0029-d9-object-overlay-contributor-layer.md @@ -0,0 +1,24 @@ +--- +"@objectstack/objectql": minor +"@objectstack/metadata-protocol": minor +"@objectstack/spec": minor +--- + +ADR-0029 D9: a tenant object overlay registers as its own contributor LAYER instead of splicing the packaged owner out + +租户对 `object` 的定制(`sys_metadata` 行)此前以默认的 `own` 身份进入 `SchemaRegistry`。当该行的 `package_id` 与代码包所有者相同时,`registerObject` 会走"重复注册"分支把**打包的 contributor 直接摘掉**——打包定义不是被遮蔽,而是在写入时被销毁,注册表里不存在第二份副本;`loadMetaFromDb` 每次启动都无声重放这次销毁。 + +D9 把这个层次关系显式化: + +- **第三种非拥有的 contributor 种类 `overlay`**,对基础层是替换语义。解析变成 `base = overlay ?? own`,extender 照旧叠在上面。**解析结果逐字节不变**(含 `_provenance: 'org'`)——变的只是注册表"记得"什么:打包的 owner 依然在下面。 +- `assertSingleOwnerPerObject` **一字未改**(overlay 不是 owner),新增一类违规:孤儿 overlay(有 overlay 没有 owner)。 +- **基础层的选择问"种类",永远不问优先级**。`DEFAULT_OVERLAY_PRIORITY = 150` 只用于列举顺序:extender 的优先级是作者声明的,不能让某个包用 `priority: 140` 把租户的 overlay 挤出基础层。 +- **artifact 身份改为读 owner contributor 的层**,而不是合并后的文档。这一条不是层次化改动的自然推论:合并结果按设计仍带 `_provenance: 'org'`,所以只有从 owner 层读,`isArtifactBacked` 才不再说谎。 +- `provisionPrimary` / `provisionSearchCompanion` 的门从"是不是 `own`"改成"**是不是基础层**",否则每个被 overlay 的对象的 `nameField` 都会变。 +- 行上的 `package_id` 是层的**来源标记**,从来不是所有权主张:同包正常;**无包(`sys_metadata` 哨兵)予以接受**(此前的抛错是借用 `own` 槽位的副产品);绑定到**其他包**的行在生产者侧被明确拒绝,新错误码 `OBJECT_OVERLAY_PACKAGE_MISMATCH`(422),启动时计入 `loadMetaFromDb` 的 `errors`。 +- **迟到安装**:代码包为一个租户行已占据的对象名注册时,代码层成为 owner,租户的贡献被重新归类为它的 overlay 层——不再抛 "already owned by",也不再把租户的定制吞掉。 +- 删除退化为**减法**:`SchemaRegistry.removeObjectOverlay(name)` 只摘掉 overlay 层,打包 owner 原地不动,因此"恢复"根本不是一次重新注册。 + +**行为变化(记录在案的成本)**:谓词诚实之后,`object` 声明的 `allowOrgOverride: false` 会被**一致地**执行——对打包对象的 overlay 写入**每次**都以 `NOT_OVERRIDABLE` 拒绝,而不是只拒第一次(此前第一次被拒、并因销毁证据而让后续每次都从 `allowRuntimeCreate` 那一档混过去)。同一谓词也喂给 `deleteMetaItem` 的两档鉴权与仓库的 `assertAllowed`,所以重置该定制同样需要那道文档化的运维口子 `OS_METADATA_WRITABLE=object`——现在它必须在定制的**整个生命周期**内保持打开,而不只是第一次保存时。 + +`ObjectContributor.ownership` 与 `ObjectOwnershipEnum` 的联合类型因此加宽(loader 设定,永不可由作者书写),这是 `objectui` / `cloud` 消费方可见的公开类型变化。 diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index e4c1ce9759..f679f8514b 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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 6198f257c2..92ffb29975 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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 1d7ab82b35..2e6edb1c13 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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 31bc35186b..ae3cf883f3 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' \| … +258 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). | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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). | | **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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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 3e92b17d3a..72f71dad0c 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' \| … +254 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' \| … +255 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 | @@ -214,6 +214,7 @@ const result = ApiErrorSchema.parse(data); * `OAUTH_REGISTER_FAILED` * `OBJECT_API_DISABLED` * `OBJECT_API_METHOD_NOT_ALLOWED` +* `OBJECT_OVERLAY_PACKAGE_MISMATCH` * `OPENAPI_UNAVAILABLE` * `OS_PROTOCOL_INCOMPATIBLE` * `PACKAGE_DELETE_FAILED` @@ -308,7 +309,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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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 | @@ -347,7 +348,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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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 | @@ -389,7 +390,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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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 | @@ -441,7 +442,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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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 | @@ -457,7 +458,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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | | **index** | `number` | optional | Index in original request | | **data** | `any` | optional | Result data (e.g. created record) | @@ -490,7 +491,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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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 e081eb0471..608f68034d 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -286,6 +286,7 @@ const result = ErrorCode.parse(data); * `OAUTH_REGISTER_FAILED` * `OBJECT_API_DISABLED` * `OBJECT_API_METHOD_NOT_ALLOWED` +* `OBJECT_OVERLAY_PACKAGE_MISMATCH` * `OPENAPI_UNAVAILABLE` * `OS_PROTOCOL_INCOMPATIBLE` * `PACKAGE_DELETE_FAILED` diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index 21a6a3b974..eb9c3f5e01 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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 df6eb5407a..8434563637 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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 d152889382..72ebf83155 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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 3bedd38c2c..21d12243ca 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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 | @@ -1494,7 +1494,7 @@ Uninstall package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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 18e178dd01..87d0fe7cfc 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +259 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/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index a4bc434158..ee2e7ab937 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -244,6 +244,7 @@ External datasource binding (ADR-0015) * `own` * `extend` +* `overlay` --- diff --git a/docs/adr/0005-metadata-customization-overlay.md b/docs/adr/0005-metadata-customization-overlay.md index 9d72a4c5d0..62c001b871 100644 --- a/docs/adr/0005-metadata-customization-overlay.md +++ b/docs/adr/0005-metadata-customization-overlay.md @@ -6,7 +6,7 @@ **Status**: Accepted (2026-05-16) · **Amended** (2026-05-22, see "Amendment: post-ADR-0006 v4 scope") · **Amended** (2026-04-13, branch concept removed — see [ADR-0008 §0](./0008-metadata-repository-and-change-log.md#0-2026-04-13-amendment--drop-project-and-branch-from-metaref)) · **Amended** (2026-08-09, #6825 — the Phase-1 overlay-index migration is deleted; see "Amendment (2026-08-09, #6825): overlay-index delivery after the Phase-1 migration was deleted") **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0003](./0003-package-as-first-class-citizen.md) (Package as first-class citizen), [ADR-0004](./0004-cloud-multi-kernel.md) (Cloud + per-project kernels) -**Amended by**: [ADR-0006 v4](./0006-project-environment-split.v4.md) (drops `sys_project` entirely), [ADR-0008](./0008-metadata-repository-and-change-log.md) (re-expresses overlay as `LayeredRepository`; subsequently drops `project`/`branch` from `MetaRef`) +**Amended by**: [ADR-0006 v4](./0006-project-environment-split.v4.md) (drops `sys_project` entirely), [ADR-0008](./0008-metadata-repository-and-change-log.md) (re-expresses overlay as `LayeredRepository`; subsequently drops `project`/`branch` from `MetaRef`), [ADR-0029 D9](./0029-kernel-object-ownership-and-platform-objects-decomposition.md#amendment-2026-08-09-6853-a-tenant-overlay-of-an-object-is-its-own-contributor-layer-not-a-second-own) (for `object` only: the overlay is a registry contributor LAYER over the packaged owner, resolved as `base = overlay ?? own`, instead of a destructive in-place overwrite) **Consumers**: `@objectstack/objectql`, `@objectstack/runtime`, `@objectstack/rest`, `apps/studio`, all customer-facing tenants > **2026-04-13 note** — overlays are keyed exclusively by `organization_id`. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 9763b804f4..ffea04ff0d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -8196,11 +8196,27 @@ export class ObjectStackProtocolImplementation implements if (request.type !== 'object' && request.type !== 'objects') return; this.engine.registry.registerItem(request.type, request.item, 'name'); try { + const layer = this.classifyObjectContribution(request.name, request.packageId); + if (layer.kind === 'mismatch') { + // Unreachable from `saveMetaItem`, which refuses this row at + // the producer (D9.9) — but `applyRegistryWriteThrough` has + // other callers (rollback, publish promotion), and registering + // the layer anyway would put `sys_metadata` and the registry + // back into the disagreement D9.9 exists to prevent. + throw ObjectStackProtocolImplementation.overlayPackageMismatchError( + request.name, layer.packageId, layer.ownerPackageId, + ); + } this.engine.registry.registerObject( { ...(request.item as Record), _provenance: 'org' } as any, - // `||`, not `??`: an empty-string binding is "no package", the - // same normalisation the boot branch applies to `package_id`. - request.packageId || 'sys_metadata', + layer.packageId, + undefined, + // [ADR-0029 D9.8] The KIND, chosen by asking the registry a + // question it can answer: does a PACKAGED owner already hold + // this name? Registering unconditionally as `'own'` is what + // made this seam splice the packaged contributor out and + // destroy the code definition at write time (#6853). + layer.kind, ); } catch (err: any) { console.warn( @@ -8209,6 +8225,90 @@ export class ObjectStackProtocolImplementation implements } } + /** + * [ADR-0029 D9.8/D9.9] Which contributor KIND a `sys_metadata` object row + * registers as — and whether it may register at all. + * + * ``` + * packaged `own` contributor already registered for this name? + * no -> `own` (a runtime-authored object; today's behaviour, keyed + * by the row's package_id or the sentinel) + * yes -> `overlay` (a tenant layer over the code definition) + * ``` + * + * Every row this classifies came out of `sys_metadata` and is therefore + * tenant-authored by definition, which is why the question is only about + * the OTHER side: a runtime-authored object's owner IS the tenant's own + * row, and re-writing it stays an ordinary re-registration rather than + * becoming a layer over itself. + * + * The row's `package_id` (`P`) is provenance ON the layer, never an + * ownership claim (D9.9), so against the packaged owner's id `O`: + * + * | case | verdict | + * |:--|:--| + * | `P == O` | the normal case — one overlay layer over `O`'s object. | + * | `P` empty / absent (the `'sys_metadata'` sentinel) | **accepted** — a package-less env-wide overlay is ADR-0005's platform-global shape. Before D9 this THREW `already owned by package "O"`, an artefact of the borrowed `own` slot rather than a decision (#6995, measured as P2). | + * | `P == Q`, some other package | **mismatch** — refused loudly. | + * + * Falls back to `'own'` — today's behaviour — for a registry that does not + * offer the discriminator, so a duck-typed engine double is never made to + * fail by a question it cannot answer. + */ + private classifyObjectContribution( + name: string, + rowPackageId: string | null | undefined, + ): + | { kind: 'own' | 'overlay'; packageId: string } + | { kind: 'mismatch'; packageId: string; ownerPackageId: string } { + // `||`, not `??`: an empty-string binding is "no package", the same + // normalisation both hydration seams apply to `package_id`. + const binding = rowPackageId || ''; + const registry: any = (this.engine as any)?.registry; + const owner = typeof registry?.getPackagedObjectOwner === 'function' + ? registry.getPackagedObjectOwner(name) + : undefined; + const ownerPackageId: unknown = owner?.packageId; + if (typeof ownerPackageId !== 'string' || ownerPackageId === '') { + return { kind: 'own', packageId: binding || 'sys_metadata' }; + } + if (binding !== '' && binding !== ownerPackageId) { + return { kind: 'mismatch', packageId: binding, ownerPackageId }; + } + return { kind: 'overlay', packageId: binding || 'sys_metadata' }; + } + + /** + * [ADR-0029 D9.9] The mis-bound overlay refusal. + * + * The overlay-uniqueness index keys on + * `(type, name, organization_id, COALESCE(package_id, ''))` (ADR-0005 + * amendment, #6825), so `sys_metadata` can legitimately hold two active + * rows for one `(type, name)` bound to two packages. For every other type + * that is fine — two packages really can ship `page/home`. For `object` it + * is not representable: `computeFQN` is identity, so the registry holds + * exactly one entry per object name and could never serve two. Refusing + * the mis-bound row is what keeps the two stores in agreement instead of + * letting `sys_metadata` describe a shape the registry cannot hold. + */ + private static overlayPackageMismatchError( + name: string, rowPackageId: string, ownerPackageId: string, + ): Error { + const err: any = new Error( + `[object_overlay_package_mismatch] Cannot layer object '${name}': the overlay is bound to package ` + + `'${rowPackageId}', but the object is owned by package '${ownerPackageId}'. ` + + `An object has exactly one registry entry, so it can carry exactly one overlay layer — bind the ` + + `customization to '${ownerPackageId}', or have '${rowPackageId}' extend the object instead. ` + + `See docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md.`, + ); + err.code = 'OBJECT_OVERLAY_PACKAGE_MISMATCH'; + err.status = 422; + err.packageId = rowPackageId; + err.ownerPackageId = ownerPackageId; + err.docs = 'docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md'; + return err; + } + /** * Register ONE active overlay body into the engine's SchemaRegistry. * @@ -8470,6 +8570,30 @@ export class ObjectStackProtocolImplementation implements try { const registry: any = this.engine.registry; const singular = PLURAL_TO_SINGULAR[type] ?? type; + + // ── [ADR-0029 D9.7] THE OBJECT HALF'S LAYER SUBTRACTION ── + // + // Runs AHEAD of the tier walk, not inside tier 3, and the reason is + // the same asymmetry #6808 was filed for — one layer down. Tier 1 + // (`removeRuntimeShadow`) returns as soon as it heals the generic + // `metadata` map, so an object whose plain-key shadow it can heal + // would never reach a tier-3 limb, and its overlay LAYER would stay + // registered in `objectContributors` — the deleted customization + // still being served by `getObject`, which is what the data plane + // dispatches on. + // + // Unconditionally correct for `object`, which is why it needs no + // tier: the row is gone, so its layer goes with it, and whatever + // was underneath — a packaged owner, or nothing — is what should be + // served next. When a packaged owner IS underneath, this is the + // whole restoration: it is already there, at its own priority, in + // its own namespace, with its own definition, so nothing has to be + // reconstructed from values that no longer exist (#6853's measured + // wall). + if (singular === 'object' && typeof registry.removeObjectOverlay === 'function') { + registry.removeObjectOverlay(name); + } + let healed = false; if (typeof registry.removeRuntimeShadow === 'function') { healed = registry.removeRuntimeShadow(singular, name); @@ -8538,8 +8662,7 @@ export class ObjectStackProtocolImplementation implements // which for `object` reads the contributor definition and applies // exactly the artifact test the sibling verb applies to the plain // key), so this limb inherits that judgement instead of open-coding - // a second one — PLUS the package-binding check below, which exists - // because that inherited judgement is measurably falsifiable here. + // a second one. // // Not theoretical, and NOT already covered by the gate at the top of // `deleteMetaItem`: that two-tier authorization — which refuses an @@ -8551,40 +8674,46 @@ export class ObjectStackProtocolImplementation implements // take that object off the whole data plane until restart, because // `assertObjectRegistered` fails closed. // - // The check lives HERE rather than in the verb because it is a - // statement about LAYERS, which is what this walk reasons about; - // `unregisterObject` stays a general removal whose only refusal is - // ADR-0029's extender rule. + // ── [ADR-0029 D9.7] THE LAYER-ADDRESSED VERB RUNS FIRST ── + // + // When a packaged owner survives underneath, the tenant's delete + // removes ONE thing: its overlay LAYER. The packaged definition is + // already there — its own priority, its own namespace, its own body + // — so restoring the artifact view is a SUBTRACTION, not a + // reconstruction. That is what dissolves #6853's measured wall + // (the delete-time heal needed five values and three of them no + // longer existed by the time it ran): the judgement moved to write + // time, where the packaged owner is one lookup away. + // + // It also retires #7012's package-binding guard, deliberately and + // measurably rather than by tidiness. That guard existed because + // `isArtifactBacked` was FALSIFIED here — an overlay row bound to + // the packaged owner's id destroyed the packaged contributor at + // write time, so the predicate answered "not shipped" for an object + // the package still ships. D9 removes the falsification at its + // source: the packaged `own` contributor is never spliced out, so + // the predicate is honest and the case the guard protected cannot + // reach the retirement limb at all (`removeObjectOverlay` returns + // first, and `isArtifactBacked` is true besides). What the guard + // still reached after that was only its own ACCEPTED COST — a + // package-bound RUNTIME-authored object (Studio's package + // workspace, #4636), indistinguishable from a shipped one by + // binding alone, kept listable-but-rowless until restart. With the + // predicate honest that is no longer the cheap direction, it is + // simply the wrong answer: nothing ships the name, the row WAS the + // item, and the operator asked for it to be gone. if ( singular === 'object' && !this.isArtifactBacked(singular, name) && typeof registry.unregisterObject === 'function' ) { - // [#7012] …AND `isArtifactBacked` ALONE CANNOT SEE THAT. - // See {@link installedPackageBindingForObject} for the whole - // argument; the one-line version is that an overlay row bound - // to the packaged owner's id DESTROYS the packaged contributor - // at write time, which turns the predicate above `false` for an - // object the package still ships. - const boundPackageId = this.installedPackageBindingForObject(name); - if (boundPackageId !== undefined) { + try { + registry.unregisterObject(name); + } catch (err: any) { console.warn( `[Protocol] object '${name}' was deleted from sys_metadata but stays registered: ` - + `its owner contributor is bound to installed package '${boundPackageId}'. ` - + `A package-shipped object must not be retired by an overlay delete, and this seam ` - + `cannot tell one from a package-bound runtime-authored object (#7012 / #6853), so ` - + `the entry survives — listable, and rowless if the delete really was the whole item ` - + `— until the next restart.`, + + `${err?.message ?? err}`, ); - } else { - try { - registry.unregisterObject(name); - } catch (err: any) { - console.warn( - `[Protocol] object '${name}' was deleted from sys_metadata but stays registered: ` - + `${err?.message ?? err}`, - ); - } } } } catch { @@ -8592,99 +8721,6 @@ export class ObjectStackProtocolImplementation implements } } - /** - * [#7012] The package binding of a registered `object`, but only when it - * names a package this process has actually INSTALLED. `undefined` means - * "no installed package answers for this object" — the only state in which - * {@link restoreArtifactRegistryView}'s tier 3 may unregister it. - * - * ## Why tier 3 needs a second predicate at all - * - * `isArtifactBacked` is the natural question ("does a code package ship - * this?") and it is the WRONG question at this exact point, because the - * thing it reads has already been destroyed by the time the walk runs. - * - * `SchemaRegistry.registerObject` splices out the same-package `own` - * contributor before pushing the new one. So an overlay row whose - * `package_id` equals the packaged owner's id does not SHADOW the packaged - * definition — it REPLACES it, and no second copy exists anywhere in the - * registry. {@link loadMetaFromDb} replays that replacement on every boot, - * with no authorization gate and no log, stamping `_provenance: 'org'` - * server-side (deliberately — cloud#970). `getArtifactItem` for an `object` - * is exactly `_provenance !== 'org'`, so `isArtifactBacked` answers `false` - * for an object a code package still ships, and tier 3 then took the whole - * entry. Measured end to end on a tenant kernel with no escape hatch: - * - * ``` - * loadMetaFromDb -> {"loaded":1,"errors":0,"invalid":0} warnings: [] - * DELETE -> {"success":true,"reset":true} - * objectContributors: [] getObject: null - * data CRUD: OBJECT_NOT_FOUND / 404 (while the table still holds the rows) - * ``` - * - * ## Why the BINDING is trustworthy where the definition is not - * - * The replacement rule fires only when the two package ids MATCH, so the - * surviving contributor provably carries the packaged owner's id — the one - * fact the overwrite cannot change, precisely because it is the overwrite's - * own precondition. The second half, "is that package installed", is a fact - * about the PROCESS rather than about the destroyed body: - * `SchemaRegistry.installPackage` writes the record and - * `ObjectQL.registerApp` calls it immediately before registering the - * manifest's objects, so a package-shipped object always has one. Durable - * packages (`sys_packages`) are re-installed at boot by `service-package`, - * and nested `registerPlugin` objects are keyed to the PARENT package, - * which `registerApp` installed. The `'sys_metadata'` sentinel — the key an - * overlay row bound to no package keeps — is handled by construction rather - * than by a special case: nothing installs a package under it, so it never - * resolves to a record. - * - * ## What this deliberately does NOT ask - * - * - NOT `enabled` / `status`. `disablePackage` flips lifecycle flags and - * removes no contributor, so a disabled package's objects stay registered - * and stay dispatchable; reading the flag here would unregister a - * definition nothing else removes — the same outage through a second door. - * - NOT the manifest's `objects` list. That would re-ask "is this - * code-shipped", which is the question whose answer was destroyed; it is - * also absent for `registerPlugin`-contributed objects. - * - * ## The accepted cost, ruled and not to be worked around - * - * A package-bound RUNTIME-authored object (Studio's package workspace, - * #4636) carries a real `package_id` too, so it is indistinguishable from a - * package-shipped one by binding alone: some genuinely deleted objects stay - * registered until restart. Per this walk's own REGISTER WIDE / RETIRE - * NARROW argument that is the cheap direction — a surplus entry degrades to - * "listable but rowless" and the next reload heals it, a wrongly retired one - * 404s data CRUD for every tenant. The honest fix for the distinguishability - * itself is #6853's direction B (the tenant overlay registers as its own - * contributor layer instead of splicing out the packaged `own`), which - * re-arms `isArtifactBacked` here and at `saveMetaItem`'s overlay gate; it is - * an ADR-0029 amendment and a separate card by maintainer ruling - * (2026-08-09). - * - * Name-addressed, like every other verb in the walk: `getObjectOwner` reads - * the contributor list under the same key `getObject` and - * {@link SchemaRegistry.unregisterObject} resolve (`computeFQN` is identity, - * so the registry key IS the object name), which is what keeps the decision - * and the removal talking about the same entry. - */ - private installedPackageBindingForObject(name: string): string | undefined { - const registry: any = (this.engine as any)?.registry; - if ( - !registry - || typeof registry.getObjectOwner !== 'function' - || typeof registry.getPackage !== 'function' - ) { - return undefined; - } - const packageId: unknown = registry.getObjectOwner(name)?.packageId; - if (typeof packageId !== 'string' || packageId === '') return undefined; - const installed = registry.getPackage(packageId); - return installed === undefined || installed === null ? undefined : packageId; - } - /** * Ensure a just-PUBLISHED object's physical table exists so it is usable * for data CRUD immediately — without a server restart. Registering the @@ -8914,11 +8950,35 @@ export class ObjectStackProtocolImplementation implements if (lockErr) throw lockErr; } + const singularType = PLURAL_TO_SINGULAR[request.type] ?? request.type; + + // [ADR-0029 D9.9 / #6995] The refusal that is about REPRESENTABILITY + // rather than authorization: an `object` overlay bound to a package + // that does not own the object. See + // {@link overlayPackageMismatchError} for why one object name can + // carry at most one overlay layer while `sys_metadata` can hold two + // rows for it. + // + // AT THE PRODUCER, deliberately. Before D9 this row reached + // `registerObject`, which threw `already owned by package "…"` into + // `applyObjectRegistryMutation`'s best-effort `console.warn` — and + // `saveMetaItem` still returned a SUCCESS RECEIPT for a write the + // runtime had discarded (#6995, the silent write-side divergence). + // The receipt and the registry now agree because the write never + // happens: this precedes `ensureOverlayIndex` and every `put`. + if (singularType === 'object') { + const layer = this.classifyObjectContribution(request.name, request.packageId); + if (layer.kind === 'mismatch') { + throw ObjectStackProtocolImplementation.overlayPackageMismatchError( + request.name, layer.packageId, layer.ownerPackageId, + ); + } + } + // Phase 3a-destructive: for object/field writes, diff against the // current schema and 409 if the change would drop data — unless the // caller has acknowledged the risk with `force: true`. The admin UI // surfaces the structured `issues` payload in a confirmation dialog. - const singularType = PLURAL_TO_SINGULAR[request.type] ?? request.type; if (!request.force && (singularType === 'object' || singularType === 'field')) { try { const existing = await this.getMetaItem({ @@ -12110,9 +12170,30 @@ export class ObjectStackProtocolImplementation implements // the write path's `request.packageId || 'sys_metadata'`: // an empty binding is "no package", and the sentinel // marks exactly that one thing. + // + // [ADR-0029 D9.8] …and the KIND is chosen, not + // defaulted. Registering every row as `'own'` is what + // made this seam replay a destruction of the packaged + // definition on EVERY boot, silently and with a clean + // `{loaded:1,errors:0,invalid:0}` receipt (#6853 P6). + // [D9.9] A row bound to a package that does NOT own the + // object throws here on purpose: the per-record catch + // below counts it in `errors` with its reason, which is + // the boot-side half of the write-path refusal. + const layer = this.classifyObjectContribution( + String(record.name), + (record as { package_id?: string | null }).package_id, + ); + if (layer.kind === 'mismatch') { + throw ObjectStackProtocolImplementation.overlayPackageMismatchError( + String(record.name), layer.packageId, layer.ownerPackageId, + ); + } this.engine.registry.registerObject( { ...(data as Record), _provenance: 'org' } as any, - (record as { package_id?: string | null }).package_id || 'sys_metadata', + layer.packageId, + undefined, + layer.kind, ); } else { // Same rule as the getMetaItems read-side hydration and diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 8ac620a332..771f29f089 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -675,6 +675,30 @@ export class ObjectQLPlugin implements Plugin { * Removed objects are left registered until restart — same lifecycle as * their tables, which managed drift deliberately never drops. */ + /** + * [ADR-0029 D9.8] Which contributor KIND a metadata-service body registers + * as. The SAME discriminator the two `sys_metadata` hydration seams ask, owed + * to these two ingest paths because they also register a reloaded body — left + * as an unconditional `'own'` they re-open the splice through a third door, + * and it is easy to miss precisely because they are about metadata-service + * reloads rather than about tenant overlays. + * + * Narrower than the `sys_metadata` seams' rule, and deliberately so: a row + * from `sys_metadata` is tenant-authored by definition, while THIS body can + * be either layer. A reload of the OWNER's own definition (HMR, a package + * re-registering its own object) must stay an `own` re-registration; it is a + * body that arrives under a different id, or one that says it is + * tenant-authored, that is a layer over the code definition. + */ + private objectContributionKind(name: string, packageId: string, body: unknown): 'own' | 'overlay' { + const registry: any = this.ql?.registry; + if (typeof registry?.getPackagedObjectOwner !== 'function') return 'own'; + const owner = registry.getPackagedObjectOwner(name); + if (!owner) return 'own'; + const tenantAuthored = (body as { _provenance?: unknown } | null | undefined)?._provenance === 'org'; + return owner.packageId === packageId && !tenantAuthored ? 'own' : 'overlay'; + } + private ingestReloadedObjects(ctx: PluginContext, payload: unknown): void { if (!this.ql) return; const objects = (payload as any)?.metadata?.objects; @@ -685,11 +709,13 @@ export class ObjectQLPlugin implements Plugin { if (typeof name !== 'string' || name.length === 0) continue; try { this.ql.registry.invalidate(name); + const reloadPackageId = (obj as any)._packageId ?? 'metadata-service'; this.ql.registry.registerObject( obj as any, - (obj as any)._packageId ?? 'metadata-service', + reloadPackageId, (obj as any).namespace, - 'own', + // [ADR-0029 D9.8] See {@link objectContributionKind}. + this.objectContributionKind(name, reloadPackageId, obj), ); ingested++; } catch (e: any) { @@ -766,7 +792,8 @@ export class ObjectQLPlugin implements Plugin { fresh as any, packageId, namespace, - 'own', + // [ADR-0029 D9.8] See {@link objectContributionKind}. + this.objectContributionKind(name, packageId, fresh), ); ctx.logger.info('[ObjectQLPlugin] object metadata updated — registry refreshed', { name, diff --git a/packages/objectql/src/protocol-delete-object-package-binding-guard.test.ts b/packages/objectql/src/protocol-delete-object-package-binding-guard.test.ts deleted file mode 100644 index f26b617b55..0000000000 --- a/packages/objectql/src/protocol-delete-object-package-binding-guard.test.ts +++ /dev/null @@ -1,455 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect, vi } from 'vitest'; -import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import { SchemaRegistry } from './registry.js'; -// [#4550 / #5480] The producer's OWN write-verb dispatch decisions, so the -// engine double below cannot accept a call `ObjectQL.delete` / `ObjectQL.update` -// refuses — a double looser than the implementation is no test at all. -import { assertEngineDeleteDispatch } from './engine-delete-dispatch.js'; -import { assertEngineUpdateDispatch } from './engine-update-dispatch.js'; - -/** - * #7012 — tier 3 of the delete heal must not unregister an object whose owner - * contributor is bound to a package this process has INSTALLED. - * - * ## The outage, measured end to end (#6853 dev report P3/P6) - * - * On a tenant kernel, with no escape hatch and no operator action, a stored - * `sys_metadata` object row bound to a code package's id takes the object off - * the whole data plane on the next `DELETE /meta/object/:name`: - * - * ``` - * loadMetaFromDb -> {"loaded":1,"errors":0,"invalid":0} boot warnings: [] - * after hydration -> contributor provenance flips package -> org - * isArtifactBacked -> false - * DELETE /meta/object/:name - * -> {"success":true,"reset":true} - * -> objectContributors: [] getObject: null - * -> data CRUD: OBJECT_NOT_FOUND / 404 - * ``` - * - * The mechanism is a WRITE-time destruction, not a shadow. - * `SchemaRegistry.registerObject` splices out the same-package `own` - * contributor before pushing the new one, so hydrating an overlay row whose - * `package_id` equals the packaged owner's id DESTROYS the packaged - * definition — there is no second copy anywhere in the registry. - * `loadMetaFromDb` replays that replacement on EVERY boot, silently. - * - * Tier 3's "AND IT NEVER RETIRES A CODE-SHIPPED OBJECT" guard then reads - * `isArtifactBacked`, which for an `object` resolves to - * `SchemaRegistry.getArtifactItem` -> `_provenance !== 'org'`. The hydration - * stamps `_provenance: 'org'` server-side (deliberately, cloud#970), so the - * guard is asking the ONE predicate the overwrite falsified, and it answers - * "not shipped" for an object the package still ships. - * - * ## What this file pins - * - * The package BINDING survives the overwrite (`registerObject` replaces only - * when the two package ids MATCH, so the surviving contributor provably carries - * the packaged owner's id), and an installed-package record is a fact about the - * process rather than about the destroyed body. So tier 3 decides on that pair - * instead, in BOTH directions: - * - * - a package-bound object whose package is installed SURVIVES the delete; - * - an object that genuinely should be retired still IS — both the - * package-less (`sys_metadata` sentinel) shape and the shape whose binding - * names a package this process never installed. - * - * ## The accepted cost (maintainer ruling 2026-08-09 on #6853, option C) - * - * A package-bound RUNTIME-authored object (Studio's package workspace, #4636) - * is indistinguishable from a package-shipped one by binding alone, so some - * genuinely deleted objects stay registered until restart — listable but - * rowless. Per the heal's own REGISTER WIDE / RETIRE NARROW argument that is - * the cheap direction: a surplus entry degrades to "listable but rowless", - * a wrongly retired one 404s the whole data plane. The honest fix for the - * distinguishability itself is #6853's direction B (the overlay registers as - * its own contributor layer), which is a separate ADR-0029 card. - */ - -const APP_PKG = 'app.myapp'; -/** The key an overlay row bound to NO package keeps. */ -const SENTINEL = 'sys_metadata'; - -/** The package's own body — carries a field the overlay does NOT. */ -const packagedBody = (name: string) => ({ - name, - label: 'Invoice', - fields: { - name: { name: 'name', type: 'text', label: 'Name' }, - amount: { name: 'amount', type: 'number', label: 'Amount' }, - packaged_only: { name: 'packaged_only', type: 'text', label: 'Packaged only' }, - }, -}); - -/** The tenant's overlay body — carries a field the package does NOT. */ -const overlayBody = (name: string) => ({ - name, - label: 'Invoice (customized)', - fields: { - name: { name: 'name', type: 'text', label: 'Name' }, - overlay_only: { name: 'overlay_only', type: 'text', label: 'Overlay only' }, - }, -}); - -/** ADR-0048: the overlay key includes `package_id`, so the double keys on it too. */ -const rowKey = (w: Record) => - [w.type, w.name, w.organization_id ?? '', w.package_id ?? '', w.state ?? 'active'].join('|'); - -const matchesWhere = (row: Record, where: Record) => - Object.entries(where ?? {}).every(([k, v]) => { - if (v === null) return row[k] === null || row[k] === undefined; - return row[k] === v; - }); - -/** - * One kernel process: a fresh `SchemaRegistry` + protocol over an in-memory - * `sys_metadata` / `sys_metadata_history` pair, plus a data-plane `insert` so - * the CRUD assertions measure writes that genuinely dispatched. - * - * `seed` is how a RESTART is expressed — the rows a previous process persisted, - * handed to a registry that knows nothing about them until `loadMetaFromDb` - * runs. That two-session shape is what makes this probe need NO escape hatch: - * session 1 writes the overlay before any package ships the name (so the - * two-tier gate has nothing to refuse), session 2 boots the package first and - * hydrates second, which is the real boot order. - */ -function makeSession(opts: { controlPlane?: boolean; seed?: any[] } = {}) { - const environmentId: string | undefined = opts.controlPlane === true ? undefined : 'env_test'; - const registry = new SchemaRegistry({ multiTenant: false }); - registry.logLevel = 'silent'; - const rows = new Map(); - for (const r of opts.seed ?? []) rows.set(rowKey(r), { ...r }); - const historyRows: any[] = []; - const dataRows: any[] = []; - let nextId = 0; - - const findRow = (w: Record) => { - for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r }; - return null; - }; - - const engine: any = { - registry, - async findOne(table: string, o: { where: Record }) { - if (table === 'sys_metadata_history') return historyRows.find((h) => matchesWhere(h, o.where)) ?? null; - if (table !== 'sys_metadata') return null; - return findRow(o.where)?.row ?? null; - }, - async find(table: string, o: { where: Record }) { - if (table === 'sys_metadata_history') return historyRows.filter((h) => matchesWhere(h, o.where)); - if (table !== 'sys_metadata') return []; - return Array.from(rows.values()).filter((r) => matchesWhere(r, o.where)); - }, - async insert(table: string, data: Record) { - if (table === 'sys_metadata_history') { - const h = { id: `h_${++nextId}`, ...(data as any) }; - historyRows.push(h); - return { id: h.id }; - } - if (table !== 'sys_metadata') { - const rec = { id: `rec_${++nextId}`, ...(data as any) }; - if (!table.startsWith('sys_')) dataRows.push({ object: table, ...rec }); - return rec; - } - const row = { id: `r_${++nextId}`, ...(data as any) }; - rows.set(rowKey(data), row); - return { id: row.id }; - }, - async update(table: string, data: Record, o: { where: Record }) { - assertEngineUpdateDispatch(data, o); - if (table !== 'sys_metadata') return { id: null }; - const found = findRow(o.where); - if (!found) return { id: null }; - const merged = { ...found.row, ...(data as any) }; - rows.delete(found.key); - rows.set(rowKey(merged), merged); - return { id: merged.id }; - }, - async delete(table: string, o?: Record) { - assertEngineDeleteDispatch(o); - if (table !== 'sys_metadata') return { deleted: 0 }; - const found = findRow(((o as any)?.where ?? {}) as Record); - if (!found) return { deleted: 0 }; - rows.delete(found.key); - return { deleted: 1 }; - }, - async syncObjectSchema() { /* no physical storage in this double */ }, - }; - - const protocol = new ObjectStackProtocolImplementation(engine, undefined, environmentId); - return { protocol, engine, registry, rows, dataRows }; -} - -/** - * Session 1: persist the overlay row, on a kernel where nothing ships the name - * yet. Returns the raw rows so a second session can boot on top of them. - * - * This is the precondition #6853 P6 names — "a row that already exists - * (authored once under the hatch, seeded, imported, or predating the gates)" — - * expressed the way the platform itself produces one, so the probe never has to - * hand-forge a checksum. - */ -async function persistOverlayRow(name: string, packageId?: string): Promise { - const s = makeSession(); - await s.protocol.saveMetaItem({ - type: 'object', - name, - ...(packageId ? { packageId } : {}), - item: overlayBody(name), - }); - return Array.from(s.rows.values()).map((r) => ({ ...r })); -} - -/** The installed-package record `registerApp` writes before it registers the objects. */ -const installPackage = (registry: SchemaRegistry, id: string) => - registry.installPackage({ id, name: 'My App', version: '1.0.0' } as any); - -const ownerPackageId = (registry: SchemaRegistry, name: string) => - registry.getObjectOwner(name)?.packageId; - -const fieldNames = (registry: SchemaRegistry, name: string) => - Object.keys((registry.getObject(name) as any)?.fields ?? {}); - -const storedRows = (rows: Map, name: string) => - Array.from(rows.values()).filter((r) => r.name === name); - -describe('#7012 — tier 3 refuses to unregister an object bound to an installed package', () => { - /** - * The precondition every assertion below rests on, measured rather than - * assumed: hydrating the overlay does not SHADOW the packaged contributor, - * it DESTROYS it, and the two predicates then disagree — the binding still - * names the package, `isArtifactBacked` says nothing is shipped. - * - * Green in both directions (it describes the pre-existing overwrite, which - * this card does not change) — reported as a guard on the premise, not as - * evidence for the fix. - */ - it('the boot replay destroys the packaged definition while the package BINDING survives', async () => { - const seed = await persistOverlayRow('myapp_invoice', APP_PKG); - const { protocol, registry } = makeSession({ seed }); - - // Real boot order: the package registers its objects, then the DB hydrates. - installPackage(registry, APP_PKG); - registry.registerObject(packagedBody('myapp_invoice') as any, APP_PKG); - expect((registry.getObject('myapp_invoice') as any)._provenance).toBe('package'); - expect(fieldNames(registry, 'myapp_invoice')).toContain('packaged_only'); - expect(registry.getArtifactItem('object', 'myapp_invoice')).toBeDefined(); - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - let res: any; - let warned: string[]; - try { - res = await protocol.loadMetaFromDb(); - } finally { - warned = warn.mock.calls.map((c) => String(c[0])); - warn.mockRestore(); - } - - // Silently, with a clean receipt — nothing tells an operator the - // packaged definition has just been replaced. - expect(res).toMatchObject({ loaded: 1, errors: 0, invalid: 0 }); - expect(warned.filter((m) => m.includes('myapp_invoice'))).toEqual([]); - - // The definition is GONE (not shadowed): no copy of it survives. - expect(fieldNames(registry, 'myapp_invoice')).not.toContain('packaged_only'); - expect(fieldNames(registry, 'myapp_invoice')).toContain('overlay_only'); - expect((registry.getObject('myapp_invoice') as any)._provenance).toBe('org'); - // …so the predicate tier 3 used to consult now answers "not shipped". - expect(registry.getArtifactItem('object', 'myapp_invoice')).toBeUndefined(); - // …while the BINDING, which the replacement rule could not change (it - // replaces only when the ids match), still names the package. - expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); - expect(registry.getPackage(APP_PKG)).toBeDefined(); - }); - - /** - * THE OUTAGE, and the direction this card closes. Pre-fix every assertion - * after the delete was the opposite: contributors emptied, `getObject` - * null, and the data plane 404ing on a table that still holds the rows. - * - * Asserted by REFUSAL IDENTITY where a refusal is the subject — a bare - * `toThrow` would pass for any unrelated throw, and the whole point here is - * WHICH error the data plane answers. - */ - it('a package-bound object SURVIVES the delete, and its data plane stays up', async () => { - const seed = await persistOverlayRow('myapp_invoice', APP_PKG); - const { protocol, registry, rows, dataRows } = makeSession({ seed }); - installPackage(registry, APP_PKG); - registry.registerObject(packagedBody('myapp_invoice') as any, APP_PKG); - await protocol.loadMetaFromDb(); - - // The data plane works before the delete, so "works after" cannot be - // green for the empty reason. - const created = await protocol.createData({ - object: 'myapp_invoice', data: { name: 'INV-1' }, - }); - expect(created.id).toBeTruthy(); - expect(dataRows).toHaveLength(1); - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - let res: any; - let warned: string[]; - try { - res = await protocol.deleteMetaItem({ type: 'object', name: 'myapp_invoice' }); - } finally { - warned = warn.mock.calls.map((c) => String(c[0])); - warn.mockRestore(); - } - - // The delete itself is unchanged: the tenant's overlay row really is gone. - expect(res.success).toBe(true); - expect(res.reset).toBe(true); - expect(storedRows(rows, 'myapp_invoice')).toHaveLength(0); - - // THE LINES THAT WERE RED: the object stays registered, owned by the - // package that ships it. - expect(registry.getObject('myapp_invoice')).toBeDefined(); - expect(registry.getObjectContributors('myapp_invoice')).toHaveLength(1); - expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); - - // …and the consequence an operator actually feels: data CRUD still - // dispatches. Pre-fix this threw OBJECT_NOT_FOUND / 404 while the table - // still held row 1. - const after = await protocol.createData({ - object: 'myapp_invoice', data: { name: 'INV-2' }, - }); - expect(after.id).toBeTruthy(); - expect(dataRows).toHaveLength(2); - - // The divergence between the store and the runtime is STATED, not - // inferred later from a registry that disagrees with `sys_metadata` — - // the same discipline the ADR-0029 extender refusal one line up carries. - const refusals = warned.filter((m) => m.includes('stays registered')); - expect(refusals).toHaveLength(1); - expect(refusals[0]).toContain('myapp_invoice'); - expect(refusals[0]).toContain(APP_PKG); - }); - - /** - * THE OTHER DIRECTION — the guard is a guard, not a blanket refusal. A - * runtime-authored object with no package binding at all keeps the - * `'sys_metadata'` sentinel, which names no installed package, so tier 3 - * retires it exactly as before and the data plane closes behind it. - * - * Green BEFORE and AFTER the change: it never depended on the new conjunct. - * Reported as a guard against over-refusal, not as evidence for the fix. - */ - it('a package-LESS runtime object is still retired, and its data plane closes', async () => { - const seed = await persistOverlayRow('myapp_note'); - const { protocol, registry, dataRows } = makeSession({ seed }); - await protocol.loadMetaFromDb(); - - expect(ownerPackageId(registry, 'myapp_note')).toBe(SENTINEL); - expect(registry.getPackage(SENTINEL)).toBeUndefined(); - const created = await protocol.createData({ object: 'myapp_note', data: { name: 'N-1' } }); - expect(created.id).toBeTruthy(); - - await protocol.deleteMetaItem({ type: 'object', name: 'myapp_note' }); - - expect(registry.getObject('myapp_note')).toBeUndefined(); - expect(registry.getItem('object', 'myapp_note')).toBeUndefined(); - const err = await protocol - .createData({ object: 'myapp_note', data: { name: 'N-2' } }) - .then(() => null, (e: any) => e); - expect(err).toBeInstanceOf(Error); - expect(err.code).toBe('OBJECT_NOT_FOUND'); - expect(err.status).toBe(404); - expect(dataRows).toHaveLength(1); - }); - - /** - * The BOUNDARY of the new predicate, and the reason it is "installed - * package" and not "has a package id". A binding naming a package this - * process never installed is a dangling reference — nothing ships that - * name here, so nothing is protected by keeping the entry, and the walk's - * tier-3 verdict ("no layer serves this name") stands. - * - * This is also the shape every pre-existing pin in - * `protocol-delete-object-registry-heal.test.ts` uses, which is why they - * stay green: those seed `packageId: 'app.myapp'` without ever installing - * a package record for it. - * - * Green BEFORE and AFTER — a guard, not evidence. - */ - it('a binding naming a package that is NOT installed is still retired', async () => { - const seed = await persistOverlayRow('myapp_invoice', APP_PKG); - const { protocol, registry } = makeSession({ seed }); - await protocol.loadMetaFromDb(); - - expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); - expect(registry.getPackage(APP_PKG)).toBeUndefined(); - - await protocol.deleteMetaItem({ type: 'object', name: 'myapp_invoice' }); - - expect(registry.getObject('myapp_invoice')).toBeUndefined(); - expect(registry.getItem('object', 'myapp_invoice')).toBeUndefined(); - }); - - /** - * A DISABLED package still counts as installed. `disablePackage` flips - * `enabled`/`status` on the record and touches no contributor — the objects - * stay in `objectContributors` and the data plane keeps dispatching on - * them — so reading the lifecycle flag here would unregister a definition - * nothing else removes, which is the outage again through a second door. - * The predicate is deliberately "is there an installed-package record", - * never "is that package enabled". - */ - it('a DISABLED installed package still protects its object', async () => { - const seed = await persistOverlayRow('myapp_invoice', APP_PKG); - const { protocol, registry } = makeSession({ seed }); - installPackage(registry, APP_PKG); - registry.registerObject(packagedBody('myapp_invoice') as any, APP_PKG); - await protocol.loadMetaFromDb(); - registry.disablePackage(APP_PKG); - expect(registry.getPackage(APP_PKG)?.enabled).toBe(false); - // The lifecycle flag removed nothing — the object is still served. - expect(registry.getObject('myapp_invoice')).toBeDefined(); - - await protocol.deleteMetaItem({ type: 'object', name: 'myapp_invoice' }); - - expect(registry.getObject('myapp_invoice')).toBeDefined(); - expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); - }); - - /** - * The plural `objects` spelling reaches the same limb. - * `canonicalizeMetaRequestType` folds it at the top of `deleteMetaItem` and - * the heal re-folds through `PLURAL_TO_SINGULAR`; the guard keys off the - * SINGULAR, so neither spelling can miss it (#4432's "every surface in - * agreement"). - */ - it('the plural `objects` spelling is guarded identically', async () => { - const seed = await persistOverlayRow('myapp_invoice', APP_PKG); - const { protocol, registry } = makeSession({ seed }); - installPackage(registry, APP_PKG); - registry.registerObject(packagedBody('myapp_invoice') as any, APP_PKG); - await protocol.loadMetaFromDb(); - - await protocol.deleteMetaItem({ type: 'objects', name: 'myapp_invoice' }); - - expect(registry.getObject('myapp_invoice')).toBeDefined(); - expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); - }); - - /** - * The control-plane kernel reaches this walk through a different door — - * `deleteMetaItem`'s two-tier authorization is skipped entirely when - * `environmentId === undefined`, and `revertCommit`'s soft-remove limb - * arrives here without it either. Pinned so the guard is not mistaken for - * something only the tenant path exercises. - */ - it('a control-plane kernel is guarded too', async () => { - const seed = await persistOverlayRow('myapp_invoice', APP_PKG); - const { protocol, registry } = makeSession({ seed, controlPlane: true }); - installPackage(registry, APP_PKG); - registry.registerObject(packagedBody('myapp_invoice') as any, APP_PKG); - await protocol.loadMetaFromDb(); - - await protocol.deleteMetaItem({ type: 'object', name: 'myapp_invoice' }); - - expect(registry.getObject('myapp_invoice')).toBeDefined(); - expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); - }); -}); diff --git a/packages/objectql/src/protocol-object-overlay-layer.test.ts b/packages/objectql/src/protocol-object-overlay-layer.test.ts new file mode 100644 index 0000000000..5e2a425099 --- /dev/null +++ b/packages/objectql/src/protocol-object-overlay-layer.test.ts @@ -0,0 +1,581 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol'; +import { SchemaRegistry } from './registry.js'; +// [#4550 / #5480] The producer's OWN write-verb dispatch decisions, so the +// engine double below cannot accept a call `ObjectQL.delete` / `ObjectQL.update` +// refuses — a double looser than the implementation is no test at all. +import { assertEngineDeleteDispatch } from './engine-delete-dispatch.js'; +import { assertEngineUpdateDispatch } from './engine-update-dispatch.js'; + +/** + * ADR-0029 D9, end to end — a tenant overlay of an object registers as its own + * contributor LAYER, and the two gates that read `isArtifactBacked` stop being + * silently disarmed. + * + * ## What this file used to pin, and why it does not any more + * + * It was #7012's file: tier 3 of the delete heal decided on the PACKAGE BINDING + * instead of on `isArtifactBacked`, because the predicate had been falsified. + * `SchemaRegistry.registerObject` spliced out the same-package `own` + * contributor, so hydrating an overlay row whose `package_id` equalled the + * packaged owner's id DESTROYED the packaged definition — no second copy + * anywhere — and `loadMetaFromDb` replayed that on every boot, silently: + * + * ``` + * loadMetaFromDb -> {"loaded":1,"errors":0,"invalid":0} warnings: [] + * DELETE -> {"success":true,"reset":true} + * objectContributors: [] getObject: null + * data CRUD: OBJECT_NOT_FOUND / 404 (while the table still holds the rows) + * ``` + * + * D9 removes the falsification at its source, so the guard is RETIRED here and + * this file pins the model that subsumes it. The first case below is the direct + * inversion of the old premise test: the packaged definition survives the boot + * replay. `isArtifactBacked` is then honest, which covers the case the guard + * protected without a second predicate — and the guard's own remaining reach + * was only its ACCEPTED COST (a package-bound RUNTIME-authored object kept + * listable-but-rowless until restart), which is no longer the cheap direction + * but simply the wrong answer. Both directions are pinned below. + * + * ## The cost D9.6 declares, and the second gate it also reaches + * + * With the predicate honest, `object`'s `allowOrgOverride: false` is enforced + * CONSISTENTLY rather than only on the first write. D9.6 states that for + * `saveMetaItem`; the same predicate feeds `deleteMetaItem`'s two-tier + * authorization and `SysMetadataRepository.assertAllowed`, so a tenant overlay + * of a PACKAGED object can no longer be re-saved or reset without the + * documented operator hatch either. That is not softened here — it is pinned, + * because a fixture encoding the old leniency would be encoding the defect. + */ + +const APP_PKG = 'app.myapp'; +const OTHER_PKG = 'app.otherapp'; +/** The key an overlay row bound to NO package keeps. */ +const SENTINEL = 'sys_metadata'; + +/** The package's own body — carries a field the overlay does NOT. */ +const packagedBody = (name: string) => ({ + name, + label: 'Invoice', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + amount: { name: 'amount', type: 'number', label: 'Amount' }, + packaged_only: { name: 'packaged_only', type: 'text', label: 'Packaged only' }, + }, +}); + +/** The tenant's overlay body — carries a field the package does NOT. */ +const overlayBody = (name: string) => ({ + name, + label: 'Invoice (customized)', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + overlay_only: { name: 'overlay_only', type: 'text', label: 'Overlay only' }, + }, +}); + +/** ADR-0048: the overlay key includes `package_id`, so the double keys on it too. */ +const rowKey = (w: Record) => + [w.type, w.name, w.organization_id ?? '', w.package_id ?? '', w.state ?? 'active'].join('|'); + +const matchesWhere = (row: Record, where: Record) => + Object.entries(where ?? {}).every(([k, v]) => { + if (v === null) return row[k] === null || row[k] === undefined; + return row[k] === v; + }); + +/** + * One kernel process: a fresh `SchemaRegistry` + protocol over an in-memory + * `sys_metadata` / `sys_metadata_history` pair, plus a data-plane `insert` so + * the CRUD assertions measure writes that genuinely dispatched. + * + * `seed` is how a RESTART is expressed — the rows a previous process persisted, + * handed to a registry that knows nothing about them until `loadMetaFromDb` + * runs. That two-session shape is what makes this probe need NO escape hatch: + * session 1 writes the overlay before any package ships the name (so the + * two-tier gate has nothing to refuse), session 2 boots the package first and + * hydrates second, which is the real boot order. + */ +function makeSession(opts: { controlPlane?: boolean; seed?: any[] } = {}) { + const environmentId: string | undefined = opts.controlPlane === true ? undefined : 'env_test'; + const registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + const rows = new Map(); + for (const r of opts.seed ?? []) rows.set(rowKey(r), { ...r }); + const historyRows: any[] = []; + const dataRows: any[] = []; + let nextId = 0; + + const findRow = (w: Record) => { + for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r }; + return null; + }; + + const engine: any = { + registry, + async findOne(table: string, o: { where: Record }) { + if (table === 'sys_metadata_history') return historyRows.find((h) => matchesWhere(h, o.where)) ?? null; + if (table !== 'sys_metadata') return null; + return findRow(o.where)?.row ?? null; + }, + async find(table: string, o: { where: Record }) { + if (table === 'sys_metadata_history') return historyRows.filter((h) => matchesWhere(h, o.where)); + if (table !== 'sys_metadata') return []; + return Array.from(rows.values()).filter((r) => matchesWhere(r, o.where)); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_history') { + const h = { id: `h_${++nextId}`, ...(data as any) }; + historyRows.push(h); + return { id: h.id }; + } + if (table !== 'sys_metadata') { + const rec = { id: `rec_${++nextId}`, ...(data as any) }; + if (!table.startsWith('sys_')) dataRows.push({ object: table, ...rec }); + return rec; + } + const row = { id: `r_${++nextId}`, ...(data as any) }; + rows.set(rowKey(data), row); + return { id: row.id }; + }, + async update(table: string, data: Record, o: { where: Record }) { + assertEngineUpdateDispatch(data, o); + if (table !== 'sys_metadata') return { id: null }; + const found = findRow(o.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as any) }; + rows.delete(found.key); + rows.set(rowKey(merged), merged); + return { id: merged.id }; + }, + async delete(table: string, o?: Record) { + assertEngineDeleteDispatch(o); + if (table !== 'sys_metadata') return { deleted: 0 }; + const found = findRow(((o as any)?.where ?? {}) as Record); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async syncObjectSchema() { /* no physical storage in this double */ }, + }; + + const protocol = new ObjectStackProtocolImplementation(engine, undefined, environmentId); + return { protocol, engine, registry, rows, dataRows }; +} + +/** + * Session 1: persist the overlay row, on a kernel where nothing ships the name + * yet. Returns the raw rows so a second session can boot on top of them. + * + * This is the precondition #6853 P6 names — "a row that already exists + * (authored once under the hatch, seeded, imported, or predating the gates)" — + * expressed the way the platform itself produces one, so the probe never has to + * hand-forge a checksum. + */ +async function persistOverlayRow(name: string, packageId?: string): Promise { + const s = makeSession(); + await s.protocol.saveMetaItem({ + type: 'object', + name, + ...(packageId ? { packageId } : {}), + item: overlayBody(name), + }); + return Array.from(s.rows.values()).map((r) => ({ ...r })); +} + +/** The installed-package record `registerApp` writes before it registers the objects. */ +const installPackage = (registry: SchemaRegistry, id: string) => + registry.installPackage({ id, name: 'My App', version: '1.0.0' } as any); + +const ownerPackageId = (registry: SchemaRegistry, name: string) => + registry.getObjectOwner(name)?.packageId; + +const fieldNames = (registry: SchemaRegistry, name: string) => + Object.keys((registry.getObject(name) as any)?.fields ?? {}); + +const storedRows = (rows: Map, name: string) => + Array.from(rows.values()).filter((r) => r.name === name); + +/** [ADR-0005 / D9.6] The documented operator hatch — the ONE door, for the LIFE of the customization. */ +function withObjectWritable(run: () => T): T { + const previous = process.env.OS_METADATA_WRITABLE; + process.env.OS_METADATA_WRITABLE = 'object'; + // Two memoised readers of the same env var — the protocol's gate and the + // repository's `assertAllowed`. Both must be reset or the second answers + // from a stale parse. + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + const restore = () => { + if (previous === undefined) delete process.env.OS_METADATA_WRITABLE; + else process.env.OS_METADATA_WRITABLE = previous; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + }; + try { + const out = run(); + return (out as any) instanceof Promise + ? ((out as any).finally(restore) as T) + : (restore(), out); + } catch (e) { + restore(); + throw e; + } +} + +/** A booted kernel: the package registers its objects, then the DB hydrates. */ +async function bootWithPackage(seed: any[], opts: { controlPlane?: boolean; install?: boolean } = {}) { + const s = makeSession({ seed, ...(opts.controlPlane ? { controlPlane: true } : {}) }); + if (opts.install !== false) installPackage(s.registry, APP_PKG); + s.registry.registerObject(packagedBody('myapp_invoice') as any, APP_PKG); + const res = await s.protocol.loadMetaFromDb(); + return { ...s, res }; +} + +const kinds = (registry: SchemaRegistry, name: string) => + registry.getObjectContributors(name).map((c) => c.ownership); + +describe('ADR-0029 D9 — the boot replay LAYERS the tenant row instead of destroying the package', () => { + /** + * The direct inversion of this file's old premise test, which asserted + * `getArtifactItem(...)` was `undefined` and `packaged_only` was gone. Both + * flip here, and that flip is the whole card. + */ + it('after hydration the packaged definition is still there, underneath', async () => { + const seed = await persistOverlayRow('myapp_invoice', APP_PKG); + const { registry, res } = await bootWithPackage(seed); + + expect(res).toMatchObject({ loaded: 1, errors: 0, invalid: 0 }); + + // Two layers, not one — and the owner is the package, not the tenant. + expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); + const owner = registry.getObjectOwner('myapp_invoice')!; + expect(Object.keys((owner.definition as any).fields)).toContain('packaged_only'); + expect((owner.definition as any)._provenance).toBe('package'); + + // The RESOLVED object does not move: still the tenant's body, still + // stamped `org`. Bit-for-bit what the splice used to produce. + expect(fieldNames(registry, 'myapp_invoice')).toContain('overlay_only'); + expect(fieldNames(registry, 'myapp_invoice')).not.toContain('packaged_only'); + expect((registry.getObject('myapp_invoice') as any)._provenance).toBe('org'); + + // …and the predicate both gates read is honest again. + expect(registry.getArtifactItem('object', 'myapp_invoice')).toBeDefined(); + }); + + it('the hydration is idempotent across boots — one layer, not a stack', async () => { + const seed = await persistOverlayRow('myapp_invoice', APP_PKG); + const { protocol, registry } = await bootWithPackage(seed); + await protocol.loadMetaFromDb(); + await protocol.loadMetaFromDb(); + expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + }); +}); + +describe('ADR-0029 D9.6 — the declared contract, enforced consistently', () => { + /** + * The cost the record already accepts, measured. Today the FIRST write is + * refused and, by destroying the evidence, admits every later one through + * the `allowRuntimeCreate` tier. Asserted by refusal IDENTITY (code AND + * status), not by `toThrow` — a bare throw assertion carries one bit where + * the defect has two. + */ + it('an overlay write to a PACKAGED object is refused EVERY time, not only the first', async () => { + const seed = await persistOverlayRow('myapp_invoice', APP_PKG); + const { protocol, registry } = await bootWithPackage(seed); + + for (const attempt of [1, 2, 3]) { + const err = await protocol.saveMetaItem({ + type: 'object', name: 'myapp_invoice', packageId: APP_PKG, item: overlayBody('myapp_invoice'), + }).then(() => null, (e: any) => e); + expect(err, `attempt ${attempt}`).toBeInstanceOf(Error); + expect(err.code, `attempt ${attempt}`).toBe('NOT_OVERRIDABLE'); + expect(err.status, `attempt ${attempt}`).toBe(403); + } + + // The refusal costs the packaged definition nothing. + expect(registry.getArtifactItem('object', 'myapp_invoice')).toBeDefined(); + expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + }); + + /** + * The SECOND gate that reads the same predicate, which D9.6 does not + * enumerate: `deleteMetaItem`'s two-tier authorization. Pinned rather than + * softened — the reset of a customization the type never allowed is the + * same declaration, on the other verb, and it is exactly the delete whose + * unchecked version took the object off the data plane (#7012). + */ + it('…and so is the RESET of one: deleteMetaItem answers NOT_OVERRIDABLE / 403', async () => { + const seed = await persistOverlayRow('myapp_invoice', APP_PKG); + const { protocol } = await bootWithPackage(seed); + + const err = await protocol + .deleteMetaItem({ type: 'object', name: 'myapp_invoice' }) + .then(() => null, (e: any) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('NOT_OVERRIDABLE'); + expect(err.status).toBe(403); + }); + + /** + * Guard against over-refusal: a RUNTIME-authored object — no package layer + * — is untouched by the honest predicate and stays editable. This is + * cloud#970's counter-example, and it is why D9.6 reads the OWNER + * contributor rather than simply trusting a package id. + */ + it('a runtime-authored object stays editable — no code layer, no refusal', async () => { + const seed = await persistOverlayRow('myapp_note', APP_PKG); + const { protocol, registry } = makeSession({ seed }); + installPackage(registry, APP_PKG); + await protocol.loadMetaFromDb(); + + expect(registry.getArtifactItem('object', 'myapp_note')).toBeUndefined(); + const evolved = overlayBody('myapp_note'); + (evolved.fields as any).due_date = { name: 'due_date', type: 'date', label: 'Due' }; + const res = await protocol.saveMetaItem({ + type: 'object', name: 'myapp_note', packageId: APP_PKG, item: evolved, + }); + expect(res.success).toBe(true); + expect(fieldNames(registry, 'myapp_note')).toContain('due_date'); + }); +}); + +describe('ADR-0029 D9.7 — the delete is a SUBTRACTION, and #7012\'s guard is retired', () => { + /** + * THE RESTORATION THAT IS NOT A RE-REGISTRATION. Under the hatch — the one + * door D9.6 names, which now has to stay open for the life of the + * customization — the delete removes the tenant's LAYER and the packaged + * owner, which was never destroyed, is served again. Pre-D9 the same delete + * emptied `objectContributors` and 404'd the data plane. + */ + it('removes the overlay layer and serves the packaged owner again, data plane up throughout', async () => { + const seed = await persistOverlayRow('myapp_invoice', APP_PKG); + const { protocol, registry, rows, dataRows } = await bootWithPackage(seed); + + // The data plane works before the delete, so "works after" cannot be + // green for the empty reason. + expect((await protocol.createData({ object: 'myapp_invoice', data: { name: 'INV-1' } })).id).toBeTruthy(); + + const res = await withObjectWritable(() => + protocol.deleteMetaItem({ type: 'object', name: 'myapp_invoice' })); + + expect(res.success).toBe(true); + expect(storedRows(rows, 'myapp_invoice')).toHaveLength(0); + + // The layer is gone; the package's own definition is back, in full. + expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); + expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); + expect(fieldNames(registry, 'myapp_invoice')).toContain('packaged_only'); + expect(fieldNames(registry, 'myapp_invoice')).not.toContain('overlay_only'); + expect((registry.getObject('myapp_invoice') as any)._provenance).toBe('package'); + + // …and CRUD never stopped dispatching. + expect((await protocol.createData({ object: 'myapp_invoice', data: { name: 'INV-2' } })).id).toBeTruthy(); + expect(dataRows).toHaveLength(2); + }); + + it('the plural `objects` spelling reaches the same subtraction', async () => { + const seed = await persistOverlayRow('myapp_invoice', APP_PKG); + const { protocol, registry } = await bootWithPackage(seed); + + await withObjectWritable(() => protocol.deleteMetaItem({ type: 'objects', name: 'myapp_invoice' })); + + expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); + expect(fieldNames(registry, 'myapp_invoice')).toContain('packaged_only'); + }); + + /** + * A CONTROL-PLANE kernel skips `deleteMetaItem`'s two-tier authorization + * entirely (`environmentId === undefined`) — but not the repository's + * `assertAllowed`, which is topology-independent and refuses an + * `override-artifact` delete of a type without `allowOrgOverride`. Pinned + * so "the tenant gate is skipped" is never mistaken for "ungated". + */ + it('a control-plane kernel refuses at the repository, and subtracts under the hatch', async () => { + const seed = await persistOverlayRow('myapp_invoice', APP_PKG); + const a = await bootWithPackage(seed, { controlPlane: true }); + const refused = await a.protocol + .deleteMetaItem({ type: 'object', name: 'myapp_invoice' }) + .then(() => null, (e: any) => e); + expect(refused).toBeInstanceOf(Error); + expect(String(refused.message)).toContain('NOT_OVERRIDABLE'); + + const b = await bootWithPackage(seed, { controlPlane: true }); + await withObjectWritable(() => b.protocol.deleteMetaItem({ type: 'object', name: 'myapp_invoice' })); + expect(kinds(b.registry, 'myapp_invoice')).toEqual(['own']); + }); + + /** + * #7012'S RETIREMENT, THE DIRECTION THAT CHANGES. This is the object the + * binding guard kept alive: a package-bound RUNTIME-authored object + * (Studio's package workspace, #4636) whose package IS installed. Nothing + * ships the name — `getPackagedObjectOwner` answers `undefined`, so the row + * registered as `own` and `isArtifactBacked` is honestly `false` — so the + * row WAS the item and the operator's delete now means what it says. + * + * Under the retired guard this was "listable but rowless until restart": + * `getObject` kept answering and data CRUD kept writing into a table whose + * metadata the operator had just deleted. + */ + it('a package-bound RUNTIME-authored object is retired, and its data plane closes', async () => { + const seed = await persistOverlayRow('myapp_note', APP_PKG); + const { protocol, registry, dataRows } = makeSession({ seed }); + installPackage(registry, APP_PKG); // the package record exists… + await protocol.loadMetaFromDb(); // …but ships no object of this name + + expect(registry.getPackage(APP_PKG)).toBeDefined(); + expect(ownerPackageId(registry, 'myapp_note')).toBe(APP_PKG); + expect(registry.getArtifactItem('object', 'myapp_note')).toBeUndefined(); + expect((await protocol.createData({ object: 'myapp_note', data: { name: 'N-1' } })).id).toBeTruthy(); + + await protocol.deleteMetaItem({ type: 'object', name: 'myapp_note' }); + + expect(registry.getObject('myapp_note')).toBeUndefined(); + expect(registry.getItem('object', 'myapp_note')).toBeUndefined(); + const err = await protocol + .createData({ object: 'myapp_note', data: { name: 'N-2' } }) + .then(() => null, (e: any) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_NOT_FOUND'); + expect(err.status).toBe(404); + expect(dataRows).toHaveLength(1); + }); + + /** + * The two directions the guard already answered correctly, carried over + * verbatim in intent. Green BEFORE and AFTER the retirement — guards that + * the retirement did not open a hole, not evidence for it. + */ + it('a package-LESS runtime object is still retired', async () => { + const seed = await persistOverlayRow('myapp_note'); + const { protocol, registry } = makeSession({ seed }); + await protocol.loadMetaFromDb(); + + expect(ownerPackageId(registry, 'myapp_note')).toBe(SENTINEL); + await protocol.deleteMetaItem({ type: 'object', name: 'myapp_note' }); + expect(registry.getObject('myapp_note')).toBeUndefined(); + }); + + it('a binding naming a package that is NOT installed is still retired', async () => { + const seed = await persistOverlayRow('myapp_invoice', APP_PKG); + const { protocol, registry } = makeSession({ seed }); + await protocol.loadMetaFromDb(); + + expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); + expect(registry.getPackage(APP_PKG)).toBeUndefined(); + await protocol.deleteMetaItem({ type: 'object', name: 'myapp_invoice' }); + expect(registry.getObject('myapp_invoice')).toBeUndefined(); + }); +}); + +describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never an ownership claim', () => { + /** `P == O`: the normal case. One overlay layer over the owner's object. */ + it('same package — a normal layer, no error, no warning', async () => { + const seed = await persistOverlayRow('myapp_invoice', APP_PKG); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let warned: string[] = []; + let res: any; + try { + res = (await bootWithPackage(seed)).res; + } finally { + warned = warn.mock.calls.map((c) => String(c[0])); + warn.mockRestore(); + } + expect(res).toMatchObject({ loaded: 1, errors: 0 }); + expect(warned.filter((m) => m.includes('myapp_invoice'))).toEqual([]); + }); + + /** + * `P` absent — the `'sys_metadata'` sentinel. ACCEPTED. Today this THROWS + * `already owned by package "app.myapp"` into `loadMetaFromDb`'s per-record + * catch (#6995, measured as P2); that refusal was an artefact of the + * borrowed `own` slot, not a decision. A package-less env-wide overlay is + * ADR-0005's platform-global shape: the row addresses the object by name + * and the registry knows who owns it. + */ + it('package-LESS row over a packaged object — accepted as the layer, not refused', async () => { + const seed = await persistOverlayRow('myapp_invoice'); + const { registry, res } = await bootWithPackage(seed); + + expect(res).toMatchObject({ loaded: 1, errors: 0 }); + expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + // The layer carries the sentinel as its own provenance; ownership is + // untouched, and still the package's. + expect(registry.getObjectContributors('myapp_invoice')[1].packageId).toBe(SENTINEL); + expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); + expect(fieldNames(registry, 'myapp_invoice')).toContain('overlay_only'); + }); + + /** + * `P == Q`: REFUSED AT THE PRODUCER, loudly — the half #6995 was filed + * about. Pre-D9 `saveMetaItem` returned `success: true` while + * `registerObject`'s throw went to a best-effort `console.warn`: a receipt + * for a write the runtime had discarded. + * + * Asserted by refusal identity (code AND status), because "refused with the + * wrong envelope" and "not refused at all" are the two defects here and a + * bare `toThrow` cannot separate them. + */ + it('different package — the WRITE is refused with an ADR-0112 envelope, and nothing is persisted', async () => { + const seed = await persistOverlayRow('myapp_invoice', APP_PKG); + const { protocol, registry, rows } = await bootWithPackage(seed); + + const err = await withObjectWritable(() => protocol.saveMetaItem({ + type: 'object', name: 'myapp_invoice', packageId: OTHER_PKG, item: overlayBody('myapp_invoice'), + }).then(() => null, (e: any) => e)); + + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); + expect(err.status).toBe(422); + expect(String(err.message)).toContain(OTHER_PKG); + expect(String(err.message)).toContain(APP_PKG); + + // No success receipt, and no row for the mis-bound package. + expect(storedRows(rows, 'myapp_invoice').filter((r) => r.package_id === OTHER_PKG)).toHaveLength(0); + // The registry is untouched: one owner, one layer, still the right ones. + expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); + }); + + /** + * …and the BOOT side of the same fact: the row is not layered and is + * counted in `loadMetaFromDb`'s per-record `errors` with its reason, which + * is why #6995 is a write-path divergence and not a boot-path one. + */ + it('different package — the BOOT counts it in `errors` and leaves the package alone', async () => { + const seed = await persistOverlayRow('myapp_invoice', OTHER_PKG); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let warned: string[] = []; + let booted: any; + try { + booted = await bootWithPackage(seed); + } finally { + warned = warn.mock.calls.map((c) => String(c[0])); + warn.mockRestore(); + } + + expect(booted.res).toMatchObject({ loaded: 0, errors: 1 }); + expect(warned.join('\n')).toContain('object_overlay_package_mismatch'); + + // The packaged definition is served, untouched — no half-applied layer. + expect(kinds(booted.registry, 'myapp_invoice')).toEqual(['own']); + expect(fieldNames(booted.registry, 'myapp_invoice')).toContain('packaged_only'); + expect(booted.registry.getArtifactItem('object', 'myapp_invoice')).toBeDefined(); + }); + + /** + * Guard: the mismatch refusal is about `object` only. Every other type can + * legitimately hold two rows for one name bound to two packages, and the + * registry can represent that — so nothing here narrows them. + */ + it('a non-object type with a different binding is untouched by the rule', async () => { + const { protocol } = makeSession(); + const res = await protocol.saveMetaItem({ + type: 'view', name: 'shared_grid', packageId: OTHER_PKG, + item: { name: 'shared_grid', type: 'grid', columns: ['name'] }, + }); + expect(res.success).toBe(true); + }); +}); diff --git a/packages/objectql/src/registry-object-overlay-layer.test.ts b/packages/objectql/src/registry-object-overlay-layer.test.ts new file mode 100644 index 0000000000..d03b5f6843 --- /dev/null +++ b/packages/objectql/src/registry-object-overlay-layer.test.ts @@ -0,0 +1,499 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0029 D9 — a tenant overlay of an object is its own contributor LAYER, + * not a second `own`. + * + * ## What changed, and what deliberately did not + * + * Before D9 a tenant overlay reached `registerObject` with two arguments, so + * `ownership` took its default `'own'`; when the row's `package_id` equalled + * the packaged owner's id the re-registration branch SPLICED THE PACKAGED + * CONTRIBUTOR OUT. The packaged definition was destroyed at write time — not + * shadowed — and `loadMetaFromDb` replayed that destruction on every boot + * (#6853, measured P1/P6). + * + * D9 registers the overlay as a third, NON-owning kind with replace semantics + * on the base layer: `base = overlay ?? own`, extenders fold on top as before. + * The load-bearing property, and the first thing this file pins, is that the + * RESOLVED OBJECT DOES NOT MOVE — `resolveObject` answers bit-for-bit what the + * old splice produced, `_provenance: 'org'` included. Only what the registry + * REMEMBERS changes: the packaged owner is still there, underneath. + * + * ## Why several assertions here are guards rather than evidence + * + * D9.5 keeps `assertSingleOwnerPerObject` literally unchanged and D9.4 leaves + * `computeFQN` alone, so the pins for those are green in both directions by + * construction. They are here because ADR-0028's D5/D6 rest on that sentence + * staying unconditional — a future "unless it is an overlay" clause must fail + * something — and they are reported as guards, not as evidence for D9. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + SchemaRegistry, + DEFAULT_OWNER_PRIORITY, + DEFAULT_OVERLAY_PRIORITY, + DEFAULT_EXTENDER_PRIORITY, +} from './registry.js'; + +const APP_PKG = 'app.myapp'; +const OTHER_PKG = 'app.otherapp'; +/** The key an overlay row bound to NO package keeps. */ +const SENTINEL = 'sys_metadata'; + +/** The package's own body — carries a field the overlay does NOT. */ +const packagedBody = (name: string) => ({ + name, + label: 'Invoice', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + amount: { name: 'amount', type: 'number', label: 'Amount' }, + packaged_only: { name: 'packaged_only', type: 'text', label: 'Packaged only' }, + }, +}); + +/** The tenant's overlay body — carries a field the package does NOT, and DROPS two. */ +const overlayBody = (name: string) => ({ + name, + label: 'Invoice (customized)', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + overlay_only: { name: 'overlay_only', type: 'text', label: 'Overlay only' }, + }, +}); + +const silent = () => { + const r = new SchemaRegistry({ multiTenant: false }); + r.logLevel = 'silent'; + return r; +}; + +const fieldNames = (r: SchemaRegistry, name: string) => + Object.keys((r.getObject(name) as any)?.fields ?? {}); + +const kinds = (r: SchemaRegistry, name: string) => + r.getObjectContributors(name).map((c) => c.ownership); + +/** The registry as a package boot leaves it, plus the tenant's layer. */ +function overlaidRegistry(name = 'myapp_invoice', binding: string = APP_PKG) { + const r = silent(); + r.registerObject(packagedBody(name) as any, APP_PKG); + r.registerObject({ ...overlayBody(name), _provenance: 'org' } as any, binding, undefined, 'overlay'); + return r; +} + +describe('ADR-0029 D9.2 — the overlay REPLACES the base layer, bit for bit', () => { + /** + * THE CORRECTNESS CHECK THE CARD ASKS FOR. The right-hand registry is the + * pre-D9 world expressed exactly: the overlay body registered as the sole + * `own` contributor, which is what the splice left behind. If D9 moved the + * resolved schema anywhere, this is where it shows. + */ + it('resolves to exactly what the pre-D9 splice produced', () => { + const d9 = overlaidRegistry(); + + const preD9 = silent(); + // What the splice left: one contributor, the overlay body, owning the name. + preD9.registerObject({ ...overlayBody('myapp_invoice'), _provenance: 'org' } as any, APP_PKG); + + expect(d9.getObject('myapp_invoice')).toEqual(preD9.getObject('myapp_invoice')); + }); + + it('keeps `_provenance: \'org\'` on the merged body — every registry-direct consumer reads it', () => { + expect((overlaidRegistry().getObject('myapp_invoice') as any)._provenance).toBe('org'); + }); + + it('serves the overlay body: the tenant\'s field is there and the dropped packaged ones are not', () => { + const r = overlaidRegistry(); + expect(fieldNames(r, 'myapp_invoice')).toContain('overlay_only'); + expect(fieldNames(r, 'myapp_invoice')).not.toContain('packaged_only'); + expect(fieldNames(r, 'myapp_invoice')).not.toContain('amount'); + }); + + /** The subtraction that is the whole point: nothing was destroyed. */ + it('the packaged OWNER survives underneath, with its own body and package id', () => { + const r = overlaidRegistry(); + expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'overlay']); + const owner = r.getObjectOwner('myapp_invoice')!; + expect(owner.packageId).toBe(APP_PKG); + expect(Object.keys((owner.definition as any).fields)).toContain('packaged_only'); + expect((owner.definition as any)._provenance).toBe('package'); + }); + + it('extenders still fold on top of whichever layer is the base', () => { + const r = overlaidRegistry(); + r.registerObject( + { name: 'myapp_invoice', fields: { ext_field: { name: 'ext_field', type: 'text' } } } as any, + OTHER_PKG, undefined, 'extend', + ); + expect(fieldNames(r, 'myapp_invoice')).toEqual( + expect.arrayContaining(['name', 'overlay_only', 'ext_field']), + ); + expect(fieldNames(r, 'myapp_invoice')).not.toContain('packaged_only'); + }); + + /** + * D9.2's REJECTED alternative, pinned by the behaviour that rejected it: + * `mergeObjectDefinitions` is additive and has no expression for removal, so + * an overlay modelled as an extender would silently resurrect every field + * the tenant deleted. Replace semantics is what keeps a deletion deleted. + */ + it('an overlay is NOT an extender: the fields it dropped stay dropped', () => { + const asExtender = silent(); + asExtender.registerObject(packagedBody('myapp_invoice') as any, APP_PKG); + asExtender.registerObject( + { ...overlayBody('myapp_invoice'), _provenance: 'org' } as any, APP_PKG, undefined, 'extend', + ); + // The counter-factual: as an extender the packaged fields come back. + expect(Object.keys((asExtender.getObject('myapp_invoice') as any).fields)).toContain('packaged_only'); + // As an overlay they do not. + expect(fieldNames(overlaidRegistry(), 'myapp_invoice')).not.toContain('packaged_only'); + }); +}); + +describe('ADR-0029 D9.3 — priority orders peers; the KIND selects the base', () => { + it('lists the stack in layer order: owner 100, overlay 150, extender 200', () => { + const r = overlaidRegistry(); + r.registerObject({ name: 'myapp_invoice', fields: {} } as any, OTHER_PKG, undefined, 'extend'); + expect(r.getObjectContributors('myapp_invoice').map((c) => [c.ownership, c.priority])).toEqual([ + ['own', DEFAULT_OWNER_PRIORITY], + ['overlay', DEFAULT_OVERLAY_PRIORITY], + ['extend', DEFAULT_EXTENDER_PRIORITY], + ]); + }); + + /** + * The attack D9.3 names: extender priority is AUTHOR-declared + * (`ext.priority ?? 200`), so a package could otherwise re-rank a tenant's + * overlay out of the base slot by declaring a number below 150. Selection + * asks the kind, so the number changes only the fold order. + */ + it('an extender declaring priority 140 does not become the base layer', () => { + const r = overlaidRegistry(); + r.registerObject( + { name: 'myapp_invoice', fields: { sneaky: { name: 'sneaky', type: 'text' } } } as any, + OTHER_PKG, undefined, 'extend', 140, + ); + // It sorts BEFORE the overlay… + expect(r.getObjectContributors('myapp_invoice').map((c) => c.priority)).toEqual([100, 140, 150]); + + // …and is still folded ONTO the overlay rather than being folded onto by + // it: the base is the tenant's layer, which is what the identity of the + // resolved body says. (Scalar props like `label` are "last writer wins" + // across the fold for extenders and always have been — that is the + // extension model, not base selection, so it is not the discriminator.) + const merged = r.getObject('myapp_invoice') as any; + expect(merged._provenance).toBe('org'); + expect(Object.keys(merged.fields)).toContain('overlay_only'); + expect(Object.keys(merged.fields)).not.toContain('packaged_only'); + expect(Object.keys(merged.fields)).toContain('sneaky'); + + // The control: with the layer gone, the SAME extender folds onto the + // packaged owner instead — so the assertion above is about which layer is + // the base, not about the extender being inert. + r.removeObjectOverlay('myapp_invoice'); + const restored = r.getObject('myapp_invoice') as any; + expect(restored._provenance).toBe('package'); + expect(Object.keys(restored.fields)).toContain('packaged_only'); + expect(Object.keys(restored.fields)).toContain('sneaky'); + }); +}); + +describe('ADR-0029 D9.6 — artifact identity is read from the OWNER contributor', () => { + /** + * The clause that makes `isArtifactBacked` stop lying, and the one D9 says + * is NOT implied by the layering change: D9.2 deliberately leaves the merged + * body carrying `_provenance: 'org'`, so a predicate that reads the merged + * body answers about the tenant even though the package still ships the name. + */ + it('a packaged object with a tenant overlay IS artifact-backed', () => { + const r = overlaidRegistry(); + const item = r.getArtifactItem('object', 'myapp_invoice'); + expect(item).toBeDefined(); + // …and what comes back is the CODE-layer baseline, not the tenant body. + expect(Object.keys(item.fields)).toContain('packaged_only'); + expect(item._provenance).toBe('package'); + expect(item._packageId).toBe(APP_PKG); + }); + + /** + * The other direction, and the reason cloud#970 stays closed: an app the + * user just built through Studio/AI has no package layer at all — the owner + * IS the tenant's row — so it must stay editable. + */ + it('a runtime-authored object with no package layer is NOT artifact-backed', () => { + const r = silent(); + r.registerObject({ ...overlayBody('eymm_project'), _provenance: 'org' } as any, 'app.eymm'); + expect(r.getArtifactItem('object', 'eymm_project')).toBeUndefined(); + }); + + it('the `sys_metadata` sentinel is still not an artifact', () => { + const r = silent(); + r.registerObject(overlayBody('runtime_thing') as any, SENTINEL); + expect(r.getArtifactItem('object', 'runtime_thing')).toBeUndefined(); + }); + + /** + * Guard, green in both directions: with no overlay registered the code-layer + * resolution is byte-for-byte the merged object, so the honest predicate + * costs nothing on the overwhelmingly common shape — including extenders. + */ + it('with no overlay, the artifact item still carries the extenders (unchanged)', () => { + const r = silent(); + r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG); + r.registerObject( + { name: 'myapp_invoice', fields: { ext_field: { name: 'ext_field', type: 'text' } } } as any, + OTHER_PKG, undefined, 'extend', + ); + expect(r.getArtifactItem('object', 'myapp_invoice')).toEqual(r.getObject('myapp_invoice')); + }); +}); + +describe('ADR-0029 D9 blast radius — the `own`-gated sites, one by one', () => { + /** + * Blast-radius row 2, and the silent regression D9 warns about by name: the + * overlay body IS the resolved base, so gating title provisioning on `own` + * would leave `nameField` unset on every overlaid object. + */ + it('the overlay layer is title-provisioned, so `nameField` does not move', () => { + const packagedOnly = silent(); + packagedOnly.registerObject(packagedBody('myapp_invoice') as any, APP_PKG); + const expected = (packagedOnly.getObject('myapp_invoice') as any).nameField; + expect(expected).toBeTruthy(); + + expect((overlaidRegistry().getObject('myapp_invoice') as any).nameField).toBe(expected); + }); + + it('`getObjectOwner` never answers with the overlay — it still means "owns the table"', () => { + expect(overlaidRegistry('myapp_invoice', SENTINEL).getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG); + }); + + it('`getAllObjects` stamps the PACKAGED owner\'s id, and both bindings still match the filter', () => { + const r = overlaidRegistry('myapp_invoice', SENTINEL); + expect((r.getAllObjects().find((o) => o.name === 'myapp_invoice') as any)._packageId).toBe(APP_PKG); + // `getAllObjects(packageId)` matches ANY contribution, owner or not. + expect(r.getAllObjects(APP_PKG).map((o) => o.name)).toContain('myapp_invoice'); + expect(r.getAllObjects(SENTINEL).map((o) => o.name)).toContain('myapp_invoice'); + }); +}); + +describe('ADR-0029 D9.5 — the single-owner assertion, unchanged, plus one class', () => { + /** + * Guard, green in both directions. It is here because ADR-0028's D5/D6 rest + * on D3 being unconditional: an overlay must never be counted as an owner, + * so the sentence keeps holding with no exemption list. + */ + it('an overlaid object still has exactly ONE owner', () => { + expect(() => overlaidRegistry().assertSingleOwnerPerObject()).not.toThrow(); + }); + + it('a second cross-package OWNER is still refused at registration', () => { + const r = overlaidRegistry(); + expect(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG)) + .toThrow(/already owned by package "app\.myapp"/); + }); + + /** + * The one class D9 adds. Reachable by uninstalling the packaged owner while a + * SENTINEL-bound row for its object still exists — the seam + * `unregisterObjectsByPackage` cannot reach by package id. + */ + it('an ORPHAN overlay (overlay, no owner) is a violation, and says which it is', () => { + const r = overlaidRegistry('myapp_invoice', SENTINEL); + // Reach the state directly: drop the owner without the package walk. + r.unregisterObjectsByPackage(APP_PKG, true); + r.registerObject({ ...overlayBody('myapp_invoice'), _provenance: 'org' } as any, SENTINEL, undefined, 'overlay'); + + expect(kinds(r, 'myapp_invoice')).toEqual(['overlay']); + expect(() => r.assertSingleOwnerPerObject()).toThrow(/orphan overlay layer/); + // …and it is not mis-described as an extender problem. + expect(() => r.assertSingleOwnerPerObject()).not.toThrow(/only extend contributions/); + }); + + it('an orphan overlay resolves to nothing, and the warning names the layer', () => { + const r = silent(); + r.registerObject({ ...overlayBody('ghost'), _provenance: 'org' } as any, SENTINEL, undefined, 'overlay'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect(r.getObject('ghost')).toBeUndefined(); + expect(warn.mock.calls.map((c) => String(c[0])).join('\n')).toMatch(/overlay layer but no owner/); + } finally { + warn.mockRestore(); + } + }); + + /** Guard: the pre-existing owner-less shape keeps its own wording. */ + it('extenders with no owner still report the extend violation', () => { + const r = silent(); + r.registerObject({ name: 'nobody', fields: {} } as any, OTHER_PKG, undefined, 'extend'); + expect(() => r.assertSingleOwnerPerObject()).toThrow(/only extend contributions/); + }); +}); + +describe('ADR-0029 D9.9 — at most ONE overlay layer per object name', () => { + /** + * Keyed by KIND, never by package. The overlay-uniqueness index in + * `sys_metadata` keys on `(type, name, organization_id, COALESCE(package_id, + * ''))` and can legitimately hold two rows for one `(type, name)` bound to + * two packages; `computeFQN` is identity, so the registry holds exactly one + * entry per object name and could never serve two. Replacing by package + * would let that unrepresentable shape in through the side door. + */ + it('re-writing the layer REPLACES it, even under a different binding', () => { + const r = overlaidRegistry('myapp_invoice', SENTINEL); + r.registerObject( + { name: 'myapp_invoice', label: 'Second', fields: { second_only: { name: 'second_only', type: 'text' } }, _provenance: 'org' } as any, + APP_PKG, undefined, 'overlay', + ); + expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'overlay']); + expect(fieldNames(r, 'myapp_invoice')).toContain('second_only'); + expect(fieldNames(r, 'myapp_invoice')).not.toContain('overlay_only'); + }); +}); + +describe('ADR-0029 D9.7 — removal is a subtraction', () => { + it('`removeObjectOverlay` drops the layer and the packaged owner is served again', () => { + const r = overlaidRegistry(); + expect(r.removeObjectOverlay('myapp_invoice')).toBe(true); + + expect(kinds(r, 'myapp_invoice')).toEqual(['own']); + const merged = r.getObject('myapp_invoice') as any; + expect(Object.keys(merged.fields)).toContain('packaged_only'); + expect(merged._provenance).toBe('package'); + expect(merged.label).toBe('Invoice'); + // The warm merge cache cannot outlive the removal. + expect(r.getArtifactItem('object', 'myapp_invoice')).toBeDefined(); + }); + + it('is idempotent, and answers `false` when there is no layer to remove', () => { + const r = overlaidRegistry(); + expect(r.removeObjectOverlay('myapp_invoice')).toBe(true); + expect(r.removeObjectOverlay('myapp_invoice')).toBe(false); + expect(r.removeObjectOverlay('never_registered')).toBe(false); + // …and the object is still there: it removes a LAYER, not the object. + expect(r.getObject('myapp_invoice')).toBeDefined(); + }); + + it('bumps objectRevision so registry-derived caches learn the set moved', () => { + const r = overlaidRegistry(); + const before = r.objectRevision; + r.removeObjectOverlay('myapp_invoice'); + expect(r.objectRevision).toBeGreaterThan(before); + }); + + it('uninstalling the owning package takes the overlay layer with it', () => { + const r = overlaidRegistry('myapp_invoice', SENTINEL); + r.unregisterObjectsByPackage(APP_PKG); + // No orphan is manufactured: the whole entry is gone, not a layer with no base. + expect(r.getObjectContributors('myapp_invoice')).toHaveLength(0); + expect(() => r.assertSingleOwnerPerObject()).not.toThrow(); + }); + + /** Guard: the name-addressed removal keeps taking the whole entry (#6808). */ + it('`unregisterObject` still removes the object and every layer of it', () => { + const r = overlaidRegistry(); + expect(r.unregisterObject('myapp_invoice')).toBe(true); + expect(r.getObject('myapp_invoice')).toBeUndefined(); + expect(r.getObjectContributors('myapp_invoice')).toHaveLength(0); + }); +}); + +describe('ADR-0029 D9 §6.1 — LATE INSTALL: the code layer takes ownership', () => { + /** + * A package registering an object a tenant row already holds. Before D9 the + * row had taken the `own` slot by default, so this threw "already owned by" + * under a foreign id — the case D9 left open for this card. + * + * The recommended default, and the only outcome that loses nothing: the code + * layer becomes the owner and the tenant contribution is re-classified as its + * overlay layer. The resolved object does not move; what changes is that the + * packaged definition now survives underneath it. + */ + it('a package arriving after a SENTINEL-bound tenant row is not refused', () => { + const r = silent(); + r.registerObject({ ...overlayBody('myapp_invoice'), _provenance: 'org' } as any, SENTINEL); + const before = r.getObject('myapp_invoice'); + + expect(() => r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG)).not.toThrow(); + + expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'overlay']); + expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG); + // The resolved object did not move… + expect(r.getObject('myapp_invoice')).toEqual(before); + // …and the predicate is honest from this moment on. + expect(r.getArtifactItem('object', 'myapp_invoice')).toBeDefined(); + expect(() => r.assertSingleOwnerPerObject()).not.toThrow(); + }); + + /** + * The SAME-package spelling, which pre-D9 did not throw — it took the + * re-registration splice and destroyed the tenant's customization instead. + */ + it('a package arriving after a row bound to its OWN id does not destroy the customization', () => { + const r = silent(); + r.registerObject({ ...overlayBody('myapp_invoice'), _provenance: 'org' } as any, APP_PKG); + + r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG); + + expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'overlay']); + expect(fieldNames(r, 'myapp_invoice')).toContain('overlay_only'); + expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG); + expect((r.getObjectOwner('myapp_invoice')!.definition as any)._provenance).toBe('package'); + }); + + /** + * THE BOUNDARY, and why the re-classification is not a hole in D3: it fires + * only when the sitting owner is TENANT-authored. Two code packages claiming + * one name is still the cross-package refusal, whatever the ids. + */ + it('does NOT re-classify a packaged owner — a second code package is still refused', () => { + const r = silent(); + r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG); + expect(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG)) + .toThrow(/already owned by package "app\.myapp"/); + expect(kinds(r, 'myapp_invoice')).toEqual(['own']); + }); + + /** Guard: an ordinary tenant re-write is still a re-registration, not a layer over itself. */ + it('a tenant re-writing its OWN runtime object stays a single `own` contributor', () => { + const r = silent(); + r.registerObject({ ...overlayBody('eymm_project'), _provenance: 'org' } as any, 'app.eymm'); + r.registerObject({ ...overlayBody('eymm_project'), label: 'v2', _provenance: 'org' } as any, 'app.eymm'); + expect(kinds(r, 'eymm_project')).toEqual(['own']); + expect((r.getObject('eymm_project') as any).label).toBe('v2'); + }); +}); + +describe('ADR-0029 D9.8 — `getPackagedObjectOwner`, the hydration discriminator', () => { + it('answers the packaged owner for a code-shipped name', () => { + expect(overlaidRegistry().getPackagedObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG); + }); + + it('answers undefined for a runtime-authored object — its owner IS the tenant row', () => { + const r = silent(); + r.registerObject({ ...overlayBody('eymm_project'), _provenance: 'org' } as any, 'app.eymm'); + expect(r.getPackagedObjectOwner('eymm_project')).toBeUndefined(); + }); + + it('answers undefined for a name nothing has registered', () => { + expect(silent().getPackagedObjectOwner('nothing_here')).toBeUndefined(); + }); +}); + +describe('ADR-0029 D9.4 — `computeFQN` is untouched, so the namespace loss is repaired for free', () => { + /** + * P5 measured the packaged owner's `namespace` field coming back as `''` + * where the package had `'myapp'` — destroyed with the contributor. D9 fixes + * it by SUBTRACTION rather than by a rule: the owner is no longer removed. + */ + it('the packaged owner keeps its namespace when a tenant layer arrives', () => { + const r = silent(); + r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG, 'myapp'); + r.registerObject( + { ...overlayBody('myapp_invoice'), _provenance: 'org' } as any, APP_PKG, undefined, 'overlay', + ); + expect(r.getObjectOwner('myapp_invoice')?.namespace).toBe('myapp'); + expect(r.getNamespaceOwner('myapp')).toBe(APP_PKG); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 2126812e1e..92355d7ecb 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -26,14 +26,40 @@ import { applyProtection } from '@objectstack/spec/shared'; export const RESERVED_NAMESPACES = new Set(['base', 'system']); /** - * Default priorities for ownership types. + * Default priorities for the three contributor kinds. + * + * [ADR-0029 D9.3] `DEFAULT_OVERLAY_PRIORITY` sits BETWEEN the owner and the + * extenders so a `getObjectContributors()` read lists the stack in layer + * order. It is ORDERING ONLY: {@link SchemaRegistry.resolveObject} selects its + * base layer by KIND, never by "highest priority wins" — extender priority is + * author-declared (`ext.priority ?? 200` in the `objectExtensions` loop), so a + * package could otherwise re-rank a tenant's overlay out of the base slot by + * declaring `priority: 140`. Declared numbers order PEERS; they must not be + * able to change WHICH LAYER IS THE BASE. */ export const DEFAULT_OWNER_PRIORITY = 100; +export const DEFAULT_OVERLAY_PRIORITY = 150; export const DEFAULT_EXTENDER_PRIORITY = 200; /** * Contributor Record - * Tracks how a package contributes to an object (own or extend). + * Tracks how a package contributes to an object. + * + * Three kinds (ADR-0029 D9): + * + * - `own` — the single owning package. Defines the base schema, the table and + * the namespace claim. Exactly one per object name, unconditionally + * ({@link SchemaRegistry.assertSingleOwnerPerObject}). + * - `overlay` — [D9.1] a TENANT customization layer over the owner's + * definition, hydrated from a `sys_metadata` row. It REPLACES the base layer + * at resolution time (D9.2) and owns nothing: it claims no namespace, + * decides no package membership, holds no table. That is why the + * single-owner assertion needs no "unless it is an overlay" clause — which + * matters beyond tidiness, since ADR-0028 D5/D6 rest on that sentence being + * unconditional. `ownership: 'overlay'` is LOADER-SET at the hydration seams + * and reachable from no authoring surface. + * - `extend` — additive contributions from other packages, folded on top of + * whichever layer is the base. */ export interface ObjectContributor { packageId: string; @@ -845,6 +871,22 @@ function isTenantAuthored(item: unknown): boolean { return (item as { _provenance?: unknown } | null | undefined)?._provenance === 'org'; } +/** + * [ADR-0029 D9.6] Is this registered body a CODE-shipped artifact? + * + * The exact test {@link SchemaRegistry.getArtifactItem} has always applied, + * factored out so the object branch and the D9.8 hydration discriminator + * ({@link SchemaRegistry.getPackagedObjectOwner}) cannot drift into two + * different answers to one question — "does a code package ship this name?". + * Truthy `_packageId`, not the `'sys_metadata'` rehydration sentinel, and not + * tenant provenance. + */ +function isCodeArtifactBody(item: unknown): boolean { + const it = item as { _packageId?: unknown } | null | undefined; + if (!it || !it._packageId || it._packageId === 'sys_metadata') return false; + return !isTenantAuthored(it); +} + export class SchemaRegistry { // ========================================== // Logging control @@ -1060,19 +1102,26 @@ export class SchemaRegistry { * Register an object with ownership semantics. * * @param schema - The object definition - * @param packageId - The owning package ID + * @param packageId - The owning package ID, or — for an `overlay` — the + * `sys_metadata` row's own binding (provenance ON the layer, never an + * ownership claim; ADR-0029 D9.9) * @param namespace - The package namespace (for FQN computation) - * @param ownership - 'own' (single owner) or 'extend' (additive merge) + * @param ownership - 'own' (single owner) | 'overlay' (tenant layer that + * REPLACES the base at resolution; ADR-0029 D9) | 'extend' (additive merge) * @param priority - Merge priority (lower applied first, higher wins on conflict) * - * @throws Error if trying to 'own' an object that already has an owner + * @throws Error if trying to 'own' an object that already has a PACKAGED owner */ registerObject( schema: ServiceObject, packageId: string, namespace?: string, ownership: ObjectOwnership = 'own', - priority: number = ownership === 'own' ? DEFAULT_OWNER_PRIORITY : DEFAULT_EXTENDER_PRIORITY + priority: number = ownership === 'own' + ? DEFAULT_OWNER_PRIORITY + : ownership === 'overlay' + ? DEFAULT_OVERLAY_PRIORITY + : DEFAULT_EXTENDER_PRIORITY ): string { // Apply system-field injection (multi-tenant org_id, future owner/audit) // BEFORE FQN computation and contributor storage so every consumer of @@ -1113,7 +1162,15 @@ export class SchemaRegistry { // system/append-only tables (sys_record_share, sys_member, …) via the // driver's `syncSchema`, a schema-migration-bearing change. Those keep // resolving their title on read via `resolveDisplayField` / `titleFormat`. - if (ownership === 'own') { + // + // [ADR-0029 D9, blast-radius row 2] The gate asks "is this a BASE LAYER", + // not "is this the owner". An `overlay` REPLACES the base at resolution + // time (D9.2), so the overlay body IS the resolved object — gating title + // provisioning on `own` alone would silently change `nameField` on every + // overlaid object, which is the same designation the packaged twin gets. + // Extensions still never redesignate: they merge into a base that has + // already been provisioned. + if (ownership === 'own' || ownership === 'overlay') { schema = provisionPrimary(schema, { synthesize: false }); // [#2486] Search-normalization companion column (`__search`). Runs @@ -1122,8 +1179,8 @@ export class SchemaRegistry { // column is a real additive migration (ADR-0045) that the driver's // `syncSchema` materializes, populated by plugin-pinyin-search's // before-save hooks and OR-ed into `$search` by the engine's - // `expandSearchToFilter`. Owned objects only: extensions merge into - // the owner's already-provisioned shape. + // `expandSearchToFilter`. BASE layers only: extensions merge into + // the base's already-provisioned shape. if (this.searchCompanion) { schema = provisionSearchCompanion(schema); } @@ -1147,21 +1204,68 @@ export class SchemaRegistry { // Validate ownership rules if (ownership === 'own') { const existingOwner = contributors.find(c => c.ownership === 'own'); - if (existingOwner && existingOwner.packageId !== packageId) { + if (existingOwner && isTenantAuthored(existingOwner.definition) && !isTenantAuthored(schema)) { + // ── LATE INSTALL (ADR-0029 D9 §6.1) ── + // + // A code package registers an object a TENANT row already holds. That + // happens whenever the hydration seams run before the package does — + // a `sys_metadata` row written on a kernel where nothing shipped the + // name, then a package that later ships it (install, upgrade, or a + // boot whose order puts `loadMetaFromDb` first). Before D9 the tenant + // row had taken the `own` slot by default, so this threw "already + // owned by" under a foreign id, or SPLICED THE TENANT'S BODY AWAY + // under the same id. + // + // D9's recommended default, and the only outcome that loses nothing: + // the CODE layer becomes the owner and the tenant contribution is + // re-classified as its overlay layer — exactly the two layers that + // would exist had the package registered first. The resolved object + // does not move (the tenant body is the base either way, D9.2); what + // changes is that the packaged definition now survives underneath it, + // so `isArtifactBacked` is honest from this moment on. + // + // Deliberately narrow: it fires ONLY when the sitting owner is + // tenant-authored (`_provenance: 'org'`) and the incoming body is not. + // Two code packages claiming one name is D3's cross-package refusal + // and still throws below, whatever the ids. + const staleOverlay = contributors.findIndex(c => c.ownership === 'overlay'); + if (staleOverlay !== -1) contributors.splice(staleOverlay, 1); + existingOwner.ownership = 'overlay'; + existingOwner.priority = DEFAULT_OVERLAY_PRIORITY; + this.log( + `[Registry] Late install of "${fqn}": package "${packageId}" takes ownership; ` + + `the tenant contribution (${existingOwner.packageId}) becomes its overlay layer.` + ); + } else if (existingOwner && existingOwner.packageId !== packageId) { throw new Error( `Object "${fqn}" is already owned by package "${existingOwner.packageId}". ` + `Package "${packageId}" cannot claim ownership. Use 'extend' to add fields.` ); - } - // Remove existing owner contribution from same package (re-registration) - const idx = contributors.findIndex(c => c.packageId === packageId && c.ownership === 'own'); - if (idx !== -1) { - contributors.splice(idx, 1); + } else if (existingOwner) { + // Remove existing owner contribution from same package (re-registration). // Normal path (metadata rebuild / HMR / multi-project seed replays the // same owned object), not an error — keep it at debug so a stock boot // stays warning-free (#3420). + contributors.splice(contributors.indexOf(existingOwner), 1); this.debug(`[Registry] Re-registering owned object: ${fqn} from ${packageId}`); } + } else if (ownership === 'overlay') { + // [ADR-0029 D9.9] AT MOST ONE overlay layer per object name, replaced on + // re-write — and keyed by KIND, never by package. The row's `package_id` + // is provenance ON the layer, not a second identity for it: the + // overlay-uniqueness index in `sys_metadata` keys on + // `(type, name, organization_id, COALESCE(package_id, ''))` and can + // legitimately hold two rows for one `(type, name)` bound to two + // packages, which this registry could never represent because + // `computeFQN` is identity. Keying the replacement by package would let + // that unrepresentable shape in through the side door; the mis-bound row + // is refused at the PRODUCER instead (`saveMetaItem`), and counted in + // `loadMetaFromDb`'s per-record `errors` at boot. + const idx = contributors.findIndex(c => c.ownership === 'overlay'); + if (idx !== -1) { + contributors.splice(idx, 1); + this.debug(`[Registry] Replacing overlay layer of object: ${fqn} from ${packageId}`); + } } else { // extend mode: remove existing extension from same package const idx = contributors.findIndex(c => c.packageId === packageId && c.ownership === 'extend'); @@ -1216,25 +1320,78 @@ export class SchemaRegistry { // Find owner (must exist for a valid object) const ownerContrib = contributors.find(c => c.ownership === 'own'); if (!ownerContrib) { - console.warn(`[Registry] Object "${fqn}" has extenders but no owner. Skipping.`); + // [ADR-0029 D9.5] An ORPHAN OVERLAY lands here too, and says so: the + // tenant can still see the row in `sys_metadata`, so "extenders" would + // be the wrong noun to hand whoever reads this line. + console.warn( + contributors.some(c => c.ownership === 'overlay') + ? `[Registry] Object "${fqn}" has an overlay layer but no owner. Skipping.` + : `[Registry] Object "${fqn}" has extenders but no owner. Skipping.` + ); return undefined; } - // Start with owner's definition - let merged = { ...ownerContrib.definition }; + // [ADR-0029 D9.2] The BASE layer is the tenant overlay when one is + // registered, and the owner otherwise — `overlay ?? own`. Selection asks + // the KIND (D9.3), never the priority. + // + // This is deliberately BIT-FOR-BIT what the pre-D9 splice produced: the + // overlay used to BE the owner, so the fold already ran over the overlay + // body. The resolved schema — `_provenance: 'org'` included, which every + // registry-direct consumer reads — does not move. Only what the registry + // REMEMBERS changes: the packaged owner is still here, underneath. + const baseContrib = contributors.find(c => c.ownership === 'overlay') ?? ownerContrib; + const merged = this.foldExtenders(contributors, baseContrib); - // Apply extensions in priority order (already sorted) + // Cache the result + this.mergedObjectCache.set(fqn, merged); + return merged; + } + + /** + * Fold every `extend` contribution over a chosen base layer, in the list's + * (priority-sorted) order. Extracted from {@link resolveObject} so the + * code-layer resolution ({@link resolveOwnerLayer}) folds extenders exactly + * the same way rather than growing a second, drifting copy. + */ + private foldExtenders(contributors: ObjectContributor[], base: ObjectContributor): ServiceObject { + let merged = { ...base.definition }; for (const contrib of contributors) { if (contrib.ownership === 'extend') { merged = mergeObjectDefinitions(merged, contrib.definition); } } - - // Cache the result - this.mergedObjectCache.set(fqn, merged); return merged; } + /** + * [ADR-0029 D9.6] The CODE-LAYER resolution of an object: the OWNER's + * declaration with its extenders folded on, deliberately ignoring any tenant + * `overlay` layer. + * + * This is what artifact identity has to be read from, and it is NOT implied + * by D9.2 — D9.2 leaves the merged body identical on purpose, `_provenance: + * 'org'` included, so asking the MERGED body "does a code package ship + * this?" answers about whatever last overwrote the base rather than about + * the code layer. That is precisely how `isArtifactBacked` came to answer + * `false` for a name a package still ships (#6853 P3), disarming both gates + * that read it. + * + * With no overlay registered this is byte-for-byte `resolveObject`'s answer, + * which is why the honest predicate costs nothing on the common shape. + * + * Uncached on purpose: a second cache would have to stay correct at every + * one of `mergedObjectCache`'s invalidation points, and the fold is a + * shallow spread over a contributor list that is almost always length 1. + */ + private resolveOwnerLayer(fqn: string): ServiceObject | undefined { + const contributors = this.objectContributors.get(fqn); + if (!contributors || contributors.length === 0) return undefined; + const ownerContrib = contributors.find(c => c.ownership === 'own'); + if (!ownerContrib) return undefined; + return this.foldExtenders(contributors, ownerContrib); + } + /** * Get object by name (short name canonical, FQN supported for disambiguation). * @@ -1328,12 +1485,71 @@ export class SchemaRegistry { /** * Get the owner contributor for an object. + * + * Unchanged by ADR-0029 D9: it keeps meaning "the package that owns the + * table". An overlay is not an owner, so it is never returned here. */ getObjectOwner(fqn: string): ObjectContributor | undefined { const contributors = this.objectContributors.get(fqn); return contributors?.find(c => c.ownership === 'own'); } + /** + * [ADR-0029 D9.8] The PACKAGED (code-shipped) owner of an object name, if + * one is registered — the discriminator every overlay-hydration seam asks + * before deciding which KIND to register: + * + * ``` + * packaged `own` contributor already registered for this name? + * yes -> register as `overlay` (a layer over the code definition) + * no -> register as `own` (a runtime-authored object, keyed by the + * row's package_id or the sentinel) + * ``` + * + * "Packaged" is the artifact test, not merely "an owner exists": a + * runtime-authored object's owner IS the tenant's own row, and re-writing it + * must stay an ordinary re-registration rather than becoming a layer over + * itself. Name-addressed, resolving the key exactly as `getObject` does, so + * the decision and the registration cannot talk about different entries. + */ + getPackagedObjectOwner(name: string): ObjectContributor | undefined { + const fqn = this.resolveObjectKey(name); + if (fqn === undefined) return undefined; + const owner = this.objectContributors.get(fqn)?.find(c => c.ownership === 'own'); + return owner && isCodeArtifactBody(owner.definition) ? owner : undefined; + } + + /** + * [ADR-0029 D9.7] Remove the tenant OVERLAY layer of an object, leaving the + * packaged owner exactly where it is — the layer-addressed sibling of + * #6818's name-addressed {@link unregisterObject}. + * + * This is why #6853's "how do we restore the packaged definition?" question + * dissolves instead of being answered. The packaged owner is already here, + * at its own priority, in its own namespace, with its own definition, so + * restoration is NOT a re-registration at all — the heal that used to need + * `(definition, packageId, namespace, ownership, priority)` at delete time, + * three of which no longer existed by then, has nothing left to reconstruct. + * + * @returns whether an overlay layer was removed (`false` = nothing was + * registered under that name, or the entry had no overlay layer; removal + * is idempotent). + */ + removeObjectOverlay(name: string): boolean { + const fqn = this.resolveObjectKey(name); + if (fqn === undefined) return false; + const contributors = this.objectContributors.get(fqn); + if (!contributors) return false; + const idx = contributors.findIndex(c => c.ownership === 'overlay'); + if (idx === -1) return false; + const [removed] = contributors.splice(idx, 1); + // The same two invalidations every other contributor mutation performs. + this.mergedObjectCache.delete(fqn); + this._objectRevision += 1; + this.log(`[Registry] Removed overlay layer of object: ${fqn} (was bound to ${removed.packageId})`); + return true; + } + /** * ADR-0029 K0 — assert every registered object resolves to exactly one * owner. @@ -1350,6 +1566,13 @@ export class SchemaRegistry { * the `sys` namespace is shared across many first-party plugins, but each * object name has exactly one owner. * + * [ADR-0029 D9.5] The count is LITERALLY unchanged by the overlay layer — + * `ownership === 'own'`, no exemption clause. Overlays are not owners, so + * D3's sentence keeps holding as written, which is what ADR-0028's D5/D6 + * depend on: an ownership rule qualified with "unless it is an overlay" + * would have to be re-litigated at every call site. What D9 adds is one new + * VIOLATION class, the orphan overlay (an `overlay` with no `own`). + * * @throws Error listing every object whose owner count is not exactly 1. */ assertSingleOwnerPerObject(): void { @@ -1357,11 +1580,27 @@ export class SchemaRegistry { for (const [fqn, contributors] of this.objectContributors.entries()) { const owners = contributors.filter(c => c.ownership === 'own'); if (owners.length === 0) { - const extenders = contributors.map(c => c.packageId).join(', ') || '(none)'; - violations.push( - `Object "${fqn}" has no owner — only extend contributions from [${extenders}]. ` + - `Exactly one package must register it with ownership 'own'.` - ); + // [ADR-0029 D9.5] The ORPHAN OVERLAY class. Reachable by uninstalling + // the packaged owner while a `sys_metadata` row for its object still + // exists under a binding `unregisterObjectsByPackage` cannot reach by + // package id (the sentinel). It must be LOUD rather than silent: + // `resolveObject` would otherwise warn once and answer `undefined` for + // a name the tenant can still see in `sys_metadata`. + const overlays = contributors.filter(c => c.ownership === 'overlay'); + if (overlays.length > 0) { + const bindings = overlays.map(c => c.packageId).join(', ') || '(none)'; + violations.push( + `Object "${fqn}" has an orphan overlay layer [${bindings}] and no owner. ` + + `An overlay layers OVER an owned object — re-install the package that owns it, ` + + `or delete the sys_metadata row.` + ); + } else { + const extenders = contributors.map(c => c.packageId).join(', ') || '(none)'; + violations.push( + `Object "${fqn}" has no owner — only extend contributions from [${extenders}]. ` + + `Exactly one package must register it with ownership 'own'.` + ); + } } else if (owners.length > 1) { const names = owners.map(c => c.packageId).join(', '); violations.push( @@ -1407,6 +1646,25 @@ export class SchemaRegistry { contributors.splice(idx, 1); this.log(`[Registry] Removed ${contrib.ownership} contribution to ${fqn} from ${packageId}`); } + + // [ADR-0029 D9.7] An overlay layer leaves with the base it layers + // over. Nothing durable is lost — the layer is a runtime projection of + // a `sys_metadata` row this does not touch, and a re-install + // re-hydrates it — whereas leaving it behind would MANUFACTURE the + // orphan-overlay violation D9.5 names, on every uninstall of a + // customized packaged object. Keyed off the OWNER's removal, not off + // the overlay's own binding: a sentinel-bound layer names no package + // and this walk is addressed by package id. + if (contrib.ownership === 'own') { + const overlayIdx = contributors.findIndex(c => c.ownership === 'overlay'); + if (overlayIdx !== -1) { + const [dropped] = contributors.splice(overlayIdx, 1); + this.log( + `[Registry] Removed overlay layer of ${fqn} with its owner ` + + `(layer was bound to ${dropped.packageId})` + ); + } + } } // Clean up empty contributor lists @@ -1726,10 +1984,23 @@ export class SchemaRegistry { */ getArtifactItem(type: string, name: string, currentPackageId?: string): T | undefined { if (type === 'object' || type === 'objects') { - const obj = this.getObject(name) as any; - return obj && obj._packageId && obj._packageId !== 'sys_metadata' && !isTenantAuthored(obj) - ? (obj as T) - : undefined; + // [ADR-0029 D9.6] Artifact identity is read from the OWNER contributor's + // layer, NEVER from the merged body. The merged body deliberately keeps + // the overlay's `_provenance: 'org'` (D9.2 — the resolved schema must + // not move), so applying the artifact test to it answers about whichever + // layer is on top rather than about the code layer, which is exactly how + // this predicate came to answer `false` for a name a code package still + // ships (#6853 P3) and silently disarmed `saveMetaItem`'s overlay gate + // and tier 3 of the delete heal. + // + // What comes BACK is the code-layer body (owner + its extenders), which + // is what every consumer of this lookup wants — the packaged baseline an + // overlay customizes, and the `_lock`/`_packageId`/`_provenance` + // envelope that must win over an overlay (ADR-0010 §3.3). With no + // overlay registered it is byte-for-byte the previous answer. + const fqn = this.resolveObjectKey(name); + const ownerLayer = fqn === undefined ? undefined : this.resolveOwnerLayer(fqn); + return ownerLayer && isCodeArtifactBody(ownerLayer) ? (ownerLayer as T) : undefined; } const collection = this.metadata.get(type); if (!collection) return undefined; diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 786db8033c..0bcdff0ae5 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -267,6 +267,7 @@ export const ERROR_CODE_LEDGER = { 'NOT_ATTEMPTED', // atomic data-batch row never ran — an earlier row's failure aborted the batch (#4793) 'NOT_CREATABLE', 'NOT_OVERRIDABLE', + 'OBJECT_OVERLAY_PACKAGE_MISMATCH', // [ADR-0029 D9.9] object overlay row bound to a package that does not own the object 'ROLLED_BACK', // atomic data-batch row was written, then undone by the batch rollback (#4793) 'UNSUPPORTED_QUERY_PARAM', 'VALIDATION_FAILED', diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 2c4934c68c..1f3247008a 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1386,9 +1386,10 @@ const ObjectSchemaBase = strictObject( * set, or the `sys_` namespace — skip owner injection regardless.) * * NOTE: this is the RECORD-ownership model, DISTINCT from the package - * *contribution* kind (`own` | `extend`, {@link ObjectOwnershipEnum}) that lives - * on the registry's contributor record and is set via `registerObject` — do not - * conflate the two despite the shared word. + * *contribution* kind (`own` | `extend` | `overlay`, + * {@link ObjectOwnershipEnum}) that lives on the registry's contributor + * record and is set via `registerObject` — do not conflate the two despite + * the shared word. */ ownership: z.enum(['user', 'org', 'none'], { error: @@ -2419,20 +2420,42 @@ function normalizeRowCrudOverride( // ================================================================= /** - * How a package relates to an object it references. + * How a contribution relates to the object it targets — the registry's + * CONTRIBUTOR-kind vocabulary. * * - `own`: This package is the original author/owner of the object. * Only one package may own a given object name. The owner defines * the base schema (table name, primary key, core fields). * + * - `overlay`: [ADR-0029 D9.1] A TENANT customization layer over the owner's + * definition, hydrated from a `sys_metadata` row. It REPLACES the base layer + * at resolution time (`base = overlay ?? own`) while owning nothing — no + * namespace claim, no package membership, no table — so the single-owner + * invariant keeps counting exactly one `own` per object name with no + * exemption clause. + * * - `extend`: This package adds fields, views, or actions to an * existing object owned by another package. Multiple packages * may extend the same object. Extensions are merged at boot time. * + * ## LOADER-FACING, NEVER AUTHOR-FACING + * + * No author ever writes one of these values. A package author declares + * `objectExtensions: [{ extend: '…' }]` and the LOADER picks `extend`; the + * package loader picks `own`; ADR-0029 D9.1 binds `overlay` to the two + * `sys_metadata` hydration seams and to nothing else. This enum is the shared + * vocabulary those loaders name their choice with — not an authoring surface — + * and D9.1 binds it to stay that way, because a new kind that no hand-written + * or AI-written metadata can reach for adds no way to get metadata wrong. + * + * NOTE: this is the package-CONTRIBUTION kind, DISTINCT from the RECORD + * -ownership model (`user` | `org` | `none`) that lives on the object schema's + * own `ownership` property — do not conflate the two despite the shared word. + * * Follows Salesforce/ServiceNow patterns: * object name = database table name, globally unique, no namespace prefix. */ -export const ObjectOwnershipEnum = z.enum(['own', 'extend']); +export const ObjectOwnershipEnum = z.enum(['own', 'extend', 'overlay']); export type ObjectOwnership = z.input; /** diff --git a/scripts/adr-anchors.json b/scripts/adr-anchors.json index 2185508cd3..bfa26c3cbc 100644 --- a/scripts/adr-anchors.json +++ b/scripts/adr-anchors.json @@ -152,9 +152,10 @@ { "file": "packages/metadata-protocol/src/protocol.ts", "adrs": [ + "ADR-0029", "ADR-0119" ], - "invariant": "`batchData`'s `atomic` is REAL or REFUSED, never silent best-effort. An explicitly atomic batch runs inside ONE `engine.transaction()`, so a failure rolls back every prior write and the response reports zero successes (rows marked ROLLED_BACK / NOT_ATTEMPTED — reporting a rolled-back row as `success: true` was the original bug, not merely the missing transaction). A runtime that cannot roll back gets 501 NOT_IMPLEMENTED; degrading to best-effort is how the flag came to lie." + "invariant": "ADR-0029 D9.8/D9.9 — the two `sys_metadata` object-hydration seams (`applyObjectRegistryMutation` and `loadMetaFromDb`) CHOOSE the contributor kind instead of defaulting to `'own'`: a packaged owner already registered for the name means the row is that object's `overlay` LAYER, and no packaged owner means the row IS the object. Registering `'own'` unconditionally is what spliced the packaged contributor out and destroyed the code definition at write time, replayed silently on every boot (#6853). The row's `package_id` is provenance ON the layer and never an ownership claim (D9.9): same package is normal, a package-less sentinel is ACCEPTED (the old throw was an artefact of the borrowed `own` slot, #6995), and a row bound to a DIFFERENT package is refused at the producer with `OBJECT_OVERLAY_PACKAGE_MISMATCH` / 422 and counted in `loadMetaFromDb`'s per-record `errors` at boot — because the overlay-uniqueness index can hold two rows for one (type, name) bound to two packages and `computeFQN` is identity, so the registry could never serve both. The delete heal subtracts the LAYER (`removeObjectOverlay`) BEFORE the tier walk, because tier 1 returns as soon as it heals the generic metadata map and would otherwise leave the object's layer registered.\n\nADR-0119 — `batchData`'s `atomic` is REAL or REFUSED, never silent best-effort. An explicitly atomic batch runs inside ONE `engine.transaction()`, so a failure rolls back every prior write and the response reports zero successes (rows marked ROLLED_BACK / NOT_ATTEMPTED — reporting a rolled-back row as `success: true` was the original bug, not merely the missing transaction). A runtime that cannot roll back gets 501 NOT_IMPLEMENTED; degrading to best-effort is how the flag came to lie." }, { "file": "packages/spec/src/kernel/functional-completeness.ts", @@ -344,7 +345,7 @@ "adrs": [ "ADR-0029" ], - "invariant": "ADR-0029 D3 — exactly ONE `own` contributor per object name, others may only `extend`; `assertSingleOwnerPerObject` is the install-time backstop and `unregisterObject`'s extender refusal mirrors the same judgement rather than inventing a second one. Two things here look like local conveniences and are neither. (1) The same-package `own` SPLICE in `registerObject` was written for HMR / metadata-rebuild replays of the SAME package's object — it is not a layering mechanism, and a tenant `sys_metadata` overlay reaching it DESTROYS the packaged definition at write time (measured on #6853: contributor provenance package -> org, the packaged fields gone, no second copy anywhere in the registry, re-done on every boot by `loadMetaFromDb`). That is what makes `getArtifactItem`'s object branch — which reads the MERGED body and rejects `_provenance: 'org'` — answer `false` for a name a code package still ships, disarming both `saveMetaItem`'s overlay gate and the delete heal's \"never retire a code-shipped object\" guard, and taking the object off the data plane (404) until restart. (2) `mergeObjectDefinitions` is ADDITIVE for fields/validations/indexes and has no expression for removal, so an overlay must never be modelled as an `extend` — it would silently stop removing a packaged field. The fix is the ADR-0029 2026-08-09 amendment (D9, #6853): the overlay becomes its own non-owning `overlay` contributor layer, base selection becomes `overlay ?? own`, and artifact identity is read from the OWNER contributor instead of the merged object. D9 is DESIGN ONLY — do not implement it by widening these branches ad hoc, and do not 'simplify' the splice or the single-owner count in the meantime: both are the recorded decision, not an oversight." + "invariant": "ADR-0029 D3 + D9 — exactly ONE `own` contributor per object name; others may only `extend`, and a TENANT customization is a third, NON-owning `overlay` LAYER. `assertSingleOwnerPerObject` counts `own` and nothing else, with no exemption clause (ADR-0028 D5/D6 rest on that sentence being unconditional); `unregisterObject`'s extender refusal mirrors that judgement rather than inventing a second one. Three things here look like local conveniences and are not. (1) The same-package `own` SPLICE in `registerObject` is for HMR / metadata-rebuild replays of the SAME package's object — it is NOT a layering mechanism. A tenant `sys_metadata` overlay must never reach it: before D9 it did, and it DESTROYED the packaged definition at write time (measured on #6853 — contributor provenance package -> org, the packaged fields gone, no second copy anywhere, replayed on every boot by `loadMetaFromDb`), which made `getArtifactItem` answer `false` for a name a code package still ships and disarmed both `saveMetaItem`'s overlay gate and the delete heal's \"never retire a code-shipped object\" guard. The hydration seams now pass `'overlay'`, chosen through `getPackagedObjectOwner` (D9.8). (2) `mergeObjectDefinitions` is ADDITIVE for fields/validations/indexes and has no expression for removal, so an overlay must never be modelled as an `extend` — it would silently resurrect every packaged field the tenant deleted. That is why the overlay REPLACES the base layer instead (D9.2), and why `resolveObject` answers bit-for-bit what the old splice produced, `_provenance: 'org'` included: the resolved object must not move. (3) Base selection asks the KIND, never the priority (`DEFAULT_OVERLAY_PRIORITY = 150` is descriptive ordering only, D9.3) — extender priority is author-declared, so a package could otherwise re-rank a tenant's overlay out of the base slot. Related invariants that must move together: `provisionPrimary` / `provisionSearchCompanion` are gated on \"is this a BASE layer\" (`own` or `overlay`) and not on `own`, or `nameField` changes on every overlaid object; artifact identity is read from the OWNER contributor's layer, never from the merged body (D9.6); an overlay layer leaves with the owner it layers over (D9.7); and an `overlay` with no `own` is an orphan violation (D9.5)." } ] }