From d252b88e14c466af23b9c2d464dddceec6b1c9f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 03:15:55 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(spec,metadata-protocol):=20capability?= =?UTF-8?q?=20=E8=A1=A5=E9=BD=90=E4=B8=89=E5=A4=84=E6=B3=A8=E5=86=8C=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E6=8E=88=E6=9D=83=E9=9D=A2=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8E=A5=E5=8F=97=E4=BB=BB=E6=84=8F=20JSON=20(#5961)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #5961(裁决 A 案,ADR-0066 D1 依据)。 ## 前提复核(premise-first) 对 origin/main(b532f8a,已含 #6478 / #6474)逐一核验,三处皆缺仍然成立: - `MetadataTypeSchema`(metadata-plugin.zod.ts)枚举内无 `capability`; - `BUILTIN_METADATA_TYPE_SCHEMAS`(metadata-type-schemas.ts)无 `capability` 键 (文件内 "capability" 仅出现在 position/ADR 注释里); - `DEFAULT_METADATA_TYPE_REGISTRY` 无 `capability` 条目; - `HAND_CRAFTED_SCHEMAS`(protocol.ts:188)亦无——其中的 `capabilities` 是 `object` 的一个内层属性,不是顶层 kind 兜底。 而平台早就把它当 kind 在用:`PLURAL_TO_SINGULAR` 自 #5870 起有 `capabilities` -> `capability`;`AppPlugin` 用这个名字 `registerInMemory`; `bootstrapDeclaredCapabilities` 再读回来 seed `sys_capability`。这是 "enforced but undeclared" —— #5271 给 `api` 关掉的那个 declared != enforced 的镜像。 后果两条,第二条才使它成为授权缺陷: 1. `getMetadataTypeSchema('capability')` 返回 undefined,`saveMetaItem` 走 "未注册类型 -> 不校验直接存" 分支,`PUT /api/v1/meta/capability/:name` 接受任意 JSON 落进 sys_metadata。capability 由**名字字符串**解析 (授予侧 systemPermissions / 要求侧 requiredPermissions),任意 JSON 直接落在 活的授权命名空间里。 2. `isRuntimeCreateAllowed` 镜像 `getMetaTypes()` 的合成规则:无静态条目的类型 被当作可运行时创建。所以缺的那一行不只是没关门,它**把门打开了**; `/meta/types` 同步发布该虚构(allowRuntimeCreate: true + 无 schema = raw-JSON 文本框表单)。 ## 改动 照 #5271 给 `api` 的同形模板: 1. `BUILTIN_METADATA_TYPE_SCHEMAS['capability'] = CapabilityDeclarationSchema` (Security Protocol 段)。既有 422 invalid_metadata 路径覆盖该 kind, /meta/types 发出真 JSON Schema。 2. `DEFAULT_METADATA_TYPE_REGISTRY` 新增条目,`allowRuntimeCreate: false` + `allowOrgOverride: false` —— 即 #5086 的 CODE-ONLY 声明,`saveMetaItem` 在 任何 kernel 上以 403 not_creatable 拒绝,并从条目自身 filePatterns[0] 读回 "该去哪儿声明"。`supportsOverlay: false`(capability 无 merge 语义,且允许 租户 overlay 包发布的声明等于允许把 scope 从 org 抬成 platform)。 `loadOrder: 12` 早于 permission/position(15)。domain: security。 3. `MetadataTypeSchema` 枚举补 `'capability'`。 4. metadata-protocol 侧按注册表条目自然收敛,未改 protocol.ts 一行: `STATIC_REGISTRY_TYPES` / `RUNTIME_CREATE_ALLOWED_TYPES` / `getMetaTypes()` 的 registryByType 全是从注册表派生的。 ### 裁决未明示、按其理据自行判定的一处(请 PM 过目) 裁决只写死了 `allowRuntimeCreate: false`,未提 `allowOrgOverride`。本 PR 取 `false`,理据即裁决自身:授权面 + "包声明,不由管理员运行时创建"。若取 true, 租户可 overlay 包发布的声明并改写 scope(org -> platform),那是提权;且 allowRuntimeCreate/allowOrgOverride 双 false 才构成 #5086 的 code-only 语义 (job / agent 同形)。如认为应另判,改动仅此一词。 ### 联带的必要成本(不是范围外改动) 加入 BUILTIN_METADATA_TYPE_SCHEMAS 会被三个既有的数据驱动闸门自动纳管, 它们红了才需要补,不是主动扩围: - `CapabilityDeclarationSchema` 声明 ADR-0010 保护信封并收紧为 strict。 信封是硬性的:loader 对每个已注册类型都调 applyProtection,不声明就 422 掉 loader 自己的输出(#4001 在 permission/position 上补过同一个洞)。 收紧与 api 不同 —— ApiEndpointSchema 同时是**存储行**解析器,故留在 STILL_STRIP;而无任何地方拿本 schema 重解析 sys_capability 行 (bootstrapDeclaredCapabilities 经 capabilityRowFields 按名读字段), 故收紧零成本。改用 strictObject 书写,已知键从 shape 派生,不新增手抄键表 (因此 alias-integrity 的 44 上限未动)。 - #4001 计数:registered 25 -> 26,closed 23 -> 24,STILL_STRIP 未增长。 - create-seeds 的 KNOWN_UNSEEDED 增 capability(code-only,无运行时创建面, 与 job/agent 同因)。 - liveness:新注册类型必须被治理。新建 packages/spec/liveness/capability.json, 5 个可授权属性逐条 call-graph 闭合到真实读者(全部 live,证据路径均可解析), ADR-0010 信封 7 键沿用 permission/position 的 null 判定;GOVERNED 加 capability。**未**走 PENDING_GOVERNANCE 记债。 - protocol.code-only-types.test.ts:CODE_ONLY_TYPES 由注册表派生,capability 自动入列,补 schema-valid 探针 + 覆盖断言 ['agent','capability','job']。 - packages/cli 的 metadata-type-schema-gate:capability 进 GATED_AT (stack 在 capabilities: 以注册表原形的扁平数组授权,且 shape 已收紧)。 生成物整体重生成:authorable-surface/security.json、 content/docs/references 三处、json-schema manifest;gen:openapi 随 build 跑过 (产物在 gitignore 的 json-schema/ 下,无 diff)。未触 content/docs/releases/。ADR-0122 无新增导出别名,故未动 pin 计数。 ## 测试 新增两支: - packages/spec/src/kernel/capability-metadata-kind.test.ts —— 声明侧: 三处注册齐备、schema 可解析、code-only 双标志、loadOrder 早于 permission、 filePatterns 非空(403 的处方半边)、单复数映射双向、合法载荷通过、 信封通过、非法载荷/非法 name 被拒、错键给出 surface 名与建议、 邻近概念(permissionSets/requiredPermissions/inputs)按 ADR-0066 分层回绝。 - packages/metadata-protocol/src/protocol.capability-write-door.test.ts —— 执行侧(验收标准),真实写路径 + stub engine(delete/update 用 assertEngineDeleteDispatch / assertEngineUpdateDispatch): 403 拒非法载荷且 sys_metadata 无行、403 同样拒**合法**载荷(门是关于谁写)、 与 environmentId 无关、处方含 .capability.、复数拼写同规则; OS_METADATA_WRITABLE=capability 逃生门后非法载荷 422 而非原样落盘、 合法载荷仍可存(控制组);包声明通道不经 saveMetaItem; /meta/types 发真实注册表描述符(allowRuntimeCreate=false / domain=security / loadOrder / label)与真 JSON Schema,不再合成假描述符。 ### 逆向验证(方向先判后跑,两肢各自重建 dist) 首轮曾误判:metadata-protocol 从 dist 解析 spec,未重建就跑 -> 假绿。重建后: - 肢一 删注册表条目:预判 403 用例变为 resolve、/meta/types 回到合成描述符。 实测 7 红 3 绿,拒绝用例均报 `promise resolved "{ success: true, ...(4) }" instead of rejecting`。**比预判更锐利的一处**:第 1 用例(无逃生门 + 垃圾 载荷)不是 resolve 而是转为 422 —— 条目没了,写入越过 code-only 门抵达仍然 绑定的 schema 校验。故两肢非简单叠加:schema 绑定是注册表条目之后真实的第二 道防线,只有"既合法又越权"的载荷(用例 2)才单独隔离出条目本身。此事实已写 进测试头注,未抹平。 - 肢二 删 schema 绑定:预判逃生门后 422 用例改为存盘、/meta/types 无 schema。 实测 3 红 7 绿,恰为该三例。 绿的那半不是松弛:一个"把 capability 改成任何情况都不可写"的假修复会通过全部 拒绝用例而挂在逃生门后的存盘用例上。 ## 验证(前台阻塞全量) - spec: test 341 files / 8727 tests 全绿;typecheck(tsc + scripts + test-typecheck)绿。 - metadata-protocol: test 56 files / 584 tests 全绿。 - spec check:generated: 10/10 up to date。 - spec 源审计: liveness / empty-state / skill-examples / variant-docs / exported-any / dual-source-exports 全绿。 - 门禁族(ESLint job 内): nul-bytes / role-word / adr-anchors / route-envelope / error-code-casing / meta-type-normalized / engine-double-contract / spec-parsed-alias / empty-changeset / doc-authoring / quick-reference-counts / authz-resolver / wildcard-fallthrough / published-files / type-check-coverage / adr-0087-registration 全 PASS;改动文件 eslint 0 问题。 - 跨包回归(注册表是共享数据,主动跑): objectql 146/2448 绿、metadata 26/517 绿、 plugin-security 35/768 绿、cli metadata-type-schema-gate 8/8 绿。 - examples/app-showcase `os validate` 退出码 0(收紧后真实 capability 声明仍通过; 输出中的告警均为既有项,与本改动无关)。 - 控制字节自扫描(gate 之外): 改动文件 0 命中。 ## 未搭车 role / profile / policy 未加入 —— 裁决原文:另形另单,它们连 PLURAL_TO_SINGULAR 都没有,也没有声明 schema 与读回接缝。该拒绝以断言形式钉在 capability-metadata-kind.test.ts,因为"capability 有了条目,邻居也该有"正是下一个 显而易见却错误的改动。 changeset: @objectstack/spec minor + @objectstack/metadata-protocol patch。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0117Ku1TvXRjvMMToDqfa48u --- ...capability-metadata-kind-registry-entry.md | 64 ++++ content/docs/references/api/metadata.mdx | 5 +- .../references/kernel/metadata-plugin.mdx | 6 +- content/docs/references/security/misc.mdx | 7 + .../test/metadata-type-schema-gate.test.ts | 6 + .../protocol.capability-write-door.test.ts | 362 ++++++++++++++++++ .../src/protocol.code-only-types.test.ts | 28 +- .../spec/authorable-surface/security.json | 7 + packages/spec/liveness/capability.json | 43 +++ .../spec/scripts/liveness/check-liveness.mts | 2 +- .../kernel/capability-metadata-kind.test.ts | 186 +++++++++ .../src/kernel/metadata-create-seeds.test.ts | 5 + .../spec/src/kernel/metadata-plugin.zod.ts | 43 +++ .../src/kernel/metadata-type-schemas.test.ts | 13 +- .../spec/src/kernel/metadata-type-schemas.ts | 20 + packages/spec/src/security/capabilities.ts | 61 ++- 16 files changed, 846 insertions(+), 12 deletions(-) create mode 100644 .changeset/capability-metadata-kind-registry-entry.md create mode 100644 packages/metadata-protocol/src/protocol.capability-write-door.test.ts create mode 100644 packages/spec/liveness/capability.json create mode 100644 packages/spec/src/kernel/capability-metadata-kind.test.ts diff --git a/.changeset/capability-metadata-kind-registry-entry.md b/.changeset/capability-metadata-kind-registry-entry.md new file mode 100644 index 0000000000..ca2d4423cb --- /dev/null +++ b/.changeset/capability-metadata-kind-registry-entry.md @@ -0,0 +1,64 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": patch +--- + +fix(spec,metadata-protocol): `capability` 补齐三处注册 —— 授权面不再接受任意 JSON (#5961) + +`capability` 是「enforced but undeclared」——#5271 给 `api` 关掉的那个 +`declared ≠ enforced` 的镜像。平台早就把它当成一个 metadata kind 在用: +`PLURAL_TO_SINGULAR` 从 #5870 起就有 `capabilities` → `capability`, +`AppPlugin` 用这个名字注册 stack 声明的 capability, +`bootstrapDeclaredCapabilities` 再读回来 seed `sys_capability`。但三处注册表 +里都没有它:`MetadataTypeSchema`(kind 枚举)、`BUILTIN_METADATA_TYPE_SCHEMAS` +(schema 解析)、`DEFAULT_METADATA_TYPE_REGISTRY`(谁可以写、怎么加载)。 + +后果有两条,第二条才是这个 issue 属于授权缺陷而非整洁度问题的原因: + +- `getMetadataTypeSchema('capability')` 返回 `undefined`,于是 `saveMetaItem` + 走了它自己文档化的「未注册类型 → 不校验直接存」分支, + `PUT /api/v1/meta/capability/:name` 接受**任意 JSON** 落进 `sys_metadata`。 + capability 是靠**名字字符串**被解析的——授予侧 `systemPermissions`、 + 要求侧 `requiredPermissions` 都是——所以一行任意 JSON 直接落在活的授权命名 + 空间里。 +- `isRuntimeCreateAllowed` 镜像 `getMetaTypes()` 的合成规则:没有静态注册表条目 + 的类型被当作可运行时创建。所以缺的那一行不只是「没关上门」,它**把门打开了**。 + `/meta/types` 同步发布了这个虚构:`allowRuntimeCreate: true` + 无 schema, + metadata-admin 引擎据此渲染成一个 raw-JSON 文本框。 + +### 改了什么 + +- **`BUILTIN_METADATA_TYPE_SCHEMAS['capability'] = CapabilityDeclarationSchema`**。 + 既有的 422 `invalid_metadata` 路径就此覆盖 `capability`,`/meta/types` 发出真 + JSON Schema。 +- **`DEFAULT_METADATA_TYPE_REGISTRY` 新增 `capability` 条目, + `allowRuntimeCreate: false` + `allowOrgOverride: false`**。ADR-0066 D1:包 + DEFINE capability,权限集 GRANT,资源 REQUIRE。管理员在运行时凭空造一个 + capability 在这个三分里没有位置——代码里不会有任何地方 require 那个名字,这行 + 只是授权命名空间里一个无人引用的授予目标。这一对标志就是 #5086 的 CODE-ONLY + 声明,`saveMetaItem` 在**任何** kernel 上都以 403 `not_creatable` 拒绝,并从条 + 目自己的 `filePatterns[0]` 读回「该去哪儿声明」。`supportsOverlay: false`—— + capability 只是名字/标签/scope,没有 merge 语义,而允许租户 overlay 一个包发布 + 的声明等于允许把 `scope` 从 `org` 抬成 `platform`。`loadOrder: 12` 早于 + `permission`/`position`(15),使权限集的 `systemPermissions` 解析时 capability + 已经存在。 +- **`MetadataTypeSchema` 枚举补 `'capability'`**。 +- **`CapabilityDeclarationSchema` 声明 ADR-0010 保护信封并收紧为 `.strict()`**。 + 信封是必须的:loader 对每个已注册类型都调 `applyProtection`,不声明就会 422 掉 + loader 自己的输出(#4001 在 `permission`/`position` 上补过同一个洞)。收紧则与 + `api` 不同——`ApiEndpointSchema` 同时是**存储行**的解析器,所以它留在 + `STILL_STRIP`;而没有任何地方拿这个 schema 重新解析 `sys_capability` 行 + (`bootstrapDeclaredCapabilities` 通过 `capabilityRowFields` 按名读字段), + 所以收紧零成本,买到的是一个授权面本就该有的 declared = enforced 姿态。 + 改用 `strictObject` 书写,已知键从 shape 派生,不新增手抄键表。 + +**包声明通道完全没动。** `AppPlugin` 通过 `registerInMemory` 注册 stack 的 +`capabilities[]`,文件系统 loader 按 `filePatterns` glob——两条都不经过 +`saveMetaItem`,所以 `bootstrapDeclaredCapabilities` 依旧照常 seed。 +`OS_METADATA_WRITABLE=capability` 仍是 ADR-0005 那唯一一道运维逃生门,而在它后面 +写入现在由 `CapabilityDeclarationSchema` 判定(422),不再原样落盘。 + +⛔ `role` / `profile` / `policy` **不搭车**:它们没有 `PLURAL_TO_SINGULAR` 映射、 +没有声明 schema、没有读回接缝,是另一个问题,另开单。这条以断言形式钉在 +`capability-metadata-kind.test.ts` 里,因为「capability 有了条目,邻居也该有」 +正是下一个显而易见却错误的改动。 diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 35d6a3e68a..e190d899c3 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -326,7 +326,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | +| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | | **namespaces** | `string[]` | optional | Filter by namespaces | | **packageId** | `string` | optional | Filter by owning package | | **search** | `string` | optional | Full-text search query | @@ -361,7 +361,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| … +11 more>` | ✅ | Metadata type | +| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| … +12 more>` | ✅ | Metadata type | | **name** | `string` | ✅ | Item name (snake_case) | | **data** | `Record` | ✅ | Metadata payload | | **namespace** | `string` | optional | Optional namespace | @@ -391,6 +391,7 @@ Metadata query with filtering, sorting, and pagination * `book` * `permission` * `position` +* `capability` * `agent` * `tool` * `skill` diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 43c7ed896f..35edca46ec 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -128,7 +128,7 @@ const result = MetadataBulkResultSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | +| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | | **namespaces** | `string[]` | optional | Filter by namespaces | | **packageId** | `string` | optional | Filter by owning package | | **search** | `string` | optional | Full-text search query | @@ -184,6 +184,7 @@ const result = MetadataBulkResultSchema.parse(data); * `book` * `permission` * `position` +* `capability` * `agent` * `tool` * `skill` @@ -197,7 +198,7 @@ const result = MetadataBulkResultSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| … +11 more>` | ✅ | Metadata type identifier | +| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| … +12 more>` | ✅ | Metadata type identifier | | **label** | `string` | ✅ | Display label for the metadata type | | **description** | `string` | optional | Description of the metadata type | | **filePatterns** | `string[]` | ✅ | Glob patterns to discover files of this type | @@ -235,6 +236,7 @@ const result = MetadataBulkResultSchema.parse(data); * `book` * `permission` * `position` +* `capability` * `agent` * `tool` * `skill` diff --git a/content/docs/references/security/misc.mdx b/content/docs/references/security/misc.mdx index 74728e60d2..f231e72c42 100644 --- a/content/docs/references/security/misc.mdx +++ b/content/docs/references/security/misc.mdx @@ -28,6 +28,13 @@ const result = CapabilityDeclarationSchema.parse(data); | **description** | `string` | optional | What holding this capability permits | | **scope** | `Enum<'platform' \| 'org'>` | ✅ | platform = a platform-wide power; org = scoped to an organization | | **packageId** | `string` | optional | [ADR-0086 D3] Owning package id (author-declared fallback; absent = registry-stamped) | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | --- diff --git a/packages/cli/test/metadata-type-schema-gate.test.ts b/packages/cli/test/metadata-type-schema-gate.test.ts index 72f150428f..091c416817 100644 --- a/packages/cli/test/metadata-type-schema-gate.test.ts +++ b/packages/cli/test/metadata-type-schema-gate.test.ts @@ -82,6 +82,12 @@ const GATED_AT: Readonly> = { book: 'books', permission: 'permissions', position: 'positions', + // [#5961] `capability` joined the registry as a CLOSED shape, so it goes + // straight into the gated set rather than into NOT_YET_CLOSED below: the + // stack authors it at `capabilities:` as a flat array of the registry's own + // shape (`stack.zod.ts`: `z.array(CapabilityDeclarationSchema)`), and that + // shape is `.strict()`, so both gates reject an undeclared key identically. + capability: 'capabilities', agent: 'agents', tool: 'tools', skill: 'skills', diff --git a/packages/metadata-protocol/src/protocol.capability-write-door.test.ts b/packages/metadata-protocol/src/protocol.capability-write-door.test.ts new file mode 100644 index 0000000000..a7e2e8b07b --- /dev/null +++ b/packages/metadata-protocol/src/protocol.capability-write-door.test.ts @@ -0,0 +1,362 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5961 — the `capability` write door, and what `/meta/types` says about it. + * + * `packages/spec` now declares `capability` in all three registries it was + * missing from (kind enum / schema map / type registry) — the DECLARATION half, + * pinned in `capability-metadata-kind.test.ts` over there. This file is the + * ENFORCEMENT half, and it is the acceptance criterion: a registry row that no + * write path reads would have changed a document, not a behaviour. + * + * What was wrong, precisely: + * + * • `getMetadataTypeSchema('capability')` answered `undefined`, so + * `saveMetaItem` took its documented "unregistered type → store without + * validation" branch. `PUT /api/v1/meta/capability/:name` accepted ANY + * JSON into `sys_metadata` — on the AUTHORIZATION surface, where + * `systemPermissions` (grant) and `requiredPermissions` (requirement) + * resolve capabilities by NAME STRING. + * • `isRuntimeCreateAllowed` mirrors `getMetaTypes()`'s synthesis rule: + * a type with no static registry entry is treated as runtime-creatable, so + * the missing row did not merely fail to close the door — it OPENED it. + * `/meta/types` published the matching fiction: `allowRuntimeCreate: true` + * with no schema, which the metadata-admin engine renders as a raw-JSON + * textarea. + * + * --------------------------------------------------------------------------- + * The three doors, and which one each case is about + * --------------------------------------------------------------------------- + * `capability` is CODE-ONLY (`allowRuntimeCreate: false` + + * `allowOrgOverride: false`, ADR-0066 D1 — packages DEFINE capabilities). That + * makes the ORDER inside `saveMetaItem` load-bearing, and this file pins the + * order rather than asserting a 422 that the real code never reaches: + * + * 1. DEFAULT — #5086's code-only refusal fires FIRST: 403 `not_creatable`, + * on every kernel, before any schema is consulted. Nothing is stored. + * 2. ESCAPE HATCH — with `OS_METADATA_WRITABLE=capability` (ADR-0005's one + * documented operator door) the write reaches the spec-conformance check, + * and THAT is where the new schema earns its keep: 422 `invalid_metadata` + * instead of storing the body verbatim. + * 3. PACKAGE DECLARATION — untouched. `AppPlugin` registers stack + * `capabilities[]` through `registerInMemory` and the filesystem loader + * globs `filePatterns`; neither goes through `saveMetaItem`, so + * `bootstrapDeclaredCapabilities` still seeds `sys_capability` exactly as + * before. Asserted here as the "legal payload still gets in" half. + * + * --------------------------------------------------------------------------- + * Reverse verification, direction predicted BEFORE running + * --------------------------------------------------------------------------- + * Two limbs, two predictions, both ordinary red. Each limb was rebuilt before + * measuring — this package resolves `@objectstack/spec` from `dist`, so an + * unbuilt limb measures the OLD spec and reports a false green (it did, once). + * + * • **Limb 1 — delete the `capability` row from + * `DEFAULT_METADATA_TYPE_REGISTRY`.** Predicted: `isRuntimeCreateAllowed` + * falls into its "no static entry ⇒ synthesised as creatable" branch, so + * the refusal cases RESOLVE instead of rejecting and `/meta/types` shows + * the synthesised descriptor again. Measured: 7 red / 3 green, with the + * refusals failing as `promise resolved "{ success: true, …(4) }" instead + * of rejecting` — the open door, reproduced on demand. + * + * One case failed in a SHARPER shape than predicted, and it is worth + * writing down rather than smoothing over: the first case (garbage body, + * no hatch) did not resolve — it turned into `422 invalid_metadata`, + * because with the registry row gone the write sails past the code-only + * gate and reaches the schema check, which is still bound and still + * rejects. So the two limbs are not merely additive: the schema binding is + * a genuine second line of defence behind the registry row, and only a + * body that is BOTH well-formed and unauthorised (case 2) isolates the row + * on its own. + * + * • **Limb 2 — delete `capability: CapabilityDeclarationSchema` from + * `BUILTIN_METADATA_TYPE_SCHEMAS`.** Predicted: `resolveOverlaySchema` + * answers null, the escape-hatch case SAVES the garbage body instead of + * 422-ing, and `/meta/types` emits an entry with no schema. Measured: + * 3 red / 7 green, exactly those three, `promise resolved "{ success: + * true, …(4) }" instead of rejecting` and `expected undefined to be + * defined`. + * + * The green halves are not slack. A "fix" that made `capability` unwritable + * under every condition would pass every refusal case here and fail the + * behind-the-hatch save; a harness that could not save anything at all would + * pass the refusals for the wrong reason. Both are excluded by cases that stay + * green in the fixed tree and move in only one of the two limbs. + * + * Harness: the real write path over a stub engine — the same shape as + * `protocol.flow-org-override-closed.test.ts`. The gates run INSIDE + * `saveMetaItem`, so a harness that mocks it cannot see them. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions (#4550 delete / +// #5480 update). Imported from `@objectstack/metadata-core`, never from +// `@objectstack/objectql`: objectql DEPENDS ON this package, so that import +// would close a dependency cycle turbo rejects outright. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + state: string; + metadata: string; +} + +function makeStubEngine(registeredTypes: string[] = []) { + const rows = new Map(); + let nextId = 0; + const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`; + const engine: any = { + async findOne(_t: string, opts: { where: Record }) { + for (const row of rows.values()) { + if (opts.where.type !== undefined && row.type !== opts.where.type) continue; + if (opts.where.name !== undefined && row.name !== opts.where.name) continue; + if (opts.where.state !== undefined && row.state !== opts.where.state) continue; + return row; + } + return null; + }, + async find() { return []; }, + async insert(_t: string, data: Record) { + if (_t !== 'sys_metadata') return { id: 'side_effect_skip' }; + nextId += 1; + const row = { id: `r_${nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts?: Record) { + assertEngineUpdateDispatch(data, opts); + return { id: null }; + }, + async delete(_t: string, opts?: Record) { + assertEngineDeleteDispatch(opts); + return { deleted: 0 }; + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + listItems: () => [], + getItem: () => undefined, + getArtifactItem: () => undefined, + getRegisteredTypes: () => registeredTypes, + }, + }; + return { engine, rows }; +} + +function makeProtocol(registeredTypes?: string[], environmentId?: string) { + const { engine, rows } = makeStubEngine(registeredTypes); + const protocol = new ObjectStackProtocolImplementation( + engine, + () => new Map(), + environmentId, + ) as any; + return { protocol, rows }; +} + +/** A well-formed package-level capability declaration (ADR-0066 D1). */ +const CAPABILITY = { + name: 'billing.refund', + label: 'Issue Refunds', + description: 'Refund a captured payment.', + scope: 'org', + packageId: 'com.acme.billing', +}; + +/** + * The specimen the old behaviour stored verbatim: arbitrary JSON under a + * capability name, in the namespace `systemPermissions` grants from. + */ +const NOT_A_CAPABILITY = { + name: 'billing.refund', + grantTo: ['*'], + sql: 'DROP TABLE sys_capability', +}; + +const entry = (type: string) => DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === type); + +describe('#5961 — capability: the runtime write door is closed, and validated behind the hatch', () => { + beforeEach(() => { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + }); + afterEach(() => { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + }); + + // ── door 1: the default, and the acceptance criterion ───────────────── + + it('PUT /meta/capability/:name is refused 403 — nothing reaches sys_metadata', async () => { + // The write that used to succeed with ANY body. `environmentId` is set + // here and omitted in the next case on purpose: the refusal must not + // depend on deployment topology. + const { protocol, rows } = makeProtocol([], 'env_prod'); + + await expect( + protocol.saveMetaItem({ + type: 'capability', + name: 'billing.refund', + item: NOT_A_CAPABILITY, + organizationId: 'org_alpha', + }), + ).rejects.toMatchObject({ code: 'NOT_CREATABLE', status: 403 }); + + // Refused, not "refused after writing". The stored row is the bug. + expect(rows.size).toBe(0); + }); + + it('a WELL-FORMED declaration is refused too — the door is about WHO writes, not what', async () => { + // The distinction that makes this a code-only kind rather than a + // validation improvement: a perfectly valid capability is still not + // authorable through the runtime API, because ADR-0066 D1 says + // packages DEFINE capabilities. If this ever goes green, the kind has + // silently become runtime-creatable. + const { protocol, rows } = makeProtocol([], 'env_prod'); + + await expect( + protocol.saveMetaItem({ + type: 'capability', + name: 'billing.refund', + item: CAPABILITY, + organizationId: 'org_alpha', + }), + ).rejects.toMatchObject({ code: 'NOT_CREATABLE', status: 403 }); + expect(rows.size).toBe(0); + }); + + it('the refusal does not depend on deployment topology (no environmentId either)', async () => { + // #5086's finding: keying an authorization rule off `environmentId` + // left the flagship showcase — a host config boots with NO + // environmentId — running with the gate disengaged, on a surface whose + // `PUT /api/v1/meta/*` is an END-USER door. + const { protocol, rows } = makeProtocol(); + + await expect( + protocol.saveMetaItem({ type: 'capability', name: 'billing.refund', item: CAPABILITY }), + ).rejects.toMatchObject({ code: 'NOT_CREATABLE', status: 403 }); + expect(rows.size).toBe(0); + }); + + it('the refusal prescribes where to declare it instead', async () => { + // `codeOnlySourceHint` reads the registry row's own `filePatterns[0]`, + // so the remedy cannot drift from the entry. A refusal that only says + // "no" sends the author looking for a workaround. + const { protocol } = makeProtocol(); + + await expect( + protocol.saveMetaItem({ type: 'capability', name: 'billing.refund', item: CAPABILITY }), + ).rejects.toThrow(/\.capability\./); + }); + + it('the plural spelling is judged by the same rule', async () => { + // `STATIC_REGISTRY_TYPES` / `RUNTIME_CREATE_ALLOWED_TYPES` add + // `SINGULAR_TO_PLURAL[type]` alongside each entry, so + // `PUT /meta/capabilities/:name` must not be a second, open door. + const { protocol, rows } = makeProtocol(); + + await expect( + protocol.saveMetaItem({ type: 'capabilities', name: 'billing.refund', item: CAPABILITY }), + ).rejects.toMatchObject({ code: 'NOT_CREATABLE', status: 403 }); + expect(rows.size).toBe(0); + }); + + // ── door 2: behind the operator escape hatch, the schema bites ──────── + + it('with OS_METADATA_WRITABLE=capability, a garbage body is 422 — not stored verbatim', async () => { + // THE regression this issue is about, isolated. Reaching the + // spec-conformance check requires opening ADR-0005's documented hatch, + // because the code-only refusal fires first — so this is the one place + // the new schema binding is observable on the write path, and without + // it this body was persisted. + process.env.OS_METADATA_WRITABLE = 'capability'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + const { protocol, rows } = makeProtocol([], 'env_prod'); + + await expect( + protocol.saveMetaItem({ + type: 'capability', + name: 'billing.refund', + item: NOT_A_CAPABILITY, + organizationId: 'org_alpha', + }), + ).rejects.toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + expect(rows.size).toBe(0); + }); + + it('behind the same hatch a VALID declaration saves — the 422 is about the body', async () => { + // The control that stops the case above from passing for the wrong + // reason. Without it, a change that made `capability` unwritable under + // every condition would look identical. + process.env.OS_METADATA_WRITABLE = 'capability'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + const { protocol, rows } = makeProtocol([], 'env_prod'); + + const result = await protocol.saveMetaItem({ + type: 'capability', + name: 'billing.refund', + item: CAPABILITY, + organizationId: 'org_alpha', + }); + + expect(result.success).toBe(true); + expect(rows.size).toBe(1); + }); + + // ── door 3: the package-declaration channel, untouched ──────────────── + + it('the package channel does not go through saveMetaItem at all', async () => { + // `AppPlugin` calls `metadata.registerInMemory('capability', name, item)` + // for every stack-declared capability, and the filesystem loader globs + // `filePatterns` — neither reaches the gate above, which is why + // `allowRuntimeCreate: false` closes the ADMIN door without breaking + // the one ADR-0066 D1 actually prescribes. Asserted structurally: the + // registry row is what the loader reads, and it must carry patterns. + const row = entry('capability')!; + expect(row.allowRuntimeCreate).toBe(false); + expect(row.filePatterns.length).toBeGreaterThan(0); + + // And the declaration itself is spec-valid, so the seeding pass that + // reads it back (`bootstrapDeclaredCapabilities`) is being handed a + // body this schema accepts rather than one it would now reject. + const { getMetadataTypeSchema } = await import('@objectstack/spec/kernel'); + expect(getMetadataTypeSchema('capability')!.safeParse(CAPABILITY).success).toBe(true); + }); + + // ── /meta/types no longer publishes a fiction ───────────────────────── + + it('/meta/types serves the REAL registry descriptor, not a synthesised one', async () => { + const { protocol } = makeProtocol(['capability']); + + const { entries } = await protocol.getMetaTypes(); + const descriptor = entries.find((e: any) => e.type === 'capability'); + + expect(descriptor).toBeDefined(); + // The three tells of the synthesised branch, each asserted against the + // value the registry actually declares. + expect(descriptor.allowRuntimeCreate).toBe(false); // was: true + expect(descriptor.domain).toBe('security'); // was: 'system' + expect(descriptor.loadOrder).toBe(entry('capability')!.loadOrder); // was: 1000 + expect(descriptor.label).toBe('Capability'); // was: 'capability' + }); + + it('/meta/types emits a real JSON Schema, so the admin form is not a raw-JSON textarea', async () => { + // The read-side consequence of the missing schema binding. The engine + // picks its controls from this; with no schema it falls back to a free + // text area, which is how arbitrary JSON got authored in the first + // place. + const { protocol } = makeProtocol(['capability']); + + const { entries } = await protocol.getMetaTypes(); + const descriptor = entries.find((e: any) => e.type === 'capability'); + + expect(descriptor.schema).toBeDefined(); + expect(Object.keys(descriptor.schema.properties ?? {})).toEqual( + expect.arrayContaining(['name', 'label', 'description', 'scope']), + ); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.code-only-types.test.ts b/packages/metadata-protocol/src/protocol.code-only-types.test.ts index c123ed6e58..38e735b5b3 100644 --- a/packages/metadata-protocol/src/protocol.code-only-types.test.ts +++ b/packages/metadata-protocol/src/protocol.code-only-types.test.ts @@ -85,6 +85,23 @@ const PROBES: Record }> = instructions: 'be helpful', }, }, + // [#5961] The third flagged type. ADR-0066 D1: packages DEFINE + // capabilities — an administrator minting one at runtime has no place in + // the capability / assignment / requirement separation, so the kind is + // code-only for the same reason `job` and `agent` are. This probe is + // schema-valid on purpose (see the note above): only a body the schema + // ACCEPTS proves the refusal came from the registry consult rather than + // from the 422 that #5961 also gave this type. + capability: { + name: 'rc3_capability_probe', + item: { + name: 'rc3_capability_probe', + label: 'C', + description: 'Probe capability.', + scope: 'org', + packageId: 'com.example.probe', + }, + }, }; function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) { @@ -186,11 +203,14 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => { // ── the flags are data: keep the suite honest about new ones ────────── it('covers every code-only type the registry declares', () => { - // Today: job (#4509) and agent (ADR-0063 §2). When a third type is - // flagged, this fails until it has a schema-valid probe above — - // which is the whole cost of covering it. + // Today: job (#4509), agent (ADR-0063 §2) and capability (#5961, + // ADR-0066 D1). When a fourth type is flagged, this fails until it has + // a schema-valid probe above — which is the whole cost of covering it, + // and is exactly what happened when `capability` joined: this + // assertion and eleven generated cases went red on the spec-side + // registry edit alone, before a line of this file was touched. expect(CODE_ONLY_TYPES.length).toBeGreaterThan(0); - expect([...CODE_ONLY_TYPES].sort()).toEqual(['agent', 'job']); + expect([...CODE_ONLY_TYPES].sort()).toEqual(['agent', 'capability', 'job']); for (const type of CODE_ONLY_TYPES) { expect(PROBES[type], `no probe payload for code-only type '${type}'`).toBeDefined(); } diff --git a/packages/spec/authorable-surface/security.json b/packages/spec/authorable-surface/security.json index 25a10d4882..d7c462d225 100644 --- a/packages/spec/authorable-surface/security.json +++ b/packages/spec/authorable-surface/security.json @@ -21,6 +21,13 @@ "security/AdminScope:includeSubtree", "security/AdminScope:manageAssignments", "security/AdminScope:manageBindings", + "security/CapabilityDeclaration:_lock", + "security/CapabilityDeclaration:_lockDocsUrl", + "security/CapabilityDeclaration:_lockReason", + "security/CapabilityDeclaration:_lockSource", + "security/CapabilityDeclaration:_packageId", + "security/CapabilityDeclaration:_packageVersion", + "security/CapabilityDeclaration:_provenance", "security/CapabilityDeclaration:description", "security/CapabilityDeclaration:label", "security/CapabilityDeclaration:name", diff --git a/packages/spec/liveness/capability.json b/packages/spec/liveness/capability.json new file mode 100644 index 0000000000..48dbbc7ff0 --- /dev/null +++ b/packages/spec/liveness/capability.json @@ -0,0 +1,43 @@ +{ + "type": "capability", + "_note": "CapabilityDeclarationSchema (ADR-0066 D1). The DECLARATION side of the three-way separation — packages DEFINE a capability here, permission sets GRANT it via `systemPermissions`, resources REQUIRE it via `requiredPermissions`. Seeded 2026-08-08 with #5961, the PR that made `capability` a registered metadata kind; every property was call-graph-closed at that time against `packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts`, which is the one consumer that turns a declaration into a `sys_capability` row. The ADR-0010 envelope keys carry the same `null` verdict as `permission`/`position`: they are loader-stamped, not authored.", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-08-08", + "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts:203-210 (upsertPackageCapability — the name is the `sys_capability` upsert key, is matched against PLATFORM_CAPABILITY_NAMES to refuse shadowing a curated capability, and is the string `systemPermissions` / `requiredPermissions` resolve a grant by); packages/lint/src/validate-capability-references.ts:99 (the authoring lint's known-name set)", + "note": "The whole identity of a capability. This is also why the kind's write door matters: the name is resolved by string from both the grant side and the requirement side, so an unvalidated row lands directly in the authorization namespace (#5961)." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-08", + "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts:122 (capabilityRowFields — written to sys_capability.label, falling back to humanize(name) when absent)", + "note": "display (Setup's capability list/detail, rendered from the sys_capability row)." + }, + "description": { + "status": "live", + "verifiedAt": "2026-08-08", + "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts:123 (capabilityRowFields — written to sys_capability.description, falling back to `Capability .` when absent)", + "note": "display." + }, + "scope": { + "status": "live", + "verifiedAt": "2026-08-08", + "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts:124 (capabilityRowFields — written to sys_capability.scope, normalized to 'org' only on an exact 'org' match, else 'platform')", + "note": "platform vs org decides whether holding the capability is a global power or one scoped to the caller's organization — the same distinction PLATFORM_CAPABILITIES carries on the curated side. Not display: it is the field that would be escalated if a tenant could overlay a package-shipped declaration, which is why the #5961 registry entry sets `supportsOverlay: false` / `allowOrgOverride: false`." + }, + "packageId": { + "status": "live", + "verifiedAt": "2026-08-08", + "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts:294 (`const packageId = cap._packageId ?? cap.packageId` — the ADR-0086 D3 author-declared FALLBACK provenance, consulted when the registry stamp is absent; with neither, upsertPackageCapability takes the no-owning-package refusal at :214 and the capability is materialized with no package provenance)", + "note": "Deliberately a fallback and not the primary: #5870 added `capabilities` to the ObjectQL engine's stamped-collection list (packages/objectql/src/engine.ts:2393), so `_packageId` now reaches a declaration and takes precedence. The key stays live because the fallback branch is still read and still decides materialization for any declaration that arrives unstamped." + }, + "_lock": null, + "_lockReason": null, + "_lockSource": null, + "_provenance": null, + "_packageId": null, + "_packageVersion": null, + "_lockDocsUrl": null + } +} diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 5284e47ec8..6a59c2af0f 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -127,7 +127,7 @@ const ledgerRoot = ledgerRootArg // Governed metadata types, rolled out highest-frequency / highest-risk first. // (`query` is not a metadata type — see SPEC_ONLY_SCHEMAS below.) -const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource', 'app', 'book', 'doc', 'email_template', 'job', 'mapping', 'seed', 'translation', 'validation', 'api']; +const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource', 'app', 'book', 'doc', 'email_template', 'job', 'mapping', 'seed', 'translation', 'validation', 'api', 'capability']; // Registered metadata types that are NOT yet governed — the coverage ratchet. // diff --git a/packages/spec/src/kernel/capability-metadata-kind.test.ts b/packages/spec/src/kernel/capability-metadata-kind.test.ts new file mode 100644 index 0000000000..fdd8038d44 --- /dev/null +++ b/packages/spec/src/kernel/capability-metadata-kind.test.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5961 — `capability` is a REGISTERED metadata kind, and a code-only one. + * + * ## The hole this closes + * + * `capability` was ENFORCED BUT UNDECLARED — the mirror of `declared ≠ + * enforced`, and the exact shape #5271 closed for `api`. Three registries were + * missing an entry the rest of the platform behaved as if they had: + * + * 1. `MetadataTypeSchema` (the kind enum), + * 2. `BUILTIN_METADATA_TYPE_SCHEMAS` (schema resolution), and + * 3. `DEFAULT_METADATA_TYPE_REGISTRY` (who may write, and how it is loaded). + * + * Meanwhile `PLURAL_TO_SINGULAR` has mapped `capabilities` → `capability` + * since #5870, `AppPlugin` registers stack-declared capabilities under exactly + * that name, and `bootstrapDeclaredCapabilities` reads them back to seed + * `sys_capability`. So the kind was live; it just had no contract. + * + * Two consequences, and the second is why this is an authorization bug rather + * than a tidiness one: + * + * • `getMetadataTypeSchema('capability')` answered `undefined`, so + * `saveMetaItem` took its documented "unregistered type → store without + * validation" branch and `PUT /api/v1/meta/capability/:name` accepted ANY + * JSON. Capabilities are resolved BY NAME STRING from both the grant side + * (`systemPermissions`) and the requirement side (`requiredPermissions`), + * so an arbitrary-JSON row landed in the live authorization namespace. + * • `/meta/types` synthesised a FAKE descriptor for it — + * `allowRuntimeCreate: true`, no schema, hence a raw-JSON textarea form — + * advertising a runtime authoring surface the platform never designed. + * + * ## Why `allowRuntimeCreate: false` + * + * ADR-0066 D1: packages DEFINE capabilities, permission sets GRANT them, + * resources REQUIRE them. An administrator minting a brand-new capability at + * runtime has no place in that separation — nothing in code would ever require + * the name, so the row is an unreferenced grant target in the authorization + * namespace. Paired with `allowOrgOverride: false` this is #5086's CODE-ONLY + * declaration, which `saveMetaItem` enforces on every kernel. + * + * The enforcement half lives in + * `@objectstack/metadata-protocol`'s `protocol.capability-write-door.test.ts` + * — the write path is there, and a registry pin alone would be a document. + * This file pins the DECLARATION, plus the half of the ruling that is a + * refusal. + */ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_METADATA_TYPE_REGISTRY, MetadataTypeSchema } from './metadata-plugin.zod'; +import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from './metadata-type-schemas'; +import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '../shared/metadata-collection.zod'; + +const entry = (type: string) => DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === type); + +/** A well-formed package-level declaration, as `defineCapability` would produce. */ +const DECLARATION = { + name: 'billing.refund', + label: 'Issue Refunds', + description: 'Refund a captured payment.', + scope: 'org' as const, + packageId: 'com.acme.billing', +}; + +describe('#5961 — capability is a registered metadata kind', () => { + // ── the three registries that were missing it ───────────────────────── + + it('is a member of the kind enum', () => { + expect(MetadataTypeSchema.safeParse('capability').success).toBe(true); + }); + + it('resolves a schema — the branch that made PUT store raw JSON is gone', () => { + // The single most load-bearing assertion in this file. `saveMetaItem` + // decides between "validate" and "store verbatim" purely on whether this + // returns something. + expect(getMetadataTypeSchema('capability')).toBeDefined(); + expect(listMetadataTypeSchemaTypes()).toContain('capability'); + }); + + it('has a registry entry, and it declares the kind CODE-ONLY', () => { + // The pair is the declaration: `allowRuntimeCreate: false` AND + // `allowOrgOverride: false` together are #5086's "no runtime write channel + // at all". Either one alone leaves a door open — which is why they are + // asserted together rather than in two cases. + expect(entry('capability')).toMatchObject({ + domain: 'security', + supportsOverlay: false, + allowOrgOverride: false, + allowRuntimeCreate: false, + }); + }); + + it('loads before the permission sets that grant it', () => { + // A set's `systemPermissions` names capabilities; resolving that against + // rows that do not exist yet is the ordering bug this avoids. + expect(entry('capability')!.loadOrder).toBeLessThan(entry('permission')!.loadOrder); + expect(entry('capability')!.loadOrder).toBeLessThan(entry('position')!.loadOrder); + }); + + it('declares file patterns, so the code-only refusal can prescribe a remedy', () => { + // `codeOnlySourceHint` reads `filePatterns[0]` back into the 403 body — + // "Declare it in source (…) and redeploy". An entry with an empty array + // would refuse the write and then say nothing about the alternative, and + // the filesystem loader globs this same list, so it is not decoration. + expect(entry('capability')!.filePatterns[0]).toMatch(/\.capability\./); + }); + + it('the singular/plural mapping the rest of the platform already used still holds', () => { + // #5870 added this; the registry entry is what it was always missing. + // Both directions matter: `STATIC_REGISTRY_TYPES` / `RUNTIME_CREATE_ALLOWED_TYPES` + // are built by walking the registry and adding `SINGULAR_TO_PLURAL[type]`, + // so a missing reverse entry would leave `PUT /meta/capabilities/:name` + // judged by a different rule than `PUT /meta/capability/:name`. + expect(PLURAL_TO_SINGULAR.capabilities).toBe('capability'); + expect(SINGULAR_TO_PLURAL.capability).toBe('capabilities'); + }); + + // ── the schema, now that something resolves it ──────────────────────── + + it('accepts a well-formed declaration', () => { + expect(getMetadataTypeSchema('capability')!.safeParse(DECLARATION).success).toBe(true); + }); + + it('accepts the ADR-0010 envelope the loader stamps on every registered type', () => { + // `applyProtection` runs on every registered kind. Now that this schema is + // strict, a body carrying the loader's own output must still parse — the + // #4001 gap that was a hard 422 on `permission` and `position`. + const stamped = { ...DECLARATION, _packageId: 'com.acme.billing', _provenance: 'package' }; + expect(getMetadataTypeSchema('capability')!.safeParse(stamped).success).toBe(true); + }); + + it('rejects a payload that is not a capability at all', () => { + // The specimen the old behaviour stored verbatim into the authorization + // namespace: arbitrary JSON under a capability name. + const result = getMetadataTypeSchema('capability')!.safeParse({ + name: 'billing.refund', + sql: 'DROP TABLE sys_capability', + grantTo: ['*'], + }); + expect(result.success).toBe(false); + }); + + it('rejects a name that is not a resolvable capability key', () => { + // References resolve by exact string, so a name outside the key grammar + // could never be matched by a grant — it would sit in the registry + // unreachable. + expect(getMetadataTypeSchema('capability')!.safeParse({ name: 'Billing Refund' }).success) + .toBe(false); + }); + + it('names the surface and suggests the canonical key when an author mistypes one', () => { + // The difference between a silent strip and a fixable error — the whole + // point of closing the shape rather than merely registering it. + const result = getMetadataTypeSchema('capability')!.safeParse({ ...DECLARATION, title: 'Refunds' }); + expect(result.success).toBe(false); + const message = result.error!.issues.map((i) => i.message).join(' '); + expect(message).toContain('this capability declaration'); + expect(message).toContain('label'); + }); + + it('refuses the neighbouring concepts by name, not by edit distance', () => { + // ADR-0066's three-way separation, enforced at the point an author would + // collapse it: a capability never names its own holders or its own + // requirements. `guidance` answers these with the layer they belong to. + const result = getMetadataTypeSchema('capability')! + .safeParse({ ...DECLARATION, permissionSets: ['admin_full_access'] }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.message).join(' ')).toContain('systemPermissions'); + }); + + // ── the half of the ruling that is a refusal ────────────────────────── + + it('⛔ role / profile / policy are NOT admitted alongside it', () => { + // #5961's ruling is explicit that these are a different question and do + // not ride along: they have no `PLURAL_TO_SINGULAR` mapping, no + // declaration schema and no read-back seam, so there is nothing to make + // `declared = enforced` about. Pinned because "capability got an entry, so + // should its neighbours" is the obvious next edit, and it would be wrong. + for (const type of ['role', 'profile', 'policy']) { + expect(MetadataTypeSchema.safeParse(type).success, `${type} must not be a kind`).toBe(false); + expect(entry(type), `${type} must have no registry entry`).toBeUndefined(); + expect(getMetadataTypeSchema(type), `${type} must resolve no schema`).toBeUndefined(); + } + }); +}); diff --git a/packages/spec/src/kernel/metadata-create-seeds.test.ts b/packages/spec/src/kernel/metadata-create-seeds.test.ts index a3e93d4d23..183888f2d4 100644 --- a/packages/spec/src/kernel/metadata-create-seeds.test.ts +++ b/packages/spec/src/kernel/metadata-create-seeds.test.ts @@ -56,6 +56,11 @@ describe('metadata create seeds validate against their spec schemas', () => { 'report', // canvas-create: dataset/measures picked interactively 'app', 'field', 'seed', 'job', 'datasource', 'doc', 'book', 'permission', 'position', 'agent', 'tool', 'skill', 'email_template', + // [#5961] `capability` is code-only by declaration + // (`allowRuntimeCreate: false` + `allowOrgOverride: false`, ADR-0066 D1): + // there is no runtime create surface for a create seed to seed. It is on + // this list for `job`/`agent`'s reason, not as deferred work. + 'capability', ]); const seeded = new Set(listMetadataCreateSeedTypes()); const missing = listMetadataTypeSchemaTypes().filter((t) => !seeded.has(t) && !KNOWN_UNSEEDED.has(t)); diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index 3e0e69d541..cbd6528b36 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -150,6 +150,18 @@ export const MetadataTypeSchema = lazySchema(() => z.enum([ // Security Protocol 'permission', // Permission sets (PermissionSetSchema) 'position', // Positions — flat capability-distribution groups (ADR-0090 D3) + // [#5961] `capability` was ENFORCED BUT UNDECLARED — the same mirror of + // `declared ≠ enforced` that #5271 closed for `api`. `PLURAL_TO_SINGULAR` + // has mapped `capabilities` → `capability` since #5870, `AppPlugin` + // registers stack-declared capabilities under that exact name, and + // `bootstrapDeclaredCapabilities` reads them back — but the kind was absent + // from this enum, from `BUILTIN_METADATA_TYPE_SCHEMAS` and from + // `DEFAULT_METADATA_TYPE_REGISTRY`, so it resolved no schema and + // `PUT /api/v1/meta/capability/:name` stored ANY JSON on an authorization + // surface. ⚠️ `role` / `profile` / `policy` are a DIFFERENT question and are + // deliberately NOT admitted here: they have no `PLURAL_TO_SINGULAR` mapping, + // no declaration schema and no read-back seam — see #5961's ruling. + 'capability', // Package-declared authorization capabilities (CapabilityDeclarationSchema, ADR-0066 D1) // AI Protocol 'agent', // AI agent definitions (AgentSchema) @@ -807,6 +819,37 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntryParsed[] = // Security Protocol { type: 'permission', label: 'Permission Set', filePatterns: ['**/*.permission.ts', '**/*.permission.yml'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: true, executionPinned: false, loadOrder: 15, domain: 'security' }, { type: 'position', label: 'Position', filePatterns: ['**/*.position.ts', '**/*.position.yml'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 15, domain: 'security' }, + // [#5961] Package-declared authorization capabilities (ADR-0066 D1). + // + // ⛔ CODE-ONLY, and that is the whole point of the entry. ADR-0066 D1 says + // packages DEFINE capabilities — `defineCapability` on a stack's + // `capabilities[]`, or a `*.capability.ts` file the loader globs — while + // permission sets GRANT them and resources REQUIRE them. An administrator + // minting a brand-new capability at runtime has no counterpart in that + // three-way separation: nothing in code would ever require the name, so the + // row would be an unreferenced grant target sitting in the SAME namespace + // `systemPermissions` / `requiredPermissions` resolve by string. Hence + // `allowRuntimeCreate: false` AND `allowOrgOverride: false`, which together + // are #5086's code-only declaration — `saveMetaItem` refuses + // `PUT /api/v1/meta/capability/:name` with 403 `not_creatable` on EVERY + // kernel, and `codeOnlySourceHint` reads `filePatterns[0]` back to tell the + // author where to declare it instead. `job` (#4509) and `agent` (ADR-0063 §2) + // carry the same pair for the same reason. + // + // The package-declaration channel is untouched: `AppPlugin` registers stack + // `capabilities[]` through `registerInMemory`, and the filesystem loader + // globs `filePatterns` — neither goes through `saveMetaItem`, so + // `bootstrapDeclaredCapabilities` still seeds `sys_capability` exactly as + // before. `OS_METADATA_WRITABLE=capability` remains the ONE documented + // operator escape hatch (ADR-0005), and behind it the write is now judged by + // `CapabilityDeclarationSchema` (422) instead of being stored unvalidated. + // + // `supportsOverlay: false` — a capability is a name, label and scope; there + // is no merge semantic, and letting a tenant overlay a package-shipped + // declaration would let it re-scope `org` → `platform`. + // `loadOrder: 12` — before `permission`/`position` (15), so a set's + // `systemPermissions` resolves against capabilities that already exist. + { type: 'capability', label: 'Capability', description: 'Package-declared authorization capability — the DEFINITION side of ADR-0066 D1 (grants live on permission sets; requirements on resources)', filePatterns: ['**/*.capability.ts', '**/*.capability.yml'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: false, supportsVersioning: false, executionPinned: false, loadOrder: 12, domain: 'security' }, // AI Protocol // `agent`: executionPinned — long-running conversations must stick to the diff --git a/packages/spec/src/kernel/metadata-type-schemas.test.ts b/packages/spec/src/kernel/metadata-type-schemas.test.ts index a68f9e3fbc..6a02944923 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.test.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.test.ts @@ -359,7 +359,16 @@ describe('#4001 — registered-type closure is derived, not tallied', () => { // stored rows carrying `packageId` / `state`, so closing it breaks the // load-time backstop and the publish gate. Closed count is therefore // unchanged at 23 while the registered total moves to 25. - expect(closed.length).toBe(23); - expect(types.length).toBe(25); + // + // 25 → 26 on 2026-08-08: `capability` JOINED the registry (#5961), under + // the same template `api` used. Unlike `api` it lands CLOSED — + // `CapabilityDeclarationSchema` is only an AUTHORING surface, so + // `strict()` cost nothing: nothing re-parses a stored `sys_capability` row + // through it (`bootstrapDeclaredCapabilities` reads named fields off the + // body via `capabilityRowFields`), which is exactly the property `api` + // lacks. So the closed count moves with the total, 23 → 24, and + // `STILL_STRIP` does not grow. + expect(closed.length).toBe(24); + expect(types.length).toBe(26); }); }); diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index 73c067b0c3..2aba9f3408 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -60,6 +60,7 @@ import { DocSchema } from '../system/doc.zod'; import { BookSchema } from '../system/book.zod'; import { PermissionSetSchema } from '../security/permission.zod'; +import { CapabilityDeclarationSchema } from '../security/capabilities'; import { PositionSchema } from '../identity/position.zod'; import { AgentSchema } from '../ai/agent.zod'; @@ -140,6 +141,25 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = // Security Protocol permission: PermissionSetSchema, position: PositionSchema, // flat capability-distribution group (ADR-0090 D3) + // [#5961, the same shape #5271 closed for `api`] Package-declared + // authorization capabilities (ADR-0066 D1). The kind was produced and read + // back long before it resolved a schema: `PLURAL_TO_SINGULAR` maps + // `capabilities` → `capability` (#5870), `AppPlugin` registers stack-declared + // ones under that name, and `bootstrapDeclaredCapabilities` reads them back to + // seed `sys_capability`. Yet `getMetadataTypeSchema('capability')` answered + // `undefined`, so `saveMetaItem` took its documented "unregistered type → + // store without validation" branch and `PUT /meta/capability/:name` accepted + // ANY JSON — on an AUTHORIZATION surface, where `systemPermissions` / + // `requiredPermissions` resolve capabilities by NAME STRING, so an + // arbitrary-JSON row lands in the same namespace a permission set grants + // from. `/meta/types` compounded it by synthesising a fake descriptor + // (`allowRuntimeCreate: true`, no schema ⇒ a raw-JSON textarea form) for a + // kind the registry never declared. + // + // With this entry the existing 422 `invalid_metadata` path applies to + // `capability` like every other kind, and the registry entry next to it + // (`allowRuntimeCreate: false`) closes the runtime write door outright. + capability: CapabilityDeclarationSchema, // AI Protocol agent: AgentSchema, diff --git a/packages/spec/src/security/capabilities.ts b/packages/spec/src/security/capabilities.ts index 8ce78878a9..8ed760d664 100644 --- a/packages/spec/src/security/capabilities.ts +++ b/packages/spec/src/security/capabilities.ts @@ -2,6 +2,8 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; +import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { strictObject } from '../shared/strict-object'; /** * [ADR-0066 D1] Canonical platform capability registry. @@ -85,8 +87,56 @@ export const PLATFORM_CAPABILITY_NAMES: ReadonlySet = new Set( * here (the declaration side); permission sets GRANT one via `systemPermissions` * (the assignment side). There is no `inputs` shape — see ADR-0066's three-way * separation (capability / assignment / requirement). + * + * ## [#5961] This is also the REGISTERED schema for the `capability` metadata + * kind + * + * `capability` joined `MetadataTypeSchema` / `BUILTIN_METADATA_TYPE_SCHEMAS` / + * `DEFAULT_METADATA_TYPE_REGISTRY` under the template #5271 set for `api`, so + * this shape is what `PUT /api/v1/meta/capability/:name` is judged against and + * what `/meta/types` publishes as the kind's JSON Schema. Two consequences the + * declaration side did not have before: + * + * - **The ADR-0010 protection envelope is declared.** `MetadataPlugin`'s + * loader calls `applyProtection` on every registered type, so a stored + * capability carries `_packageId` / `_provenance` / `_lock*`. A registered + * schema that cannot represent them either 422s the loader's own output or + * drops it on round-trip (`metadata-type-schemas.test.ts` asserts both). + * - **The shape is `.strict()`.** Unlike `api` (which is on that suite's + * `STILL_STRIP` list because `ApiEndpointSchema` doubles as the parser for + * STORED rows carrying `packageId` / `state`), nothing re-parses a stored + * `sys_capability` row through this schema — `bootstrapDeclaredCapabilities` + * reads named fields off the body (`capabilityRowFields`). So closing it + * costs nothing and buys the declared = enforced posture an AUTHORIZATION + * surface should have had from the start. */ -export const CapabilityDeclarationSchema = lazySchema(() => z.object({ +export const CapabilityDeclarationSchema = lazySchema(() => strictObject({ + surface: 'this capability declaration', + // No `CAPABILITY_KEYS` array: `strictObject` derives the known-key list from + // the shape below, so there is no second copy to drift and no probe test + // needed to catch the drift (#5483, and the ratchet in + // `alias-integrity.test.ts` that keeps hand-transcribed tables shrinking). + aliases: { key: 'name', title: 'label' }, + guidance: { + permissionSets: + '`permissionSets` is not a capability field — a capability is only the ' + + 'DECLARATION (ADR-0066 D1). Permission sets GRANT it from their side via ' + + '`systemPermissions`, which is the assignment edge; a capability never ' + + 'names its own holders.', + requiredPermissions: + '`requiredPermissions` is not a capability field — that is the REQUIREMENT ' + + 'side, authored on the resource (action / view / api) that needs the ' + + 'capability. Declaring it here would invert ADR-0066\'s three-way separation.', + inputs: + '`inputs` is not a capability field — a capability is a name, not a ' + + 'contract (ADR-0066: capability / assignment / requirement are three ' + + 'separate shapes, and only the requirement side carries arguments).', + }, + history: + 'Until #5961 `capability` resolved no registered schema at all, so `PUT ' + + '/api/v1/meta/capability/:name` stored ANY JSON under an AUTHORIZATION type ' + + 'and stray keys on a declaration were dropped in silence.', +}, { /** * Stable capability key referenced by `systemPermissions` / `requiredPermissions`. * Lowercase, dot/underscore separable (e.g. `export_data`, `billing.refund`). @@ -110,6 +160,15 @@ export const CapabilityDeclarationSchema = lazySchema(() => z.object({ */ packageId: z.string().optional() .describe('[ADR-0086 D3] Owning package id (author-declared fallback; absent = registry-stamped)'), + + // ADR-0010 — runtime protection envelope (internal — set by the loader). + // + // [#5961] Declared when `capability` became a registered metadata kind: the + // artifact loader's `applyProtection` stamps these on EVERY registered type, + // and this schema is now strict, so without the spread the loader's own + // output fails to parse (a hard 422 on the write path). Same gap #4001 closed + // for `permission` and `position` — the two sibling security kinds. + ...MetadataProtectionFields, })); /** A validated package-level capability declaration (output of {@link defineCapability}). */ From e8ed1c2a471af2b10006974f7bcfdfcbf69617f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 04:09:32 +0000 Subject: [PATCH 2/3] test(example-showcase): KIND_COVERAGE gains the new capability kind The registry entry (#5961) made capability an owned kind here; the showcase has declared capabilities since ADR-0066, so it is demonstrated by src/security/capabilities.ts rather than waived. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- examples/app-showcase/src/coverage.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 2ac9382b7d..2f361069ec 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -151,6 +151,16 @@ export const KIND_COVERAGE: Record = { files: ['src/security/positions.ts'], notes: 'Flat positions only (no hierarchy — ADR-0090 D3); everyone/guest are built-in anchors, never declared.', }, + // [#5961] `capability` graduated into the registry (schema + entry, code-only: + // allowRuntimeCreate/allowOrgOverride both false — ADR-0066 D1). The showcase + // has declared capabilities since ADR-0066 landed; the registry entry only + // made the kind's coverage ownable here. + capability: { + status: 'demonstrated', + files: ['src/security/capabilities.ts'], + notes: + 'Package-declared authorization capabilities (`capabilities: allCapabilities` in objectstack.config.ts), seeded into sys_capability at publish. Code-only kind: runtime creation answers 403 not_creatable, which protocol.capability-write-door.test.ts pins.', + }, // ── ai ── agent: { From f50e6521c0ec5048dfe8ecf16e01984dc79a075d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 04:28:53 +0000 Subject: [PATCH 3/3] chore(platform-objects): regenerate metadata-forms bundles for the capability kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-i18n-bundles --write after MetadataTypeSchema gained 'capability': one new key per locale (label + description), filled with source text in merge mode — same shape as the api kind's entry from #5271, this PR's template. check:i18n and check:i18n-coverage both green. --- .../src/apps/translations/en.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/es-ES.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/ja-JP.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/zh-CN.metadata-forms.generated.ts | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 1a87516148..5fae90e350 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1487,6 +1487,10 @@ export const enMetadataForms: NonNullable = { } } }, + capability: { + label: "Capability", + description: "Package-declared authorization capability — the DEFINITION side of ADR-0066 D1 (grants live on permission sets; requirements on resources)" + }, agent: { label: "AI Agent", sections: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 56f3f4a10c..fc5739d8bc 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1487,6 +1487,10 @@ export const esESMetadataForms: NonNullable = } } }, + capability: { + label: "Capability", + description: "Package-declared authorization capability — the DEFINITION side of ADR-0066 D1 (grants live on permission sets; requirements on resources)" + }, agent: { label: "Agente de IA", sections: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 7e07217e6c..d63656b100 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1487,6 +1487,10 @@ export const jaJPMetadataForms: NonNullable = } } }, + capability: { + label: "Capability", + description: "Package-declared authorization capability — the DEFINITION side of ADR-0066 D1 (grants live on permission sets; requirements on resources)" + }, agent: { label: "AI エージェント", sections: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 796c36115e..744bd04c00 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1487,6 +1487,10 @@ export const zhCNMetadataForms: NonNullable = } } }, + capability: { + label: "Capability", + description: "Package-declared authorization capability — the DEFINITION side of ADR-0066 D1 (grants live on permission sets; requirements on resources)" + }, agent: { label: "AI 代理", sections: {