Skip to content

Commit 0c1829a

Browse files
feat(metadata-protocol,objectql): an object extension is an authorable item of a writable package, saved, published and exported through the existing doors (#28574) (#28811)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XT6ZDDQFYhmZ6kxDp4hSdwM)_ Fixes #28574 Clause-②: yes (widening) Metadata-protocol: added ## 维护者速读 - **改了什么**:对象扩展(`object_extension`,即「给别的包的对象加字段和规则」)成了可写包里的普通元数据项:通过现有的 `/api/v1/meta` 草稿、发布、历史回滚和删除口保存;发布后整个扩展体注册为该包的 `extend` 贡献者,字段出现在对象的每一种读取里,表也加上列;删除或卸载包时撤回;包导出把它带成 `objectExtensions`,可以再装回去。 - **为什么改**:Studio 在基础对象上「加字段」(N-23c)要写的就是这个:写进基础包配对的 `.custom` 包、字段带 `__c` 后缀、基础包不动(ADR-0146 D10)。以前这类数据只能由代码包的 manifest 带进来,运行时没有保存口。 - **风险与回滚**:只影响新类型 `object_extension` 的行;manifest 里的 `objectExtensions` 注册方式不变。顺带修正导出:一个包扩展了别人的对象时,导出不再把那个对象当成自己的对象输出(以前再安装会被所有权冲突拒绝)。回滚 = revert 本 PR。 - **先看**:① `packages/metadata-protocol/src/protocol-object-extension-item.ts`(保存/发布/回滚口的拒绝顺序与注册体);② `packages/metadata-protocol/src/protocol.ts` 里 `registerStoredObjectExtension` / `resyncStoredObjectExtensions` 和列表合并 `listSlotNameOf` 那几处;③ `packages/runtime/src/domains/packages.ts` 的导出循环(`objectExtensions` 一腿和 `objects` 只留自有对象的过滤)。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run src/domains/meta-object-extension-item.test.ts`,预期 19 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes - **The type** (`packages/spec`, the one hunk seat 1 ruled, shape A): `object_extension` joins `MetadataTypeSchema` and `DEFAULT_METADATA_TYPE_REGISTRY` (`allowRuntimeCreate: true`, `supportsVersioning: true`, no overlay, `domain: 'data'`, `filePatterns: ['**/*.extension.ts']`, the convention of the one code file that holds an extension, `examples/app-showcase/src/data/extensions/account.extension.ts`; the registry pin `metadata-plugin.test.ts` requires a pattern on every entry), plus the one classification row the `satisfies` (a record over every `MetadataType`) on `OVERRIDE_TYPE_CLASSIFICATION` compile-forces: `object_extension: { disposition: 'excluded', basis: 'adr_0146_d10_extended' }`. Regenerated: `meta-url-data.generated.ts` (`gen:meta-url-spelling`) and the two reference pages `check:generated` named (`gen:docs`). No other spec key, no new error code, `ObjectExtensionSchema` untouched. The route ledger, SDK and OpenAPI did not move. `examples/app-showcase/src/coverage.ts` gains the `KIND_COVERAGE` entry the closed kind roster requires (admitted by the same ruling). - **The gate** — `packages/metadata-protocol/src/protocol-object-extension-item.ts` (new), asked by the save door, the per-item publish door and the history restore (`protocol-authoring-gate.ts`), beside the `__c` judge it calls. `VALIDATION_ERROR` / 400 with an issue path, in this order: a write bound to no package; the registry's own `defaultRowScope` / `membershipKeys` refusals with their sentences (`extendContributionRefusal`, now one exported function in `objectql/src/registry.ts` that `registerObject` also calls); a row filed under a name other than its `extend`; an unregistered target; a target outside the written package's closure (`narrowObjectsToPackageClosure`, with `closureDependencyRemedy`'s prescription); the `__c` judge from #28564 (`judgeExtensionFieldSuffix`), unchanged. The body's shape is the doors' own schema gate (`resolveOverlaySchema` → `ObjectExtensionSchema`, strict, over the body with the ADR-0010 envelope peeled off): 422 `INVALID_METADATA`. - **Registration** — `registerStoredObjectExtension` registers the WHOLE parsed body (`ObjectExtensionSchema` output, `_provenance: 'org'`) through `SchemaRegistry.registerObjectExtension` → `registerObject(..., 'extend', priority)`. Called from the mutation sync (`applyRegistryWriteThrough`) and the stored-row hydration every boot / reload path shares (`hydrateOverlayIntoRegistry`). The manifest loop in `engine.ts` is not touched: what a manifest extension registers is unchanged. - **Withdrawal** — `restoreArtifactRegistryView` (the delete and uninstall heal) calls `resyncStoredObjectExtensions(target)`: it withdraws every stored-row (`_provenance: 'org'`) `extend` contributor of the target whose package has no active row left (`SchemaRegistry.withdrawObjectExtension`, new) and re-registers the rows that remain. Code-package contributors are never withdrawn. - **Storage** — `ensureObjectStorage` syncs the target's table on an `object_extension` publish, so a record can store the new field without a restart. - **List reads** — an `object_extension` body carries `extend` and no `name` (its schema refuses one), so `mergePackageAwareOverlay` slotted none of them and the list and per-package reads answered `[]`. `listSlotNameOf` slots such a body by its target. The body is served as stored: no `name` is added, so what GET answers PUTs back through the strict schema. - **Export** — `assemblePackageManifest` (`packages/runtime/src/domains/packages.ts`) reads the package's `object_extension` items (per-package layer) into `objectExtensions`. Measured on base `7a48bf73`: it did NOT carry them (the base-red export test). And a package's `objects` keep only the objects it owns: the registry lists an object under every contributing package, so an extending package exported the target as its own object, and re-install refused it with `ObjectOwnershipConflictError`. - **Reference index** — `reference-sites.ts` walks `object_extension` with `ObjectExtensionSchema` (the type is deliberately not in `BUILTIN_METADATA_TYPE_SCHEMAS`), which keeps the `unwalkableSourceTypes` pin at exactly `['external_catalog']` instead of widening it. - **Translations** (`packages/platform-objects`): the metadata-forms bundles are generated from the registry, so the type's `label` / `description` arrive in every locale (`check-i18n-bundles --write`). Authored in zh-CN (对象扩展), ja-JP (オブジェクト拡張) and es-ES (Extensión de Objeto) on arrival, as `queue` and `assignment_rule` were (`pnpm i18n:extract` then dropped the six provenance rows). The two translation ledgers that count the registry move by the new type: `bare-type-display-echo-decisions.test.ts` (31 → 32 types, 14 → 15 bare, 24 → 26 leaves) and the label positive control in `object-lifecycle-panel-echo-decisions.test.ts` (697 → 698), each with its one-line history entry. - **The `__c` judge's door list** (`protocol-extension-field-suffix.ts` header, prose only): names the `object_extension` gate and the adopt-orphans door #28768 added (seat 1's note from that PR's contract review). - `@objectstack/lint/runtime` re-exports `closureDependencyRemedy` (one line), so the gate prints the closure sentence the other save doors print instead of re-spelling it. - `.changeset/28574-object-extension-package-item.md`: minor on `spec`, `lint`, `objectql`, `metadata-protocol`, `runtime`, `platform-objects`. ## Tests `packages/runtime/src/domains/meta-object-extension-item.test.ts` (new, 19 tests) over a real better-sqlite3 `SqlDriver`, a real `ObjectQL` engine, the real protocol and `HttpDispatcher.handlePackages` for the export. Fixture: code base `com.acme.crm` owning `crm_account`, its pair `com.acme.crm.custom` installed through `installPackage`, and an unrelated `com.acme.other`. All readings at head `ccfa0893` (after merging `origin/main` with #28768 in), unless named. | Reading | Result | |---|---| | Card file at head | `Tests 19 passed (19)` (lock VERDICT command-exit 0) | | Card file on base `8afaf12d` (every production file of this diff restored to the merge base, the new test kept, five packages rebuilt) | `Tests 18 failed, 1 passed (19)`. On base the type does not exist, so every save answers `'object_extension' is not a metadata type`; the one green is the read-only-base control, refused on base too (400 for the unknown type). Restore: `git checkout HEAD -- .`, `git diff HEAD` empty, rebuilt. | The 19 tests: the field on the by-name, list, layered and registry reads with the pair as its only `extend` contributor and the row stored; a draft not live until publish; a record storing a value in the new column; the extension's `validations` reaching the object (the whole body is registered); a boot (`loadMetaFromDb`) re-registering the row; the pair's export carrying `objectExtensions` that parse; the base's export carrying none; that export re-installing into a fresh kernel through `registerApp`; delete and uninstall each removing the field from every read (each asserts it was on every read first); seven refusals, each `VALIDATION_ERROR` / 400 with its issue path where it has one, leaving no row and no contributor (outside the closure, unknown target, a `.custom` field without `__c`, `defaultRowScope`, `membershipKeys`, a package-less write, a name other than the target); the read-only base refused; and the `excluded` disposition not closing the type to the pair. <details><summary>Ablations (one per door call, <code>node scripts/ablation-replace.mjs</code> from committed HEAD, package rebuilt and <code>ablation-dist-preflight</code> marker found in dist before each run)</summary> - **The save / publish / restore gate.** `objectExtensionItemRefusal`'s type check rewritten to return `undefined` always (marker `ABL1`): blob `796dce22cb1c` → `14a8016fdac0`. `Tests 7 failed, 12 passed (19)`: exactly the seven refusals. Restored: blob equals HEAD, `git diff HEAD` empty. - **The `'extend'` registration.** `registerStoredObjectExtension`'s `registry.registerObjectExtension(...)` call guarded off (marker `ABL2`): blob `bf4d4c0f79a5` → `cc5b2b90db49`. `Tests 7 failed, 12 passed (19)`: the reads, draft-then-publish, record, validations, boot, delete and uninstall tests. Restored clean. - **The export leg.** `objectExtensions` dropped from the export loop (marker `ABL3`): blob `eec70a607e34` → `ffbfc47a15cc`. `Tests 2 failed, 17 passed (19)`: the export test and the re-install test. Restored clean; runtime rebuilt, `--absent` preflight green, card file 19/19 again. - A first export ablation run in the same chain read 9 red because metadata-protocol's dist still carried `ABL2` (the ablation tool restores source only); it was discarded and re-run alone on a rebuilt dist. The reading above is that second run. </details> <details><summary>Full suites and typecheck at head <code>ccfa0893</code> (each under the verify lock, VERDICT command-exit 0)</summary> | Suite | Result | |---|---| | `@objectstack/metadata-protocol` | 295 files passed, 4 skipped · 22069 tests passed, 26 skipped | | `@objectstack/objectql` (local) | 518 files · 9959 tests | | `@objectstack/runtime` (local) | 509 files · 8247 tests passed, 19 skipped | | `@objectstack/spec` | 875 files · 24715 tests, 1 todo | | `@objectstack/lint` | 189 files · 7814 tests | | `@objectstack/platform-objects` | 107 files · 1497 tests | | `registry-extension-system-columns.test.ts` + `registry-object-extension-nonenumerated-prop-discard.test.ts` | 2 files · 16 tests | | showcase `test/coverage.test.ts`; cli `generate-file-name-registry-parity.test.ts` | 13 / 43 tests | | `tsc --noEmit` spec, lint, objectql, metadata-protocol, runtime, showcase | exit 0 each (pre-final-merge head; `check:type-check-debt` re-measured at head: 0 errors) | | `eslint --no-inline-config` over the 11 changed TS files | exit 0 | </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at head: 117 families. All 117 exit 0; `--ran`: **117 derived, 117 run, 0 NOT-MEASURED, 0 UNRUN**. On the way: `check:i18n` red until the translation bundles were regenerated for the new registry row; `check:query-options-erasure` red on two `as any` casts in the new test, removed; `check:generated` / `check:docs` red after each merge until `gen:docs`; `check-plugin-teardown-shape --self-test` exit 3 on the shallow clone until its fixture commit was fetched. No gate was added. </details> ## Acceptance notes - **Two costs of the spec ruling, on purpose.** `/meta/types` serves no JSON schema for `object_extension`, and a draft carries no `_diagnostics` badge: the type is not bound in `BUILTIN_METADATA_TYPE_SCHEMAS`, because that map requires the ADR-0010 envelope, which would change the schema a manifest's `objectExtensions` parse with. - **`excluded` does not close the type to a pair.** `OVERRIDE_TYPE_CLASSIFICATION` lists `object_extension` as `excluded` (it never replaces a base item, so it never carries `overrides`); the pair's save is admitted regardless. Pinned by the last test. - **Withdrawal is a new registry verb, not `subtractExtenderContributions`.** The order and card name `subtractExtenderContributions` for delete. Measured, that is the fold's idempotency helper (it removes from a base the `indexes` an extender is about to re-add, `registry.ts`); it removes no contributor. `withdrawObjectExtension` splices the package's `extend` contributor and drops the merged-object cache entry. - **The round trip goes through `registerApp`, the path the export is assembled for.** `assemblePackageManifest`'s own docblock names it: `marketplace-install-local` → `manifestService.register()` → `engine.registerApp()`. `installPackage` (`POST /api/v1/packages`) parses `PackageInstallManifestSchema`, which refuses every collection key but `objects` (measured on spec dist at head: `views`, `flows` and `objectExtensions` all answer "Unrecognized key(s)"), so no export carrying views or flows was ever an `installPackage` input. No second code path was written. - **The export fix to `objects`** is a defect this card makes reachable (an extending package's export), fixed where the extension leg is added. - **Outside the claimed file surface:** `packages/lint/src/runtime.ts` (one re-export line), `packages/metadata-protocol/src/reference-sites.ts` (the walk above), the `protocol-extension-field-suffix.ts` header (prose), the regenerated reference pages and translation bundles, and the two translation-ledger count pins. Each is forced by the registry row or asked by seat 1. - **Package-less rows and adopt-orphans.** A package-less `object_extension` save is refused by the gate's first step (pinned). Whether the adopt-orphans door should judge `object_extension` rows it rebinds is left open in the report, not widened here. - **Open question for N-23c (#28575):** whether Studio's add-field needs the type bound in `BUILTIN_METADATA_TYPE_SCHEMAS` (JSON schema and `_diagnostics`), which needs a ruling on the envelope. Recorded in the report. - Serial: #28728 (#28565), #28693 (#28412), #28770 (#28717) and #28768 (#28735) landed while this was in flight. `origin/main` was merged three times (after #28728 / #28693, after main moved ten more commits, and after #28768), each with no conflict, and every reading in this body was retaken after the last one. `registerObjectExtension` hands the whole body, so `fieldPatches` reaches #28565's apply unchanged. ## Verification Live probe on the stock showcase at head `ad2bfc9c` (this diff merged with `origin/main` once; the later commits change only tests, translations, prose and the file pattern), `pnpm dev -- --fresh -p 3917` in `examples/app-showcase`, signed in as the seeded admin (`POST /api/v1/auth/sign-in/email` with `Origin: http://localhost:3917`, cookie jar). `B=http://localhost:3917/api/v1`. ```text POST $B/packages/com.example.showcase/ensure-customization 201 {"packageId":"com.example.showcase.custom","namespace":"showcase_custom","created":true,"writable":true} PUT $B/meta/object_extension/showcase_account?package=com.example.showcase.custom&mode=draft {"extend":"showcase_account","fields":{"region__c":{"type":"text","label":"Region"}}} 200 Saved object_extension 'showcase_account' (env-wide, package=com.example.showcase.custom, state=draft) [seq=1] POST $B/meta/object_extension/showcase_account/publish?package=com.example.showcase.custom 200 Published draft — type=object_extension, name=showcase_account [seq=2] GET $B/meta/object/showcase_account 200 item.fields.region__c = {"label":"Region","type":"text",...} (item._packageId com.example.showcase) GET $B/meta/object/showcase_account/layers 200 code.fields.region__c present, effective.fields.region__c present GET $B/packages/com.example.showcase/customizations 200 {"extensions":[{"object":"showcase_account","kind":"field","name":"region__c"}],"counts":{"extensions":1,...}} (the pair's 'extend' contributor, read off the registry) GET $B/meta/object_extension?package=com.example.showcase.custom 200 {"items":[{"extend":"showcase_account","fields":{"region__c":{...}},"_packageId":"com.example.showcase.custom"}]} GET $B/packages/com.example.showcase.custom/export 200 objectExtensions: [{"extend":"showcase_account","fields":{"region__c":{"type":"text","label":"Region"}}}], objects: [] POST $B/data/showcase_account {"name":"Probe Co","region__c":"EMEA"} 201 record.region__c = "EMEA" PUT $B/meta/object_extension/showcase_account?package=com.example.showcase.custom&mode=draft {"extend":"showcase_account","fields":{"region":{"type":"text"}}} 400 VALIDATION_ERROR, issues[0].path "fields.region": name it `region__c`, or change a base field's display properties in the extension's patch (`fieldPatches`). DELETE $B/meta/object_extension/showcase_account?package=com.example.showcase.custom 200 Deleted object_extension 'showcase_account' — it no longer exists. [seq=3] GET $B/meta/object/showcase_account → region__c absent GET $B/packages/com.example.showcase.custom/export → no objectExtensions ``` Two-minute check: `pnpm --filter @objectstack/runtime exec vitest run src/domains/meta-object-extension-item.test.ts`, 19/19. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kkSYvKhs52WGfTj3c)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1920e48 commit 0c1829a

22 files changed

Lines changed: 1082 additions & 34 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
"@objectstack/objectql": minor
5+
"@objectstack/metadata-protocol": minor
6+
"@objectstack/runtime": minor
7+
"@objectstack/platform-objects": minor
8+
---
9+
10+
feat(metadata-protocol,objectql): an object extension is an item of a writable package — saved, published and exported through the existing doors
11+
12+
Clause-②: yes (widening)
13+
Metadata-protocol: added
14+
15+
A package can now add fields and rules to an object another package defines by saving an `object_extension` item through `/api/v1/meta`, the same way it saves any other item. This is what Studio's add-field on a base object writes (ADR-0146 D10): an extension in the base's paired `<base>.custom` package, its fields suffixed `__c`, the base untouched.
16+
17+
- **The type** (`@objectstack/spec/kernel`). `object_extension` joins `MetadataTypeSchema` and the metadata type registry. Its body is `ObjectExtensionSchema` as it stands: it carries `extend` and no `name`. The row is filed under the target object's name, one extension per package per target. `OVERRIDE_TYPE_CLASSIFICATION` lists it as `excluded`: an extension adds to its target, it never replaces a base item, so it never carries `overrides`. That disposition does not close the type to a `.custom` pair.
18+
- **The doors.** Draft, publish, history restore and delete are the doors every item uses. The save, publish and restore doors refuse, with `VALIDATION_ERROR` / 400 and an issue path: a write bound to no package; `defaultRowScope` or `membershipKeys` in the body (the registry's own sentences); a row filed under a name other than its `extend`; an unregistered target; a target outside the package's dependency closure (with the closure prescription the other save doors print); and a `.custom` field without the `__c` suffix (the existing `__c` judge). A body the schema refuses answers 422 `INVALID_METADATA` with the schema's issues. No error code is added.
19+
- **The registry** (`@objectstack/objectql`). A published row registers its whole parsed body as the package's `'extend'` contributor (`SchemaRegistry.registerObjectExtension`), so its fields, validations and indexes appear on every read of the object, and a publish adds the column to the target's table. Every stored-row sync re-registers it (boot, write-through, read hydration), and deleting the row or uninstalling the package withdraws it (`SchemaRegistry.withdrawObjectExtension`). What a manifest's `objectExtensions` register is unchanged.
20+
- **The export** (`@objectstack/runtime`). `GET /packages/:id/export` carries each of the package's extension items as an `objectExtensions` entry that re-installs. A package's `objects` are now only the objects it owns: an object the package extends is no longer exported as an `'own'` object, which re-install refused with `ObjectOwnershipConflictError`.
21+
- **`@objectstack/platform-objects`**: the metadata-forms translation bundles gain the type's `label` and `description`, authored in zh-CN, ja-JP and es-ES.
22+
- **`@objectstack/lint/runtime`** exports `closureDependencyRemedy`, the closure prescription the save doors print.
23+
24+
Two costs, both on purpose: `/meta/types` serves no JSON schema for `object_extension`, and a draft carries no `_diagnostics` badge, because the type is not bound in `BUILTIN_METADATA_TYPE_SCHEMAS` (that map requires the ADR-0010 envelope, which would change the schema a manifest's `objectExtensions` parse with).

‎content/docs/references/api/metadata.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -642,7 +642,7 @@ Metadata query with filtering, sorting, and pagination
642642

643643
| Property | Type | Required | Description |
644644
| :--- | :--- | :--- | :--- |
645-
| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'queue' \| 'assignment_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
645+
| **types** | `Enum<'object' \| 'object_extension' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'queue' \| 'assignment_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
646646
| **namespaces** | `string[]` | optional | Filter by namespaces |
647647
| **packageId** | `string` | optional | Filter by owning package |
648648
| **search** | `string` | optional | Full-text search query |
@@ -709,14 +709,15 @@ Metadata query with filtering, sorting, and pagination
709709

710710
| Property | Type | Required | Description |
711711
| :--- | :--- | :--- | :--- |
712-
| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +16 more>` | ✅ | Metadata type |
712+
| **type** | `Enum<'object' \| 'object_extension' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| … +18 more>` | ✅ | Metadata type |
713713
| **name** | `string` | ✅ | Item name (snake_case) |
714714
| **data** | `Record<string, any>` | ✅ | Metadata payload |
715715
| **namespace** | `string` | optional | Optional namespace |
716716

717717
### Allowed Values: `MetadataRegisterRequest.type`
718718

719719
* `object`
720+
* `object_extension`
720721
* `field`
721722
* `hook`
722723
* `seed`

‎content/docs/references/kernel/metadata-plugin.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ const result = MetadataBulkResultSchema.parse(data);
179179

180180
| Property | Type | Required | Description |
181181
| :--- | :--- | :--- | :--- |
182-
| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'queue' \| 'assignment_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
182+
| **types** | `Enum<'object' \| 'object_extension' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'queue' \| 'assignment_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
183183
| **namespaces** | `string[]` | optional | Filter by namespaces |
184184
| **packageId** | `string` | optional | Filter by owning package |
185185
| **search** | `string` | optional | Full-text search query |
@@ -226,6 +226,7 @@ const result = MetadataBulkResultSchema.parse(data);
226226
### Allowed Values
227227

228228
* `object`
229+
* `object_extension`
229230
* `field`
230231
* `hook`
231232
* `seed`
@@ -266,7 +267,7 @@ const result = MetadataBulkResultSchema.parse(data);
266267

267268
| Property | Type | Required | Description |
268269
| :--- | :--- | :--- | :--- |
269-
| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +16 more>` | ✅ | Metadata type identifier |
270+
| **type** | `Enum<'object' \| 'object_extension' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| … +18 more>` | ✅ | Metadata type identifier |
270271
| **label** | `string` | ✅ | Display label for the metadata type |
271272
| **description** | `string` | optional | Description of the metadata type |
272273
| **filePatterns** | `string[]` | ✅ | Glob patterns to discover files of this type |
@@ -283,6 +284,7 @@ const result = MetadataBulkResultSchema.parse(data);
283284
### Allowed Values: `MetadataTypeRegistryEntry.type`
284285

285286
* `object`
287+
* `object_extension`
286288
* `field`
287289
* `hook`
288290
* `seed`

‎examples/app-showcase/src/coverage.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
5757
notes:
5858
'Also carries the validation-rule surface: rules are authored inline via object `validations` (the `validation` KIND was retired in #4509 — ADR-0088 — because a standalone rule had no way to name the object it validated). Every declared rule type is write-path enforced (rule-validator dispatches all of state_machine/script/cross_field/format/json_schema/conditional — ADR-0020 "no silent no-ops", closing the #1475 gap) and each is demonstrated: state_machine (task/project), script+cross_field (project), format/json_schema/conditional (account). Field-level requiredWhen/readonlyWhen are likewise enforced and demonstrated on invoice.',
5959
},
60+
// [#28574] A registry kind since an extension became an authorable item of a
61+
// writable package. The showcase's own extension is the code half (the stack's
62+
// `objectExtensions`, also tracked in STACK_COLLECTION_COVERAGE); the runtime
63+
// half is `PUT /api/v1/meta/object_extension/<target>?package=<pkg>`.
64+
object_extension: {
65+
status: 'demonstrated',
66+
files: ['src/data/extensions/account.extension.ts'],
67+
notes: 'Authored in code as an `objectExtensions` entry; the same body is an `object_extension` item when a writable package saves it through `/api/v1/meta`.',
68+
},
6069
field: {
6170
status: 'demonstrated',
6271
files: ['src/data/objects/field-zoo.object.ts'],

‎packages/lint/src/runtime.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ export {
7070
runtimeGatedTypes,
7171
stackKeyForType,
7272
} from './runtime-gate.js';
73+
// [#28574] The closure prescription the save doors print, for a door outside
74+
// this package that judges an object name under a package closure (the
75+
// `object_extension` item gate in `@objectstack/metadata-protocol`), so the
76+
// sentence is not spelled a second time.
77+
export { closureDependencyRemedy } from './object-graph.js';
7378
export type {
7479
RuntimeGateResult,
7580
RuntimePackageScope,

‎packages/metadata-protocol/src/protocol-authoring-gate.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export interface AuthoringGateHost {
7979
objectNamespacePrefixViolation: ObjectStackProtocolImplementation['objectNamespacePrefixViolation'];
8080
hierarchyResolverMounted: ObjectStackProtocolImplementation['hierarchyResolverMounted'];
8181
isArtifactBacked: ObjectStackProtocolImplementation['isArtifactBacked'];
82+
objectExtensionItemRefusal: ObjectStackProtocolImplementation['objectExtensionItemRefusal'];
8283
orgWallEnforced: ObjectStackProtocolImplementation['orgWallEnforced'];
8384
permissionSetNameCollisionRefusal: ObjectStackProtocolImplementation['permissionSetNameCollisionRefusal'];
8485
refuseRestoredBodyTheSaveDoorRefuses: ObjectStackProtocolImplementation['refuseRestoredBodyTheSaveDoorRefuses'];
@@ -101,7 +102,7 @@ export function restoredBodyDerivation(
101102

102103
/** The body of `ObjectStackProtocolImplementation#restoredBodyWriter`; the method's docblock there is its contract. */
103104
export function restoredBodyWriter(
104-
this: Pick<AuthoringGateHost, 'refuseRestoredBodyTheSaveDoorRefuses' | 'restoredBodyDerivation'>,
105+
this: Pick<AuthoringGateHost, 'objectExtensionItemRefusal' | 'refuseRestoredBodyTheSaveDoorRefuses' | 'restoredBodyDerivation'>,
105106
type: string, name: string, binding: string | null, source: string): (historyBody: unknown) => Promise<unknown> {
106107
const derive = this.restoredBodyDerivation(type);
107108
return async (historyBody) => {
@@ -112,6 +113,9 @@ export function restoredBodyWriter(
112113
// [ADR-0146 D10, #28564] The `__c` reservation, on the same binding.
113114
const suffixRefusal = objectBodyExtensionFieldSuffixRefusal(type, historyBody, name, binding);
114115
if (suffixRefusal) throw suffixRefusal;
116+
// [#28574] An `object_extension` version against its address, on the same binding.
117+
const extensionRefusal = this.objectExtensionItemRefusal(type, historyBody, name, binding);
118+
if (extensionRefusal) throw extensionRefusal;
115119
await this.refuseRestoredBodyTheSaveDoorRefuses(type, name, historyBody, binding, source);
116120
return derive ? derive(historyBody) : historyBody;
117121
};

‎packages/metadata-protocol/src/protocol-extension-field-suffix.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,14 @@
1313
* protocol` (`saveMetaItem`, `rollbackMetaItem`, and the promotion
1414
* `publishMetaItem` / `publishPackageDrafts` share), for an `object` body,
1515
* through {@link objectBodyExtensionFieldSuffixRefusal};
16+
* - the same three doors for an `object_extension` item, as the package's one
17+
* `objectExtensions` entry, through {@link judgeExtensionFieldSuffix}
18+
* (`objectExtensionItemRefusal`, `protocol-object-extension-item.ts`);
1619
* - `duplicatePackage`, for every object the copy would write, judged against
1720
* the TARGET id before the copy mints anything;
21+
* - `reassignOrphanedMetadata` (the adopt-orphans door), for every active
22+
* `object` row the adoption would rebind, judged against the TARGET id
23+
* before the first storage update;
1824
* - `os validate` (`@objectstack/cli`) and the marketplace install
1925
* (`@objectstack/cloud-connection`), for a whole package body, through
2026
* {@link judgeExtensionFieldSuffix}.

0 commit comments

Comments
 (0)