diff --git a/.changeset/28404-override-declaration.md b/.changeset/28404-override-declaration.md new file mode 100644 index 00000000000..f0899fef5b9 --- /dev/null +++ b/.changeset/28404-override-declaration.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): one override declaration for code and Studio — `overrides: { package, basedOn }`, the `.custom` pairing helpers, the closed overridable-type list, the `__c` reservation and the base-field display whitelist (ADR-0146) + +Clause-②: yes (widening) +Metadata-protocol: added + +A customer's change to an installed package lives in its paired `.custom` package (ADR-0146). This release declares the shared definitions every later door reads, so a developer's `hotcrm.custom` in a repository and an administrator's Studio customization write the same thing: + +- **The override declaration.** `view`, `dashboard`, `report`, `translation`, `email_template`, `page`, `app`, `dataset`, `doc`, `book`, `mapping`, `flow` and `action` items accept an optional `overrides: { package, basedOn }`: this item replaces the base package's same-named item whole. `package` names the base (never a `.custom` id); `basedOn` is recorded and never judged. The key survives `defineStack()`, the metadata save and the package export. No door acts on it yet: the write gate and the effective read are separate changes. +- **The pair's identity** (`@objectstack/spec/kernel`): `CUSTOMIZATION_PACKAGE_SUFFIX` (`.custom`), `customizationPackageId`, `customizationBasePackageId`, `isCustomizationPackage`, `isCustomizationPairOf`, `customizationNamespace` (`_custom`; a base namespace longer than 13 characters gets none, with the named reason `base_namespace_too_long`, never a truncation) and `customizationDependencyRange` (`^` of the base version). Each derivation answers `{ ok: true, value }` or `{ ok: false, reason }`. +- **The overridable-type list.** `OVERRIDE_TYPE_CLASSIFICATION` classifies every metadata type as `overridable`, `extend_only` (`object`) or `excluded` (`permission`, `position`, `capability`, the code-only types and others), with `flow`'s and `action`'s preconditions carried as data. `OVERRIDABLE_METADATA_TYPES` and `isOverridableMetadataType` read it. `seed`, `datasource`, `tool` and `skill` are `excluded` pending a ruling. +- **Customer extension fields** (`@objectstack/spec/data`): `EXTENSION_FIELD_SUFFIX` (`__c`), `isExtensionFieldName`, and `BASE_FIELD_DISPLAY_WHITELIST`: the base-field properties an extension may change (`label`, `description`, `inlineHelpText`, `placeholder`, and an option's `label`). Enforcing both is a separate change. diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 47b869c1952..e584efbe99d 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -129,6 +129,7 @@ const result = AutomationApiErrorCode.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Nested Shape: `CreateFlowRequest.variables[number]` @@ -186,6 +187,13 @@ const result = AutomationApiErrorCode.parse(data); | **reason** | `string` | ✅ | User-visible reason shown when the lock blocks an action. | | **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | +### Nested Shape: `CreateFlowRequest.overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + --- @@ -249,6 +257,7 @@ const result = AutomationApiErrorCode.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | --- @@ -789,6 +798,7 @@ const result = AutomationApiErrorCode.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | --- @@ -1098,6 +1108,7 @@ const result = AutomationApiErrorCode.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | --- @@ -1162,6 +1173,7 @@ const result = AutomationApiErrorCode.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | --- diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 58fd6fc0a0d..0270278747e 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -106,6 +106,7 @@ const result = AppDefinitionResponseSchema.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | --- diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index 16278f56b12..7cfead5157c 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -50,6 +50,7 @@ const result = FlowSchema.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Nested Shape: `Flow.variables[number]` @@ -107,6 +108,13 @@ const result = FlowSchema.parse(data); | **reason** | `string` | ✅ | User-visible reason shown when the lock blocks an action. | | **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | +### Nested Shape: `Flow.overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + --- @@ -264,6 +272,7 @@ const result = FlowSchema.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | --- diff --git a/content/docs/references/data/mapping.mdx b/content/docs/references/data/mapping.mdx index bad43bad443..43d2c250610 100644 --- a/content/docs/references/data/mapping.mdx +++ b/content/docs/references/data/mapping.mdx @@ -57,6 +57,7 @@ const result = ImportFieldMappingSchema.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Nested Shape: `Mapping.fieldMapping[number]` @@ -77,6 +78,13 @@ const result = ImportFieldMappingSchema.parse(data); | **recordsPath** | `string` | optional | Dot path, within the action's result, to the array of records (e.g. `body.results`); omitted ⇒ the result's `body` is the array | | **watermark** | `{ field: string; param: string }` | optional | Timestamp-incremental pull; omitted ⇒ every pull reads the full set. Either way a pull makes ONE action call and reads ONE response: the connector's paging is not followed, so a paged endpoint yields its first page only — and an incremental pull over a newest-first paged endpoint moves its starting point past the pages it never read | +### Nested Shape: `Mapping.overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + --- diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 4d3ed55bf4c..3ecda159c59 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -525,6 +525,7 @@ const result = ApiMethod.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Nested Shape: `Object.protection` diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index dfa8fc1b12b..8c54d748ad9 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1577 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1578 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -26,7 +26,7 @@ counts are sums of the rows they head. Regenerate with | [Data Protocol](/docs/references/data) | 31 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 23 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | -| [Kernel Protocol](/docs/references/kernel) | 29 | 151 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | +| [Kernel Protocol](/docs/references/kernel) | 30 | 152 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [Marketplace Protocol](/docs/references/marketplace) | 4 | 30 | The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | | [Security Protocol](/docs/references/security) | 6 | 37 | Permission sets, row-level security, sharing rules, tenancy posture. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 175 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **204** | **1577** | 14 protocol modules | +| **Total** | **205** | **1578** | 14 protocol modules | --- @@ -204,7 +204,7 @@ The single connector protocol (ADR-0097) — catalog descriptors and provider-bo ## Kernel Protocol -**Source:** `packages/spec/src/kernel/` · **Import:** `@objectstack/spec/kernel` · **29 pages, 151 schemas** +**Source:** `packages/spec/src/kernel/` · **Import:** `@objectstack/spec/kernel` · **30 pages, 152 schemas** Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. @@ -225,6 +225,7 @@ Plugin lifecycle and manifests, capabilities and security, metadata loading, ser | [`metadata-loader.zod.ts`](/docs/references/kernel/metadata-loader) | `MetadataFallbackStrategy`, `MetadataManagerConfig` | | [`metadata-plugin.zod.ts`](/docs/references/kernel/metadata-plugin) | `MetadataBulkResult`, `MetadataDependency`, `MetadataPluginConfig`, `MetadataPluginManifest`, `MetadataQuery`, `MetadataQueryResult`, `MetadataType`, `MetadataTypeRegistryEntry`, `MetadataValidationResult` | | [`metadata-protection.zod.ts`](/docs/references/kernel/metadata-protection) | `MetadataLock`, `MetadataLockSource`, `MetadataProvenance` | +| [`override-declaration.zod.ts`](/docs/references/kernel/override-declaration) | `OverrideDeclaration` | | [`package-artifact.zod.ts`](/docs/references/kernel/package-artifact) | `ArtifactChecksum`, `ArtifactFileEntry`, `ArtifactSignature`, `MetadataCategoryEnum`, `PackageArtifact` | | [`package-registry.zod.ts`](/docs/references/kernel/package-registry) | `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `GetPackageRequest`, `GetPackageResponse`, `InstallPackageRequest`, `InstallPackageResponse`, `InstalledPackage`, `InstalledPackageSource`, `ListPackagesRequest`, `ListPackagesResponse`, `NamespaceConflictError`, `NamespaceRegistryEntry`, `PackageStatusEnum`, `UninstallPackageRequest`, `UninstallPackageResponse` | | [`plugin.zod.ts`](/docs/references/kernel/plugin) | `Plugin` | diff --git a/content/docs/references/kernel/index.mdx b/content/docs/references/kernel/index.mdx index 4c1c3e77c5f..89bb5be364f 100644 --- a/content/docs/references/kernel/index.mdx +++ b/content/docs/references/kernel/index.mdx @@ -1,7 +1,7 @@ --- title: Kernel Protocol — complete schema reference navTitle: Kernel Protocol -description: "The ObjectStack Kernel Protocol in 29 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." +description: "The ObjectStack Kernel Protocol in 30 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -24,6 +24,7 @@ This section contains all protocol schemas for the kernel layer of ObjectStack. + diff --git a/content/docs/references/kernel/meta.json b/content/docs/references/kernel/meta.json index 5574501de14..ce258d0d460 100644 --- a/content/docs/references/kernel/meta.json +++ b/content/docs/references/kernel/meta.json @@ -34,6 +34,7 @@ "events-dlq", "events-handlers", "events-integrations", - "events-queue" + "events-queue", + "override-declaration" ] } \ No newline at end of file diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 7400a32fd9c..e66d3011f81 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -368,6 +368,7 @@ const result = MetadataBulkResultSchema.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | --- diff --git a/content/docs/references/kernel/override-declaration.mdx b/content/docs/references/kernel/override-declaration.mdx new file mode 100644 index 00000000000..b4141b8d6b5 --- /dev/null +++ b/content/docs/references/kernel/override-declaration.mdx @@ -0,0 +1,75 @@ +--- +title: Override Declaration — Kernel Protocol +navTitle: Override Declaration +description: "A customization of an installed base package (HotCRM) is an item of the base's paired writable package .custom." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +## The override declaration (ADR-0146 D2) + +A customization of an installed base package (HotCRM) is an item of the +base's paired writable package `.custom` (ADR-0146 D3). An item of that +package that bears a base item's name and carries + +```ts +overrides: { package: '', basedOn: '' } +``` + +replaces the base's item WHOLE for every read that serves the base's name +(ADR-0146 D8). The declaration lives in the item body, so it travels with the +item through export and git: what a developer writes into `hotcrm.custom` in a +repository and what an administrator does in Studio produce the same +document. Only the paired `.custom` package declares an override (the +ruling recorded on #28390, ruling A); the pairing helpers in +`customization-package.ts` are what a door asks. + +### Why a leaf module + +It imports nothing but zod and `strictObject`, on purpose. Every overridable +type's schema spreads `OverrideDeclarationFields`, and some of those +schemas are imported by `manifest.zod.ts` (`ui/app.zod.ts`); importing the +manifest or the registry from here would close a module cycle that the eager +schema build (`OS_EAGER_SCHEMAS=1`) evaluates in temporal-dead-zone order. +The helpers that need the manifest grammar live in `customization-package.ts`, +which no schema imports. + +### What this module does NOT do + +It declares the shape. Admitting a declared override, refusing an undeclared +same-named item, refusing a declaration from a package that is not the pair, +and serving the declared document are the doors' (ADR-0146 Rollout, P1: +the write gate, the effective read, the ensure door). Which types may carry +the declaration is the closed list `OVERRIDE_TYPE_CLASSIFICATION`, never a +judgement a door makes. + + +**Source:** `packages/spec/src/kernel/override-declaration.zod.ts` + + +## TypeScript Usage + +```typescript +import { OverrideDeclarationSchema } from '@objectstack/spec/kernel'; +import type { OverrideDeclaration } from '@objectstack/spec/kernel'; + +// Validate data +const result = OverrideDeclarationSchema.parse(data); +``` + +--- + +## OverrideDeclaration + +Override declaration (ADR-0146 D2): this item, of the base's paired "``.custom" package and bearing a base item's name, replaces that base item whole for every read that serves the name. + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + + +--- + diff --git a/content/docs/references/system/book.mdx b/content/docs/references/system/book.mdx index 0cab1ce8a51..7717355315d 100644 --- a/content/docs/references/system/book.mdx +++ b/content/docs/references/system/book.mdx @@ -61,6 +61,7 @@ const result = BookSchema.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Nested Shape: `Book.groups[number]` @@ -74,6 +75,13 @@ const result = BookSchema.parse(data); | **package** | `string` | optional | Scope the rule to a package id (default: the book package; cross-package via ADR-0048) | | **pages** | `(string \| { doc?: string; href?: string; label?: string; badge?: string; … })[]` | optional | OPTIONAL explicit override — hand-pin a curated order; wins over `include` | +### Nested Shape: `Book.overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + --- diff --git a/content/docs/references/system/doc.mdx b/content/docs/references/system/doc.mdx index 5410e3ae27d..d4e125bc65b 100644 --- a/content/docs/references/system/doc.mdx +++ b/content/docs/references/system/doc.mdx @@ -64,6 +64,14 @@ const result = DocSchema.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | + +### Nested Shape: `Doc.overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | --- diff --git a/content/docs/references/system/email-template.mdx b/content/docs/references/system/email-template.mdx index 9ee7f1983c2..530e3527807 100644 --- a/content/docs/references/system/email-template.mdx +++ b/content/docs/references/system/email-template.mdx @@ -66,6 +66,7 @@ const result = EmailTemplateDefinitionSchema.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Nested Shape: `EmailTemplateDefinition.variables[number]` @@ -84,6 +85,13 @@ const result = EmailTemplateDefinitionSchema.parse(data); | **reason** | `string` | ✅ | User-visible reason shown when the lock blocks an action. | | **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | +### Nested Shape: `EmailTemplateDefinition.overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + --- diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index cd6a46c704e..b18e87f2c45 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -810,6 +810,7 @@ One locale of translations — the `translation` metadata type | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Nested Shape: `TranslationItem.objects[string]` @@ -947,6 +948,13 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **sourceLabels** | `{ env?: string; global?: string; tenant?: string; user?: string; … }` | optional | Source badge labels by resolution layer | +### Nested Shape: `TranslationItem.overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + --- diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 20769dea8e6..db5222ff211 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -78,6 +78,7 @@ const result = ActionSchema.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Allowed Values: `Action.requiresFeature` @@ -181,6 +182,13 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`; on a type:'flow' action the completed run's answer, whose `output` carries the flow's isOutput variables, e.g. $`{result.output.renewal.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. An in-app path is a console route, written without the console's mount prefix; a record is /apps/APP/OBJECT/record/ID, where APP is the app's route segment (its package id; its name also resolves). No app-relative form is declared and no $`{ctx.*}` member names the current app, so write the app segment. Renderers MUST encodeURIComponent values in query positions. | | **openIn** | `Enum<'self' \| 'newTab'>` | optional (default: `"self"`) | Where to perform the post-success navigation: 'self' (default — in-place SPA navigation, immune to popup blocking) or 'newTab'. Closed enum — no general navigation DSL. | +### Nested Shape: `Action.overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + --- diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index 49a07b2f484..31eca3fb74a 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -89,6 +89,7 @@ const result = ActionNavItemSchema.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Nested Shape: `App.branding` @@ -318,6 +319,13 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | **reason** | `string` | ✅ | User-visible reason shown when the lock blocks an action. | | **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | +### Nested Shape: `App.overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + --- diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 22d19cafba6..596513583d6 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -48,6 +48,7 @@ const result = DashboardSchema.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Nested Shape: `Dashboard.header` @@ -110,6 +111,13 @@ const result = DashboardSchema.parse(data); | **reason** | `string` | ✅ | User-visible reason shown when the lock blocks an action. | | **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | +### Nested Shape: `Dashboard.overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + --- diff --git a/content/docs/references/ui/dataset.mdx b/content/docs/references/ui/dataset.mdx index 5a9902358ab..3035ae51e23 100644 --- a/content/docs/references/ui/dataset.mdx +++ b/content/docs/references/ui/dataset.mdx @@ -65,6 +65,7 @@ const result = DatasetSchema.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Nested Shape: `Dataset.dimensions[number]` @@ -100,6 +101,13 @@ const result = DatasetSchema.parse(data); | **reason** | `string` | ✅ | User-visible reason shown when the lock blocks an action. | | **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | +### Nested Shape: `Dataset.overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + --- diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index b2b0a8d937f..fd6124f2cb0 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -242,6 +242,7 @@ View filter rule | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Nested Shape: `Page.variables[number]` @@ -322,6 +323,13 @@ View filter rule | **tabs** | `never` | optional | [REMOVED] `page.slots.tabs` was removed in @objectstack/spec 18.3.0 (ADR-0085) — an authored strip replaced the whole synthesized one, so the related lists, approvals, attachments and history the platform composes from the object were dropped unless re-added by hand, and the page stayed tabbed at every width instead of taking the record page's columns. Delete the key. The record page composes its tabs and columns from the object's roles and relationship flags: to change which related lists appear or their order, set `relatedList` / `relatedList: 'primary'` on the child's reference field; to replace the Details body, author `slots.details`; a page that needs its own strip is `kind: 'full'`, drawn as authored. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **discussion** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … }[]` | optional | | +### Nested Shape: `Page.overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + --- diff --git a/content/docs/references/ui/report.mdx b/content/docs/references/ui/report.mdx index 4b7b9b7efb8..35a1bc45c29 100644 --- a/content/docs/references/ui/report.mdx +++ b/content/docs/references/ui/report.mdx @@ -78,6 +78,7 @@ const result = JoinedReportBlockSchema.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Nested Shape: `Report.order[number]` @@ -145,6 +146,13 @@ const result = JoinedReportBlockSchema.parse(data); | **reason** | `string` | ✅ | User-visible reason shown when the lock blocks an action. | | **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | +### Nested Shape: `Report.overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + --- diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index c49b87e0793..d0ea8c8c258 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -2115,6 +2115,7 @@ This schema accepts one of the following structures: | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Nested Shape: `ViewItem[viewKind='list'].config` @@ -2175,6 +2176,13 @@ This schema accepts one of the following structures: | **reason** | `string` | ✅ | User-visible reason shown when the lock blocks an action. | | **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | +### Nested Shape: `ViewItem[viewKind='list'].overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + --- #### Option 2 @@ -2201,6 +2209,7 @@ This schema accepts one of the following structures: | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Nested Shape: `ViewItem[viewKind='form'].config` @@ -2238,6 +2247,13 @@ This schema accepts one of the following structures: | **reason** | `string` | ✅ | User-visible reason shown when the lock blocks an action. | | **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | +### Nested Shape: `ViewItem[viewKind='form'].overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + --- @@ -2282,6 +2298,7 @@ This schema accepts one of the following structures: | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | | **isPinned** | `boolean` | optional | Console round-trip: the view is pinned in the object's view switcher. Written by the console's pin toggle through the `view` metadata API and read back to draw the pinned group. Stored on the view's row, which has no per-user scope. Not authored. | | **sortOrder** | `integer` | optional | Console round-trip: the view's position among the object's saved views in the switcher, 0-based and counted over saved views only (a code-defined view carries none). Written by the console's drag-reorder and read back to order the tabs. Not authored: `order` is the authored default position. | | **visibility** | `Enum<'private' \| 'team' \| 'organization' \| 'public'>` | optional | Console round-trip: the group the switcher files this view's tab under (private, team, organization or public). Display grouping only, NOT access control: nothing restricts who can list or open the view by this value. No console control sets it; the console carries a stored value forward when it re-saves the row. Not authored. | @@ -2347,6 +2364,13 @@ This schema accepts one of the following structures: | **reason** | `string` | ✅ | User-visible reason shown when the lock blocks an action. | | **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | +### Nested Shape: `ViewItemWire[viewKind='list'].overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + ### Nested Shape: `ViewItemWire[viewKind='list'].columnState` | Property | Type | Required | Description | @@ -2380,6 +2404,7 @@ This schema accepts one of the following structures: | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | | **isPinned** | `boolean` | optional | Console round-trip: the view is pinned in the object's view switcher. Written by the console's pin toggle through the `view` metadata API and read back to draw the pinned group. Stored on the view's row, which has no per-user scope. Not authored. | | **sortOrder** | `integer` | optional | Console round-trip: the view's position among the object's saved views in the switcher, 0-based and counted over saved views only (a code-defined view carries none). Written by the console's drag-reorder and read back to order the tabs. Not authored: `order` is the authored default position. | | **visibility** | `Enum<'private' \| 'team' \| 'organization' \| 'public'>` | optional | Console round-trip: the group the switcher files this view's tab under (private, team, organization or public). Display grouping only, NOT access control: nothing restricts who can list or open the view by this value. No console control sets it; the console carries a stored value forward when it re-saves the row. Not authored. | @@ -2422,6 +2447,13 @@ This schema accepts one of the following structures: | **reason** | `string` | ✅ | User-visible reason shown when the lock blocks an action. | | **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | +### Nested Shape: `ViewItemWire[viewKind='form'].overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + ### Nested Shape: `ViewItemWire[viewKind='form'].columnState` | Property | Type | Required | Description | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/kernel.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/kernel.md index 3625c957d44..c0c068f9904 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/kernel.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/kernel.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `kernel/` | 237 | +| `kernel/` | 238 | diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index 589130330d1..5d85f4546de 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -259,10 +259,13 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no // joined the accepted set, and the sample names it. // [#27944] 49 → 50: `submitText` (the param dialog's submit button // label) joined the accepted set, and the sample names it. - expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(50); + // [#28404] 50 → 51: `overrides` (the ADR-0146 D2 override declaration + // a `.custom` item carries) joined the accepted set, and the + // sample names it. + expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(51); // A sample an author would actually address, and the one #17500's // repeater titles need a node to sit on. - for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages', 'requiresMembershipReach', 'disabledReason', 'pressed', 'submitText']) { + for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages', 'requiresMembershipReach', 'disabledReason', 'pressed', 'submitText', 'overrides']) { expect(Object.keys(properties)).toContain(key); } }); @@ -357,9 +360,14 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no // [#27945] `field` 80 → 81: `lookupAutofill`, the fields a pick in a // lookup fills. // [#28022] `app` 22 → 23: `landing`, the per-audience landing list. + // [#28404] `app` 23 → 24, `dashboard` 20 → 21, `dataset` 16 → 17, `flow` + // 22 → 23, `page` 26 → 27, `report` 23 → 24: `overrides`, the ADR-0146 D2 + // override declaration, on each overridable type. The excluded types + // (`agent`, `field`, `hook`, `object`, `position`, `skill`, `tool`) do not + // gain it, and their counts stay. const CARD_PROPERTY_COUNTS: Record = { - agent: 24, app: 23, dashboard: 20, dataset: 16, field: 81, flow: 22, - hook: 23, object: 49, page: 26, position: 13, report: 23, skill: 16, tool: 14, + agent: 24, app: 24, dashboard: 21, dataset: 17, field: 81, flow: 23, + hook: 23, object: 49, page: 27, position: 13, report: 24, skill: 16, tool: 14, }; it.each(Object.entries(CARD_PROPERTY_COUNTS))( diff --git a/packages/runtime/src/domains/packages-export-override-declaration.test.ts b/packages/runtime/src/domains/packages-export-override-declaration.test.ts new file mode 100644 index 00000000000..70eb4c95fb0 --- /dev/null +++ b/packages/runtime/src/domains/packages-export-override-declaration.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0146 D8 item 3 — "Export carries the declaration": `GET + * /packages/.custom/export` carries each overriding item with its + * `overrides` block, and the exported body re-parses through the item's own + * schema with the block intact, so the package travels to another environment + * still saying what it overrides. + * + * The export strips provenance at the top level of each item (every + * `_`-prefixed key and a fixed bookkeeping set). This file pins that the + * declaration, an AUTHORED key, is not in that strip: measured through the + * real door over a protocol double, never inferred from the key's spelling. + * + * The driver is the sibling package-door tests' (`packages-capability-gate.test.ts`): + * `HttpDispatcher` over a fake kernel whose services are doubles. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { FlowSchema } from '@objectstack/spec/automation'; +import { AssembledViewArtifactSchema } from '@objectstack/spec/ui'; +import { customizationPackageId } from '@objectstack/spec/kernel'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const BASE = 'com.acme.crm'; +const PAIR = (() => { + const derived = customizationPackageId(BASE); + if (!derived.ok) throw new Error('fixture base must have a pair'); + return derived.value; +})(); +const DECLARATION = { package: BASE, basedOn: '3.2.0' }; + +/** What the protocol serves for the pair: one overriding flow and one overriding view item. */ +const SERVED: Record>> = { + flow: [{ + name: 'crm_lead_assign', + label: 'Assign leads', + type: 'autolaunched', + nodes: [], + edges: [], + overrides: DECLARATION, + _packageId: PAIR, + _provenance: 'org', + }], + view: [{ + name: 'crm_account.all', + object: 'crm_account', + viewKind: 'list', + label: 'All accounts', + config: { type: 'grid', columns: ['name'], data: { provider: 'object', object: 'crm_account' } }, + overrides: DECLARATION, + _packageId: PAIR, + }], +}; + +function makeDoor() { + const getMetaItems = vi.fn(async (req: { type: string; packageId: string }) => ({ + items: req.packageId === PAIR ? structuredClone(SERVED[req.type] ?? []) : [], + })); + const registry = { + getAllPackages: vi.fn().mockReturnValue([]), + getPackage: vi.fn((id: string) => (id === PAIR ? { id, manifest: { id, name: 'crm_custom', version: '1.0.0' } } : undefined)), + }; + const kernel: any = { + context: { + getService: (name: string) => + name === 'objectql' ? { registry } : name === 'protocol' ? { getMetaItems } : null, + }, + }; + return { dispatcher: new HttpDispatcher(kernel), getMetaItems }; +} + +const reader = (): any => ({ + request: {}, + environmentId: 'platform', + executionContext: { userId: 'u_admin', isSystem: false, systemPermissions: ['studio.access'] }, +}); + +async function exportPair(): Promise> { + const { dispatcher } = makeDoor(); + const r = await dispatcher.handlePackages(`/${encodeURIComponent(PAIR)}/export`, 'GET', undefined, {}, reader()); + expect(r.response?.status).toBe(200); + return r.response?.body?.data; +} + +describe('GET /packages/.custom/export carries the override declaration (ADR-0146 D8.3)', () => { + it('an overriding flow is exported with its `overrides` block, and its provenance stripped', async () => { + const manifest = await exportPair(); + const flow = manifest.flows?.[0]; + expect(flow?.overrides).toEqual(DECLARATION); + expect(flow).not.toHaveProperty('_packageId'); + expect(flow).not.toHaveProperty('_provenance'); + }); + + it('the exported flow re-parses through FlowSchema with the declaration intact', async () => { + const manifest = await exportPair(); + const reparsed = FlowSchema.safeParse(manifest.flows?.[0]); + expect(reparsed.success, JSON.stringify(reparsed.error?.issues)).toBe(true); + expect((reparsed.data as { overrides?: unknown }).overrides).toEqual(DECLARATION); + }); + + it('an overriding view item travels in `viewItems:` with its block, and re-parses there', async () => { + const manifest = await exportPair(); + const item = manifest.viewItems?.[0]; + expect(item?.overrides).toEqual(DECLARATION); + const reparsed = AssembledViewArtifactSchema.safeParse(item); + expect(reparsed.success, JSON.stringify(reparsed.error?.issues)).toBe(true); + expect((reparsed.data as { overrides?: unknown }).overrides).toEqual(DECLARATION); + }); +}); diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 520b7adf3a5..557dfd5167c 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -50,6 +50,7 @@ "AutoPersistenceConfigSchema (const)", "AutonumberFormatSource (interface)", "AutonumberToken (type)", + "BASE_FIELD_DISPLAY_WHITELIST (const)", "BOOLEAN_COMPARAND_DOOR_CASES (const)", "BOOLEAN_COMPARAND_DOOR_FIXTURE (const)", "BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS (const)", @@ -66,6 +67,8 @@ "BULK_WRITE_HOOK_DISPATCH_CONTRACT (const)", "BaseEngineOptions (type)", "BaseEngineOptionsSchema (const)", + "BaseFieldDisplayProperty (type)", + "BaseFieldOptionDisplayProperty (type)", "BaseValidationRuleShape (interface)", "BooleanComparandDoorCase (type)", "BooleanComparandDoorDeferredCase (interface)", @@ -237,6 +240,7 @@ "DroppedFieldsEventSchema (const)", "EMPTY_OPERATOR_ARMS (const)", "ENGINE_UPDATE_UPSERT_REMOVED (const)", + "EXTENSION_FIELD_SUFFIX (const)", "EffectiveApiMethods (interface)", "EmptyOperatorArm (type)", "EmptyOperatorExpansion (interface)", @@ -918,6 +922,7 @@ "isDateRangePresetName (function)", "isEmptyFilterValue (function)", "isExpressionEnvelopeDefault (function)", + "isExtensionFieldName (function)", "isFileIdToken (function)", "isFilterAST (function)", "isGlobalUnique (function)", diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json index fd84a0f13fd..b87389e65dc 100644 --- a/packages/spec/api-surface/kernel.json +++ b/packages/spec/api-surface/kernel.json @@ -20,6 +20,8 @@ "BreakingChangeSchema (const)", "CONSUMER_INSTALLABLE_TYPES (const)", "CORE_PLUGIN_TYPES (const)", + "CUSTOMIZATION_NAMESPACE_SUFFIX (const)", + "CUSTOMIZATION_PACKAGE_SUFFIX (const)", "CapabilityClassification (interface)", "CapabilityConformanceLevel (type)", "CapabilityConformanceLevelSchema (const)", @@ -38,6 +40,12 @@ "CompatibilityMatrixEntryParsed (type)", "CompatibilityMatrixEntrySchema (const)", "CompletenessFinding (interface)", + "CustomizationDependencyRangeRefusal (type)", + "CustomizationDerivation (type)", + "CustomizationDerived (interface)", + "CustomizationNamespaceRefusal (type)", + "CustomizationPackageIdRefusal (type)", + "CustomizationRefused (interface)", "DEFAULT_INSTALL_MODES (const)", "DEFAULT_METADATA_TYPE_REGISTRY (const)", "DeadLetterQueueEntry (type)", @@ -161,6 +169,7 @@ "ListPackagesResponseSchema (const)", "MANIFEST_ID_EXAMPLES (const)", "MANIFEST_ID_PATTERN (const)", + "MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH (const)", "METADATA_READ_DECORATIONS (const)", "ManifestPermissions (type)", "ManifestPermissionsSchema (const)", @@ -211,6 +220,8 @@ "NamespaceConflictErrorSchema (const)", "NamespaceRegistryEntry (type)", "NamespaceRegistryEntrySchema (const)", + "OVERRIDABLE_METADATA_TYPES (const)", + "OVERRIDE_TYPE_CLASSIFICATION (const)", "ObjectStackManifest (type)", "ObjectStackManifestParsed (type)", "OclifPluginConfig (type)", @@ -221,6 +232,14 @@ "OpsFilePathSchema (const)", "OpsPluginStructure (type)", "OpsPluginStructureSchema (const)", + "OverrideClassificationBasis (type)", + "OverrideCondition (type)", + "OverrideDeclaration (type)", + "OverrideDeclarationFields (const)", + "OverrideDeclarationParsed (type)", + "OverrideDeclarationSchema (const)", + "OverrideDisposition (type)", + "OverrideTypeClassification (interface)", "PLATFORM_ALWAYS_ON_CAPABILITIES (const)", "PLATFORM_CAPABILITY_PROVIDERS (const)", "PLATFORM_CAPABILITY_TOKENS (const)", @@ -444,6 +463,10 @@ "checkViewCompleteness (function)", "checkWebhookCompleteness (function)", "classifyRequiredCapability (function)", + "customizationBasePackageId (function)", + "customizationDependencyRange (function)", + "customizationNamespace (function)", + "customizationPackageId (function)", "deriveNamespaceFromPackageId (function)", "evaluateLockForDelete (function)", "evaluateLockForWrite (function)", @@ -453,8 +476,12 @@ "getMetadataTypeActions (function)", "getMetadataTypeRedactor (function)", "getMetadataTypeSchema (function)", + "getOverrideTypeClassification (function)", "isConsumerInstallable (function)", + "isCustomizationPackage (function)", + "isCustomizationPairOf (function)", "isKnownPlatformCapability (function)", + "isOverridableMetadataType (function)", "isStoredMetadataBodyObject (function)", "lintUnknownAuthoringKeys (function)", "lintUnknownKeysAgainstSchema (function)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 5cf427ab246..c0db2f0a13f 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -365,6 +365,7 @@ "api/CreateFlowRequest:label", "api/CreateFlowRequest:name", "api/CreateFlowRequest:nodes", + "api/CreateFlowRequest:overrides", "api/CreateFlowRequest:protection", "api/CreateFlowRequest:requiredPermissions", "api/CreateFlowRequest:runAs", diff --git a/packages/spec/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json index 5e75359d33e..9defccb5002 100644 --- a/packages/spec/authorable-surface/automation.json +++ b/packages/spec/authorable-surface/automation.json @@ -184,6 +184,7 @@ "automation/Flow:label", "automation/Flow:name", "automation/Flow:nodes", + "automation/Flow:overrides", "automation/Flow:protection", "automation/Flow:requiredPermissions", "automation/Flow:runAs", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index d04a1e830f1..55e39e4b615 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -541,6 +541,7 @@ "data/Mapping:label", "data/Mapping:mode", "data/Mapping:name", + "data/Mapping:overrides", "data/Mapping:sourceFormat", "data/Mapping:targetObject", "data/Mapping:upsertKey", diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index 700f4f2c986..75f06818841 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -377,6 +377,8 @@ "kernel/OpsPluginStructure:files", "kernel/OpsPluginStructure:metadata", "kernel/OpsPluginStructure:root", + "kernel/OverrideDeclaration:basedOn", + "kernel/OverrideDeclaration:package", "kernel/PackageArtifact:builtAt", "kernel/PackageArtifact:builtWith", "kernel/PackageArtifact:checksums", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index fdc9250b952..3d7d1b68865 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -149,6 +149,7 @@ "system/Book:label", "system/Book:name", "system/Book:order", + "system/Book:overrides", "system/Book:slug", "system/BookGroup:include", "system/BookGroup:key", @@ -356,6 +357,7 @@ "system/Doc:label", "system/Doc:name", "system/Doc:order", + "system/Doc:overrides", "system/Doc:tags", "system/Doc:translations", "system/EmailAddressConfig:address", @@ -394,6 +396,7 @@ "system/EmailTemplateDefinition:label", "system/EmailTemplateDefinition:locale", "system/EmailTemplateDefinition:name", + "system/EmailTemplateDefinition:overrides", "system/EmailTemplateDefinition:protection", "system/EmailTemplateDefinition:replyTo", "system/EmailTemplateDefinition:subject", @@ -1379,6 +1382,7 @@ "system/TranslationItem:metadataForms", "system/TranslationItem:name", "system/TranslationItem:objects", + "system/TranslationItem:overrides", "system/TranslationItem:pages", "system/TranslationItem:permissions", "system/TranslationItem:picklists", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 746f40c6710..e7a62553055 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -35,6 +35,7 @@ "ui/Action:operation", "ui/Action:order", "ui/Action:outcomeMessages", + "ui/Action:overrides", "ui/Action:params", "ui/Action:patch", "ui/Action:pressed", @@ -185,6 +186,7 @@ "ui/App:landing", "ui/App:name", "ui/App:navigation", + "ui/App:overrides", "ui/App:protection", "ui/App:requiredPermissions", "ui/AppBranding:accentColor", @@ -319,6 +321,7 @@ "ui/Dashboard:header", "ui/Dashboard:label", "ui/Dashboard:name", + "ui/Dashboard:overrides", "ui/Dashboard:protection", "ui/Dashboard:refreshInterval [RETIRED]", "ui/Dashboard:refreshIntervalSeconds", @@ -395,6 +398,7 @@ "ui/Dataset:measures", "ui/Dataset:name", "ui/Dataset:object", + "ui/Dataset:overrides", "ui/Dataset:protection", "ui/DatasetDimension:dateGranularity", "ui/DatasetDimension:field", @@ -1056,6 +1060,7 @@ "ui/Page:label", "ui/Page:name", "ui/Page:object", + "ui/Page:overrides", "ui/Page:print", "ui/Page:record", "ui/Page:regions", @@ -1256,6 +1261,7 @@ "ui/Report:label", "ui/Report:name", "ui/Report:order", + "ui/Report:overrides", "ui/Report:protection", "ui/Report:rows", "ui/Report:runtimeFilter", diff --git a/packages/spec/declaration-map/kernel.json b/packages/spec/declaration-map/kernel.json index c5e9b481a30..e9bbab2b3d8 100644 --- a/packages/spec/declaration-map/kernel.json +++ b/packages/spec/declaration-map/kernel.json @@ -154,6 +154,8 @@ "OpsFilePathSchema": "kernel/OpsFilePath", "OpsPluginStructure": "kernel/OpsPluginStructure", "OpsPluginStructureSchema": "kernel/OpsPluginStructure", + "OverrideDeclaration": "kernel/OverrideDeclaration", + "OverrideDeclarationSchema": "kernel/OverrideDeclaration", "PackageArtifact": "kernel/PackageArtifact", "PackageArtifactSchema": "kernel/PackageArtifact", "PackageDependencyConflict": "kernel/PackageDependencyConflict", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 4ec83f2a079..b6be907860f 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -47,6 +47,7 @@ "AutoPersistenceConfigSchema": "src/data/driver/memory.zod.ts#AutoPersistenceConfigSchema (const)", "AutonumberFormatSource": "src/data/autonumber-format.ts#AutonumberFormatSource (interface)", "AutonumberToken": "src/data/autonumber-format.ts#AutonumberToken (type)", + "BASE_FIELD_DISPLAY_WHITELIST": "src/data/field-customization.ts#BASE_FIELD_DISPLAY_WHITELIST (const)", "BOOLEAN_COMPARAND_DOOR_CASES": "src/data/filter-boolean-comparand-declared-type.ts#BOOLEAN_COMPARAND_DOOR_CASES (const)", "BOOLEAN_COMPARAND_DOOR_FIXTURE": "src/data/filter-boolean-comparand-declared-type.ts#BOOLEAN_COMPARAND_DOOR_FIXTURE (const)", "BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS": "src/data/filter-boolean-comparand-declared-type.ts#BOOLEAN_COMPARAND_DOOR_FIXTURE_FIELDS (const)", @@ -63,6 +64,8 @@ "BULK_WRITE_HOOK_DISPATCH_CONTRACT": "src/data/bulk-write-hook-conformance.ts#BULK_WRITE_HOOK_DISPATCH_CONTRACT (const)", "BaseEngineOptions": "src/data/data-engine.zod.ts#BaseEngineOptions (type)", "BaseEngineOptionsSchema": "src/data/data-engine.zod.ts#BaseEngineOptionsSchema (const)", + "BaseFieldDisplayProperty": "src/data/field-customization.ts#BaseFieldDisplayProperty (type)", + "BaseFieldOptionDisplayProperty": "src/data/field-customization.ts#BaseFieldOptionDisplayProperty (type)", "BaseValidationRuleShape": "src/data/validation.zod.ts#BaseValidationRuleShape (interface)", "BooleanComparandDoorCase": "src/data/filter-boolean-comparand-declared-type.ts#BooleanComparandDoorCase (type)", "BooleanComparandDoorDeferredCase": "src/data/filter-boolean-comparand-declared-type.ts#BooleanComparandDoorDeferredCase (interface)", @@ -232,6 +235,7 @@ "DroppedFieldsEventSchema": "src/data/data-engine.zod.ts#DroppedFieldsEventSchema (const)", "EMPTY_OPERATOR_ARMS": "src/data/filter-empty-operator.ts#EMPTY_OPERATOR_ARMS (const)", "ENGINE_UPDATE_UPSERT_REMOVED": "src/data/data-engine.zod.ts#ENGINE_UPDATE_UPSERT_REMOVED (const)", + "EXTENSION_FIELD_SUFFIX": "src/data/field-customization.ts#EXTENSION_FIELD_SUFFIX (const)", "EffectiveApiMethods": "src/data/api-derivation.ts#EffectiveApiMethods (interface)", "EmptyOperatorArm": "src/data/filter-empty-operator.ts#EmptyOperatorArm (type)", "EmptyOperatorExpansion": "src/data/filter-empty-operator.ts#EmptyOperatorExpansion (interface)", @@ -904,6 +908,7 @@ "isDateRangePresetName": "src/data/date-range-presets.ts#isDateRangePresetName (function)", "isEmptyFilterValue": "src/data/filter-empty-operator.ts#isEmptyFilterValue (function)", "isExpressionEnvelopeDefault": "src/data/default-value-shape.ts#isExpressionEnvelopeDefault (function)", + "isExtensionFieldName": "src/data/field-customization.ts#isExtensionFieldName (function)", "isFileIdToken": "src/data/field-value.zod.ts#isFileIdToken (function)", "isFilterAST": "src/data/filter.zod.ts#isFilterAST (function)", "isGlobalUnique": "src/data/field.zod.ts#isGlobalUnique (function)", diff --git a/packages/spec/export-origins/kernel.json b/packages/spec/export-origins/kernel.json index ce8494045ea..ee4a50a45fa 100644 --- a/packages/spec/export-origins/kernel.json +++ b/packages/spec/export-origins/kernel.json @@ -20,6 +20,8 @@ "BreakingChangeSchema": "src/kernel/plugin-versioning.zod.ts#BreakingChangeSchema (const)", "CONSUMER_INSTALLABLE_TYPES": "src/kernel/plugin.zod.ts#CONSUMER_INSTALLABLE_TYPES (const)", "CORE_PLUGIN_TYPES": "src/kernel/plugin.zod.ts#CORE_PLUGIN_TYPES (const)", + "CUSTOMIZATION_NAMESPACE_SUFFIX": "src/kernel/customization-package.ts#CUSTOMIZATION_NAMESPACE_SUFFIX (const)", + "CUSTOMIZATION_PACKAGE_SUFFIX": "src/kernel/override-declaration.zod.ts#CUSTOMIZATION_PACKAGE_SUFFIX (const)", "CapabilityClassification": "src/kernel/platform-capabilities.ts#CapabilityClassification (interface)", "CapabilityConformanceLevel": "src/kernel/plugin-capability.zod.ts#CapabilityConformanceLevel (type)", "CapabilityConformanceLevelSchema": "src/kernel/plugin-capability.zod.ts#CapabilityConformanceLevelSchema (const)", @@ -38,6 +40,12 @@ "CompatibilityMatrixEntryParsed": "src/kernel/plugin-versioning.zod.ts#CompatibilityMatrixEntryParsed (type)", "CompatibilityMatrixEntrySchema": "src/kernel/plugin-versioning.zod.ts#CompatibilityMatrixEntrySchema (const)", "CompletenessFinding": "src/kernel/functional-completeness.ts#CompletenessFinding (interface)", + "CustomizationDependencyRangeRefusal": "src/kernel/customization-package.ts#CustomizationDependencyRangeRefusal (type)", + "CustomizationDerivation": "src/kernel/customization-package.ts#CustomizationDerivation (type)", + "CustomizationDerived": "src/kernel/customization-package.ts#CustomizationDerived (interface)", + "CustomizationNamespaceRefusal": "src/kernel/customization-package.ts#CustomizationNamespaceRefusal (type)", + "CustomizationPackageIdRefusal": "src/kernel/customization-package.ts#CustomizationPackageIdRefusal (type)", + "CustomizationRefused": "src/kernel/customization-package.ts#CustomizationRefused (interface)", "DEFAULT_INSTALL_MODES": "src/kernel/manifest.zod.ts#DEFAULT_INSTALL_MODES (const)", "DEFAULT_METADATA_TYPE_REGISTRY": "src/kernel/metadata-plugin.zod.ts#DEFAULT_METADATA_TYPE_REGISTRY (const)", "DeadLetterQueueEntry": "src/kernel/events/dlq.zod.ts#DeadLetterQueueEntry (type)", @@ -160,6 +168,7 @@ "ListPackagesResponseSchema": "src/kernel/package-registry.zod.ts#ListPackagesResponseSchema (const)", "MANIFEST_ID_EXAMPLES": "src/kernel/manifest.zod.ts#MANIFEST_ID_EXAMPLES (const)", "MANIFEST_ID_PATTERN": "src/kernel/manifest.zod.ts#MANIFEST_ID_PATTERN (const)", + "MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH": "src/kernel/customization-package.ts#MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH (const)", "METADATA_READ_DECORATIONS": "src/kernel/metadata-read-decorations.ts#METADATA_READ_DECORATIONS (const)", "ManifestPermissions": "src/kernel/manifest.zod.ts#ManifestPermissions (type)", "ManifestPermissionsSchema": "src/kernel/manifest.zod.ts#ManifestPermissionsSchema (const)", @@ -210,6 +219,8 @@ "NamespaceConflictErrorSchema": "src/kernel/package-registry.zod.ts#NamespaceConflictErrorSchema (const)", "NamespaceRegistryEntry": "src/kernel/package-registry.zod.ts#NamespaceRegistryEntry (type)", "NamespaceRegistryEntrySchema": "src/kernel/package-registry.zod.ts#NamespaceRegistryEntrySchema (const)", + "OVERRIDABLE_METADATA_TYPES": "src/kernel/customization-package.ts#OVERRIDABLE_METADATA_TYPES (const)", + "OVERRIDE_TYPE_CLASSIFICATION": "src/kernel/customization-package.ts#OVERRIDE_TYPE_CLASSIFICATION (const)", "ObjectStackManifest": "src/kernel/manifest.zod.ts#ObjectStackManifest (type)", "ObjectStackManifestParsed": "src/kernel/manifest.zod.ts#ObjectStackManifestParsed (type)", "OclifPluginConfig": "src/kernel/cli-extension.zod.ts#OclifPluginConfig (type)", @@ -220,6 +231,14 @@ "OpsFilePathSchema": "src/kernel/plugin-structure.zod.ts#OpsFilePathSchema (const)", "OpsPluginStructure": "src/kernel/plugin-structure.zod.ts#OpsPluginStructure (type)", "OpsPluginStructureSchema": "src/kernel/plugin-structure.zod.ts#OpsPluginStructureSchema (const)", + "OverrideClassificationBasis": "src/kernel/customization-package.ts#OverrideClassificationBasis (type)", + "OverrideCondition": "src/kernel/customization-package.ts#OverrideCondition (type)", + "OverrideDeclaration": "src/kernel/override-declaration.zod.ts#OverrideDeclaration (type)", + "OverrideDeclarationFields": "src/kernel/override-declaration.zod.ts#OverrideDeclarationFields (const)", + "OverrideDeclarationParsed": "src/kernel/override-declaration.zod.ts#OverrideDeclarationParsed (type)", + "OverrideDeclarationSchema": "src/kernel/override-declaration.zod.ts#OverrideDeclarationSchema (const)", + "OverrideDisposition": "src/kernel/customization-package.ts#OverrideDisposition (type)", + "OverrideTypeClassification": "src/kernel/customization-package.ts#OverrideTypeClassification (interface)", "PLATFORM_ALWAYS_ON_CAPABILITIES": "src/kernel/platform-capabilities.ts#PLATFORM_ALWAYS_ON_CAPABILITIES (const)", "PLATFORM_CAPABILITY_PROVIDERS": "src/kernel/platform-capabilities.ts#PLATFORM_CAPABILITY_PROVIDERS (const)", "PLATFORM_CAPABILITY_TOKENS": "src/kernel/platform-capabilities.ts#PLATFORM_CAPABILITY_TOKENS (const)", @@ -440,6 +459,10 @@ "checkViewCompleteness": "src/kernel/functional-completeness.ts#checkViewCompleteness (function)", "checkWebhookCompleteness": "src/kernel/functional-completeness.ts#checkWebhookCompleteness (function)", "classifyRequiredCapability": "src/kernel/platform-capabilities.ts#classifyRequiredCapability (function)", + "customizationBasePackageId": "src/kernel/customization-package.ts#customizationBasePackageId (function)", + "customizationDependencyRange": "src/kernel/customization-package.ts#customizationDependencyRange (function)", + "customizationNamespace": "src/kernel/customization-package.ts#customizationNamespace (function)", + "customizationPackageId": "src/kernel/customization-package.ts#customizationPackageId (function)", "deriveNamespaceFromPackageId": "src/kernel/namespace-prefix.ts#deriveNamespaceFromPackageId (function)", "evaluateLockForDelete": "src/kernel/metadata-protection.zod.ts#evaluateLockForDelete (function)", "evaluateLockForWrite": "src/kernel/metadata-protection.zod.ts#evaluateLockForWrite (function)", @@ -449,8 +472,12 @@ "getMetadataTypeActions": "src/kernel/metadata-type-schemas.ts#getMetadataTypeActions (function)", "getMetadataTypeRedactor": "src/kernel/metadata-type-redaction.ts#getMetadataTypeRedactor (function)", "getMetadataTypeSchema": "src/kernel/metadata-type-schemas.ts#getMetadataTypeSchema (function)", + "getOverrideTypeClassification": "src/kernel/customization-package.ts#getOverrideTypeClassification (function)", "isConsumerInstallable": "src/kernel/plugin.zod.ts#isConsumerInstallable (function)", + "isCustomizationPackage": "src/kernel/customization-package.ts#isCustomizationPackage (function)", + "isCustomizationPairOf": "src/kernel/customization-package.ts#isCustomizationPairOf (function)", "isKnownPlatformCapability": "src/kernel/platform-capabilities.ts#isKnownPlatformCapability (function)", + "isOverridableMetadataType": "src/kernel/customization-package.ts#isOverridableMetadataType (function)", "isStoredMetadataBodyObject": "src/kernel/stored-metadata-body-objects.ts#isStoredMetadataBodyObject (function)", "lintUnknownAuthoringKeys": "src/kernel/metadata-authoring-lint.ts#lintUnknownAuthoringKeys (function)", "lintUnknownKeysAgainstSchema": "src/kernel/metadata-authoring-lint.ts#lintUnknownKeysAgainstSchema (function)", diff --git a/packages/spec/json-schema.manifest/kernel.json b/packages/spec/json-schema.manifest/kernel.json index 2242bf2347e..89d9240c89d 100644 --- a/packages/spec/json-schema.manifest/kernel.json +++ b/packages/spec/json-schema.manifest/kernel.json @@ -80,6 +80,7 @@ "kernel/OpsDomainModule", "kernel/OpsFilePath", "kernel/OpsPluginStructure", + "kernel/OverrideDeclaration", "kernel/PackageArtifact", "kernel/PackageDependencyConflict", "kernel/PackageDependencyResolutionResult", diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 4345006ea35..b65e8c0130f 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -252,6 +252,23 @@ "status": "dead", "verifiedAt": "2026-09-28", "note": "REMOVED 2026-09-28 (#20323, ADR-0049 enforce-or-remove) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-18 conversion action-aria-removed. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); write the accessible name as the action's required `label`, and put a region name in the placing node's `aria` block (`page.components[].aria` or the list view `aria`). The `live` grade this row replaces rested on an uncited note — 「PARTIAL — honored by a few objectui renderers, not the core action buttons/menus」 — with no reader behind it: re-measured at the `.objectui-sha` pin f8a9d0fb05, none of the action surfaces (action:button/icon/menu/group/bar, the grid row and bulk action menus, record:quick_actions, the declared-actions bar) reads an action's `aria`; the only `schema.aria` readers there are the placing nodes' own blocks (the record:* page components, the list view, element:button's props). Measured at objectui @f8a9d0fb05 (moved out of the packages/spec/src/ui/action.zod.ts tombstone comment, #23434): none of the surfaces that render an action — `action:button`, `action:icon`, `action:menu`, `action:group`, `action:bar`, the grid's row and bulk action menus, `record:quick_actions`, the declared-actions bar — reads an action's `aria`; the only `schema.aria` readers are the placing nodes' own blocks." + }, + "overrides": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "PLANNED RECORD (ADR-0146 D2, the spec card N-08): the override declaration `overrides: { package, basedOn }`, one field set (`OverrideDeclarationFields`, packages/spec/src/kernel/override-declaration.zod.ts) spread on the thirteen types `OVERRIDE_TYPE_CLASSIFICATION` classifies `overridable`. CARRIED today, measured: it survives the metadata save's schema parse, `defineStack()` and `GET /packages/:id/export` (packages/spec/src/kernel/override-declaration.test.ts; packages/runtime/src/domains/packages-export-override-declaration.test.ts). ACTED ON by nothing yet: the write gate that admits a declared override from the paired `.custom` and refuses an undeclared same-named item, and the effective read that serves the declared document over the base's, are later cards of ADR-0146's Rollout (P1). Flip to `live` on the commit that lands the reader, citing it.", + "children": { + "package": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row): the base package id the declaring item replaces an item of. The pair predicate the write gate will read is `isCustomizationPairOf` (packages/spec/src/kernel/customization-package.ts). The schema refuses a `.custom` id here at parse." + }, + "basedOn": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row). RECORDED, NEVER JUDGED by contract (ADR-0146 D2, 'Decided here' item 2): no door compares it with the installed base, produces an upgrade diff from it, or refuses on it. Its whole reading is a person's (Studio's override chip, the export); it stays `planned` until that reader lands, and must never gain a gate." + } + } } } } diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json index a891c9d0ed8..e169867110a 100644 --- a/packages/spec/liveness/app.json +++ b/packages/spec/liveness/app.json @@ -352,6 +352,23 @@ "verifiedAt": "2026-09-27", "evidence": "packages/rest/src/meta-item-read-gate.ts#filterAppForUserWithReason (`if (item._unpublished === true && !sysPerms.has('studio.access') && !sysPerms.has('setup.access'))` — the ADR-0045 publish gate, which judges THIS key and deliberately not `hidden`); packages/runtime/src/domains/packages.ts#handlePackagesRequest (POST /packages/:id/publish-drafts flips it to `false` — never deletes it, because ADR-0045 §3 keeps publish and unpublish symmetric)", "note": "MACHINE-MANAGED publish gate (ADR-0045 §3, amended 2026-08-09 / #4829) — never authored: written by the AI additive-materialization path (cloud) and cleared by POST /packages/:id/publish-drafts. SERVER-enforced: filterAppForUser withholds an unpublished app from every metadata response except a builder's (studio/setup access), for direct-URL preview. Declared on AppSchema rather than omitted because the write path validates against that schema (saveMetaItem → 422; Registry.validate('app') → AppSchema.parse), so the flip itself would be unwritable otherwise. Stored pre-amendment rows carrying `hidden: true` are rewritten here by the ADR-0087 conversion `app-hidden-to-unpublished`. 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED, both legs — `rest-server.ts:2669` had rotted onto a docblock about a diagnostic view being neither translated nor cached, ~480 lines below the gate, and `packages.ts:245` onto the domain-wide anonymous-deny floor, ~270 lines above the flip. Re-closed by hand against 8cb96ec41." + }, + "overrides": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "PLANNED RECORD (ADR-0146 D2, the spec card N-08): the override declaration `overrides: { package, basedOn }`, one field set (`OverrideDeclarationFields`, packages/spec/src/kernel/override-declaration.zod.ts) spread on the thirteen types `OVERRIDE_TYPE_CLASSIFICATION` classifies `overridable`. CARRIED today, measured: it survives the metadata save's schema parse, `defineStack()` and `GET /packages/:id/export` (packages/spec/src/kernel/override-declaration.test.ts; packages/runtime/src/domains/packages-export-override-declaration.test.ts). ACTED ON by nothing yet: the write gate that admits a declared override from the paired `.custom` and refuses an undeclared same-named item, and the effective read that serves the declared document over the base's, are later cards of ADR-0146's Rollout (P1). Flip to `live` on the commit that lands the reader, citing it.", + "children": { + "package": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row): the base package id the declaring item replaces an item of. The pair predicate the write gate will read is `isCustomizationPairOf` (packages/spec/src/kernel/customization-package.ts). The schema refuses a `.custom` id here at parse." + }, + "basedOn": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row). RECORDED, NEVER JUDGED by contract (ADR-0146 D2, 'Decided here' item 2): no door compares it with the installed base, produces an upgrade diff from it, or refuses on it. Its whole reading is a person's (Studio's override chip, the export); it stays `planned` until that reader lands, and must never gain a gate." + } + } } } } diff --git a/packages/spec/liveness/book.json b/packages/spec/liveness/book.json index bc74e1eebac..1cab88c77c8 100644 --- a/packages/spec/liveness/book.json +++ b/packages/spec/liveness/book.json @@ -90,6 +90,23 @@ } }, "note": "Drilled because `translations` diverges (dead) from its six live siblings." + }, + "overrides": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "PLANNED RECORD (ADR-0146 D2, the spec card N-08): the override declaration `overrides: { package, basedOn }`, one field set (`OverrideDeclarationFields`, packages/spec/src/kernel/override-declaration.zod.ts) spread on the thirteen types `OVERRIDE_TYPE_CLASSIFICATION` classifies `overridable`. CARRIED today, measured: it survives the metadata save's schema parse, `defineStack()` and `GET /packages/:id/export` (packages/spec/src/kernel/override-declaration.test.ts; packages/runtime/src/domains/packages-export-override-declaration.test.ts). ACTED ON by nothing yet: the write gate that admits a declared override from the paired `.custom` and refuses an undeclared same-named item, and the effective read that serves the declared document over the base's, are later cards of ADR-0146's Rollout (P1). Flip to `live` on the commit that lands the reader, citing it.", + "children": { + "package": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row): the base package id the declaring item replaces an item of. The pair predicate the write gate will read is `isCustomizationPairOf` (packages/spec/src/kernel/customization-package.ts). The schema refuses a `.custom` id here at parse." + }, + "basedOn": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row). RECORDED, NEVER JUDGED by contract (ADR-0146 D2, 'Decided here' item 2): no door compares it with the installed base, produces an upgrade diff from it, or refuses on it. Its whole reading is a person's (Studio's override chip, the export); it stays `planned` until that reader lands, and must never gain a gate." + } + } } } } diff --git a/packages/spec/liveness/dashboard.json b/packages/spec/liveness/dashboard.json index 04a30a188a1..ff8a42c4c60 100644 --- a/packages/spec/liveness/dashboard.json +++ b/packages/spec/liveness/dashboard.json @@ -253,6 +253,23 @@ "evidenceScope": "in-repo", "evidence": "packages/rest/src/meta-item-read-gate.ts#holdsRequiredPermissions is the ONE predicate the `/meta` read gate asks of every carrier of the key (an app, its navigation entries, a list view, a dashboard): the caller must hold every capability named, and an absent or empty list is no gate. The `dashboard` arm of packages/rest/src/meta-item-read-gate.ts#createMetaItemReadGate refuses a dashboard whose capabilities the caller does not all hold, 403 PERMISSION_DENIED (the app's whole refusal), on every door and before the ADR-0057 D10 widget gate — so a dashboard's direct URL is refused by the server — and packages/rest/src/meta-item-read-gate.ts#createMetaListReadGate leaves it out of `GET /meta/dashboard`. Pinned per audience (holder served, non-holder not served, no key served to all) on every `/meta/:type/:name` door by packages/rest/src/meta-alternate-door-read-gates.test.ts, and across both transports by packages/runtime/src/domains/meta-item-read-gate-parity.test.ts and packages/runtime/src/domains/meta-list-read-gate-parity.test.ts", "note": "planned → live 2026-10-10 (#22639, the rest half of the ruling of record 6095014058, letter A): the `/meta` read gate enforces the key on the list read and the by-name read of `dashboard`. The prior PLANNED note (#22611) is superseded and its flip instruction discharged in this same edit: `authorWarn` / `authorHint` dropped, and the describe's not-enforced clause deleted (packages/spec/src/ui/audience-required-permissions.ts). Boundaries: the refusal is WHOLE and reaches an author as anyone (the app's whole refusal, ruling 5856774816); a caller with no signed-in user is not gated (the app arm's rule — an anonymous caller is refused before any `/meta` read of this type). What the console shows a refused direct URL is objectui's half, filed when this one is accepted. The lint resolves the capability names (packages/lint/src/validate-capability-references.ts), since a misspelled one now hides the dashboard from everyone." + }, + "overrides": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "PLANNED RECORD (ADR-0146 D2, the spec card N-08): the override declaration `overrides: { package, basedOn }`, one field set (`OverrideDeclarationFields`, packages/spec/src/kernel/override-declaration.zod.ts) spread on the thirteen types `OVERRIDE_TYPE_CLASSIFICATION` classifies `overridable`. CARRIED today, measured: it survives the metadata save's schema parse, `defineStack()` and `GET /packages/:id/export` (packages/spec/src/kernel/override-declaration.test.ts; packages/runtime/src/domains/packages-export-override-declaration.test.ts). ACTED ON by nothing yet: the write gate that admits a declared override from the paired `.custom` and refuses an undeclared same-named item, and the effective read that serves the declared document over the base's, are later cards of ADR-0146's Rollout (P1). Flip to `live` on the commit that lands the reader, citing it.", + "children": { + "package": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row): the base package id the declaring item replaces an item of. The pair predicate the write gate will read is `isCustomizationPairOf` (packages/spec/src/kernel/customization-package.ts). The schema refuses a `.custom` id here at parse." + }, + "basedOn": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row). RECORDED, NEVER JUDGED by contract (ADR-0146 D2, 'Decided here' item 2): no door compares it with the installed base, produces an upgrade diff from it, or refuses on it. Its whole reading is a person's (Studio's override chip, the export); it stays `planned` until that reader lands, and must never gain a gate." + } + } } } } diff --git a/packages/spec/liveness/dataset.json b/packages/spec/liveness/dataset.json index 257564b90ba..febd6afc08c 100644 --- a/packages/spec/liveness/dataset.json +++ b/packages/spec/liveness/dataset.json @@ -161,6 +161,23 @@ } } } + }, + "overrides": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "PLANNED RECORD (ADR-0146 D2, the spec card N-08): the override declaration `overrides: { package, basedOn }`, one field set (`OverrideDeclarationFields`, packages/spec/src/kernel/override-declaration.zod.ts) spread on the thirteen types `OVERRIDE_TYPE_CLASSIFICATION` classifies `overridable`. CARRIED today, measured: it survives the metadata save's schema parse, `defineStack()` and `GET /packages/:id/export` (packages/spec/src/kernel/override-declaration.test.ts; packages/runtime/src/domains/packages-export-override-declaration.test.ts). ACTED ON by nothing yet: the write gate that admits a declared override from the paired `.custom` and refuses an undeclared same-named item, and the effective read that serves the declared document over the base's, are later cards of ADR-0146's Rollout (P1). Flip to `live` on the commit that lands the reader, citing it.", + "children": { + "package": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row): the base package id the declaring item replaces an item of. The pair predicate the write gate will read is `isCustomizationPairOf` (packages/spec/src/kernel/customization-package.ts). The schema refuses a `.custom` id here at parse." + }, + "basedOn": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row). RECORDED, NEVER JUDGED by contract (ADR-0146 D2, 'Decided here' item 2): no door compares it with the installed base, produces an upgrade diff from it, or refuses on it. Its whole reading is a person's (Studio's override chip, the export); it stays `planned` until that reader lands, and must never gain a gate." + } + } } } } diff --git a/packages/spec/liveness/doc.json b/packages/spec/liveness/doc.json index 6a3cfd2f57c..6c1365f7f64 100644 --- a/packages/spec/liveness/doc.json +++ b/packages/spec/liveness/doc.json @@ -49,6 +49,23 @@ "verifiedAt": "2026-09-27", "evidence": "packages/spec/src/system/book.zod.ts#matchesInclude (`Array.isArray(doc.tags) && doc.tags.includes(include.tag)` — the tag branch of `include`); packages/spec/src/system/book.zod.ts#resolveBookTree (the caller that decides membership from it); packages/rest/src/meta-item-read-gate.ts#createMetaBookTreeAnswer (`tags: d.tags` in the tree corpus the route hands `audience.readableTree(book, docs)` — the transport half); packages/rest/src/meta-item-read-gate.ts#resolveBookTree (`resolveBookTree(book, docs, book._packageId, books)` — where that corpus reaches the resolver)", "note": "DECLARED 2026-08-02 (#4509, ADR-0049) — the enforce half of enforce-or-remove. The consumer, the transport and the resolver-side interface all predated this key: matchesInclude compared `doc.tags` against a group's `include: { tag }`, the book route already forwarded `tags: d.tags`, and ResolverDoc declared `tags?: string[]` marked '(P3d; absent today)'. What was missing was one line HERE — DocSchema is strict, so authoring `tags:` was a parse error, every doc reached the resolver with tags undefined, and the `{ tag }` include variant could never match. Removing the variant was the alternative and was rejected: a union member has no clean tombstone (retiredKey covers object keys), so authors would have gotten a bare union error, and it would have discarded working matcher code. Live on arrival — the branch it feeds is reachable the moment a doc carries a tag. 2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — `book.zod.ts:213` is `description?: string;` in the `ResolvedEntry` interface, and the parenthetical claiming it was \"matchesInclude tag branch\" named the right function ~61 lines away; the \"reached from :255 and :281\" hops were prose the ledger never bounded. `rest-server.ts:3218` had rotted onto `this.registerSecurityEndpoints(bp)` — a route-registration call, ~1,700 lines from the corpus that forwards the tags. Re-closed by hand against 93ea19bca." + }, + "overrides": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "PLANNED RECORD (ADR-0146 D2, the spec card N-08): the override declaration `overrides: { package, basedOn }`, one field set (`OverrideDeclarationFields`, packages/spec/src/kernel/override-declaration.zod.ts) spread on the thirteen types `OVERRIDE_TYPE_CLASSIFICATION` classifies `overridable`. CARRIED today, measured: it survives the metadata save's schema parse, `defineStack()` and `GET /packages/:id/export` (packages/spec/src/kernel/override-declaration.test.ts; packages/runtime/src/domains/packages-export-override-declaration.test.ts). ACTED ON by nothing yet: the write gate that admits a declared override from the paired `.custom` and refuses an undeclared same-named item, and the effective read that serves the declared document over the base's, are later cards of ADR-0146's Rollout (P1). Flip to `live` on the commit that lands the reader, citing it.", + "children": { + "package": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row): the base package id the declaring item replaces an item of. The pair predicate the write gate will read is `isCustomizationPairOf` (packages/spec/src/kernel/customization-package.ts). The schema refuses a `.custom` id here at parse." + }, + "basedOn": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row). RECORDED, NEVER JUDGED by contract (ADR-0146 D2, 'Decided here' item 2): no door compares it with the installed base, produces an upgrade diff from it, or refuses on it. Its whole reading is a person's (Studio's override chip, the export); it stays `planned` until that reader lands, and must never gain a gate." + } + } } } } diff --git a/packages/spec/liveness/email_template.json b/packages/spec/liveness/email_template.json index 3aace7ee6e8..102cec516e0 100644 --- a/packages/spec/liveness/email_template.json +++ b/packages/spec/liveness/email_template.json @@ -89,6 +89,23 @@ "evidence": "ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx#defaultColumns:1293 (the Studio metadata list's default `description` column, which line 573 falls back to when a type registers no `listColumns`, and whose cell line 902 reads as `row.item[c.key]`); ui/packages/app-shell/src/views/metadata-admin/default-schemas.ts#registerDefaultMetadataSchemas:331 (`email_template` is registered with a `defaultSchema` and a `fieldOrder` and no `listColumns` of its own)", "note": "Docs-shaped: read by humans in Studio, never by the send path. Until 2026-10-11 it was materialized into `sys_email_template.description`; that projection retired with the rows (ADR-0131 D6/D10), and the Studio list read — measured at the `.objectui-sha` pin 4997995022f8 — is the consumer now (#7131).", "evidenceScope": "in-repo" + }, + "overrides": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "PLANNED RECORD (ADR-0146 D2, the spec card N-08): the override declaration `overrides: { package, basedOn }`, one field set (`OverrideDeclarationFields`, packages/spec/src/kernel/override-declaration.zod.ts) spread on the thirteen types `OVERRIDE_TYPE_CLASSIFICATION` classifies `overridable`. CARRIED today, measured: it survives the metadata save's schema parse, `defineStack()` and `GET /packages/:id/export` (packages/spec/src/kernel/override-declaration.test.ts; packages/runtime/src/domains/packages-export-override-declaration.test.ts). ACTED ON by nothing yet: the write gate that admits a declared override from the paired `.custom` and refuses an undeclared same-named item, and the effective read that serves the declared document over the base's, are later cards of ADR-0146's Rollout (P1). Flip to `live` on the commit that lands the reader, citing it.", + "children": { + "package": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row): the base package id the declaring item replaces an item of. The pair predicate the write gate will read is `isCustomizationPairOf` (packages/spec/src/kernel/customization-package.ts). The schema refuses a `.custom` id here at parse." + }, + "basedOn": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row). RECORDED, NEVER JUDGED by contract (ADR-0146 D2, 'Decided here' item 2): no door compares it with the installed base, produces an upgrade diff from it, or refuses on it. Its whole reading is a person's (Studio's override chip, the export); it stays `planned` until that reader lands, and must never gain a gate." + } + } } } } diff --git a/packages/spec/liveness/flow.json b/packages/spec/liveness/flow.json index 1588c75aaa0..0ec231b2a9e 100644 --- a/packages/spec/liveness/flow.json +++ b/packages/spec/liveness/flow.json @@ -148,6 +148,23 @@ "evidence": "packages/services/service-automation/src/engine.ts" } } + }, + "overrides": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "PLANNED RECORD (ADR-0146 D2, the spec card N-08): the override declaration `overrides: { package, basedOn }`, one field set (`OverrideDeclarationFields`, packages/spec/src/kernel/override-declaration.zod.ts) spread on the thirteen types `OVERRIDE_TYPE_CLASSIFICATION` classifies `overridable`. CARRIED today, measured: it survives the metadata save's schema parse, `defineStack()` and `GET /packages/:id/export` (packages/spec/src/kernel/override-declaration.test.ts; packages/runtime/src/domains/packages-export-override-declaration.test.ts). ACTED ON by nothing yet: the write gate that admits a declared override from the paired `.custom` and refuses an undeclared same-named item, and the effective read that serves the declared document over the base's, are later cards of ADR-0146's Rollout (P1). Flip to `live` on the commit that lands the reader, citing it.", + "children": { + "package": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row): the base package id the declaring item replaces an item of. The pair predicate the write gate will read is `isCustomizationPairOf` (packages/spec/src/kernel/customization-package.ts). The schema refuses a `.custom` id here at parse." + }, + "basedOn": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row). RECORDED, NEVER JUDGED by contract (ADR-0146 D2, 'Decided here' item 2): no door compares it with the installed base, produces an upgrade diff from it, or refuses on it. Its whole reading is a person's (Studio's override chip, the export); it stays `planned` until that reader lands, and must never gain a gate." + } + } } } } diff --git a/packages/spec/liveness/mapping.json b/packages/spec/liveness/mapping.json index bd4c342d96b..151a3752ef1 100644 --- a/packages/spec/liveness/mapping.json +++ b/packages/spec/liveness/mapping.json @@ -95,6 +95,23 @@ } } } + }, + "overrides": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "PLANNED RECORD (ADR-0146 D2, the spec card N-08): the override declaration `overrides: { package, basedOn }`, one field set (`OverrideDeclarationFields`, packages/spec/src/kernel/override-declaration.zod.ts) spread on the thirteen types `OVERRIDE_TYPE_CLASSIFICATION` classifies `overridable`. CARRIED today, measured: it survives the metadata save's schema parse, `defineStack()` and `GET /packages/:id/export` (packages/spec/src/kernel/override-declaration.test.ts; packages/runtime/src/domains/packages-export-override-declaration.test.ts). ACTED ON by nothing yet: the write gate that admits a declared override from the paired `.custom` and refuses an undeclared same-named item, and the effective read that serves the declared document over the base's, are later cards of ADR-0146's Rollout (P1). Flip to `live` on the commit that lands the reader, citing it.", + "children": { + "package": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row): the base package id the declaring item replaces an item of. The pair predicate the write gate will read is `isCustomizationPairOf` (packages/spec/src/kernel/customization-package.ts). The schema refuses a `.custom` id here at parse." + }, + "basedOn": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row). RECORDED, NEVER JUDGED by contract (ADR-0146 D2, 'Decided here' item 2): no door compares it with the installed base, produces an upgrade diff from it, or refuses on it. Its whole reading is a person's (Studio's override chip, the export); it stays `planned` until that reader lands, and must never gain a gate." + } + } } } } diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index 127601affc5..33fc213648f 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -241,6 +241,23 @@ "slots": { "status": "live", "note": "slotted-page override map (header/actions/alerts/highlights/details/discussion; the tabs slot is a tombstone) — objectui plugin-detail; Preview render in objectui#1948. Verified in the Studio Design slot editor during the dogfood." + }, + "overrides": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "PLANNED RECORD (ADR-0146 D2, the spec card N-08): the override declaration `overrides: { package, basedOn }`, one field set (`OverrideDeclarationFields`, packages/spec/src/kernel/override-declaration.zod.ts) spread on the thirteen types `OVERRIDE_TYPE_CLASSIFICATION` classifies `overridable`. CARRIED today, measured: it survives the metadata save's schema parse, `defineStack()` and `GET /packages/:id/export` (packages/spec/src/kernel/override-declaration.test.ts; packages/runtime/src/domains/packages-export-override-declaration.test.ts). ACTED ON by nothing yet: the write gate that admits a declared override from the paired `.custom` and refuses an undeclared same-named item, and the effective read that serves the declared document over the base's, are later cards of ADR-0146's Rollout (P1). Flip to `live` on the commit that lands the reader, citing it.", + "children": { + "package": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row): the base package id the declaring item replaces an item of. The pair predicate the write gate will read is `isCustomizationPairOf` (packages/spec/src/kernel/customization-package.ts). The schema refuses a `.custom` id here at parse." + }, + "basedOn": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row). RECORDED, NEVER JUDGED by contract (ADR-0146 D2, 'Decided here' item 2): no door compares it with the installed base, produces an upgrade diff from it, or refuses on it. Its whole reading is a person's (Studio's override chip, the export); it stays `planned` until that reader lands, and must never gain a gate." + } + } } } } diff --git a/packages/spec/liveness/report.json b/packages/spec/liveness/report.json index 0b47eb9090e..e654fb4e9a7 100644 --- a/packages/spec/liveness/report.json +++ b/packages/spec/liveness/report.json @@ -124,6 +124,23 @@ "status": "live", "verifiedAt": "2026-09-27", "note": "objectui: joined-report sub-reports, each dataset-bound — DatasetReportRenderer joined branch (:1462-1524 at the `.objectui-sha` pin f8a9d0fb) reads a block's dataset/rows/columns/values/runtimeFilter/order/type/label/description/name and draws it as a table. A block has NO chart: `chart` was removed from JoinedReportBlockSchema in #20161 because no renderer ever read it (`block.chart`: zero hits over objectui packages/ at the pin, against a lit `block.runtimeFilter` control). A block with no `dataset` (packages/spec/src/ui/report.zod.ts joinedBlockDatasetRequired, #21702; read point moved out of spec source, #23434): at objectui @ab1879721595 packages/plugin-report/src/DatasetReportRenderer.tsx#DatasetReportRenderer the joined branch hands each block's dataset to its table as `String(block.dataset ?? '')` and the table's query hook goes idle on an empty name; a report whose blocks all lack one fails objectui @ab1879721595 packages/plugin-report/src/DatasetReportRenderer.tsx#isDatasetReport and falls through to the pre-9.0 presentation bridge, which queries nothing." + }, + "overrides": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "PLANNED RECORD (ADR-0146 D2, the spec card N-08): the override declaration `overrides: { package, basedOn }`, one field set (`OverrideDeclarationFields`, packages/spec/src/kernel/override-declaration.zod.ts) spread on the thirteen types `OVERRIDE_TYPE_CLASSIFICATION` classifies `overridable`. CARRIED today, measured: it survives the metadata save's schema parse, `defineStack()` and `GET /packages/:id/export` (packages/spec/src/kernel/override-declaration.test.ts; packages/runtime/src/domains/packages-export-override-declaration.test.ts). ACTED ON by nothing yet: the write gate that admits a declared override from the paired `.custom` and refuses an undeclared same-named item, and the effective read that serves the declared document over the base's, are later cards of ADR-0146's Rollout (P1). Flip to `live` on the commit that lands the reader, citing it.", + "children": { + "package": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row): the base package id the declaring item replaces an item of. The pair predicate the write gate will read is `isCustomizationPairOf` (packages/spec/src/kernel/customization-package.ts). The schema refuses a `.custom` id here at parse." + }, + "basedOn": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row). RECORDED, NEVER JUDGED by contract (ADR-0146 D2, 'Decided here' item 2): no door compares it with the installed base, produces an upgrade diff from it, or refuses on it. Its whole reading is a person's (Studio's override chip, the export); it stays `planned` until that reader lands, and must never gain a gate." + } + } } } } diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index d44ea8eb3e9..e54afd341ee 100644 --- a/packages/spec/liveness/state-counts/action.md +++ b/packages/spec/liveness/state-counts/action.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `action` | 50 | 0 | 0 | 1 | 0 | 51 | +| `action` | 50 | 0 | 0 | 1 | 2 | 53 | diff --git a/packages/spec/liveness/state-counts/app.md b/packages/spec/liveness/state-counts/app.md index 0e3986f5688..40afbeaa9d2 100644 --- a/packages/spec/liveness/state-counts/app.md +++ b/packages/spec/liveness/state-counts/app.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `app` | 51 | 0 | 0 | 0 | 6 | 57 | +| `app` | 51 | 0 | 0 | 0 | 8 | 59 | diff --git a/packages/spec/liveness/state-counts/book.md b/packages/spec/liveness/state-counts/book.md index ec8aac4381d..5daef479ace 100644 --- a/packages/spec/liveness/state-counts/book.md +++ b/packages/spec/liveness/state-counts/book.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `book` | 20 | 0 | 0 | 1 | 0 | 21 | +| `book` | 20 | 0 | 0 | 1 | 2 | 23 | diff --git a/packages/spec/liveness/state-counts/dashboard.md b/packages/spec/liveness/state-counts/dashboard.md index fa8b7e19b60..7591516931c 100644 --- a/packages/spec/liveness/state-counts/dashboard.md +++ b/packages/spec/liveness/state-counts/dashboard.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `dashboard` | 43 | 0 | 0 | 6 | 0 | 49 | +| `dashboard` | 43 | 0 | 0 | 6 | 2 | 51 | diff --git a/packages/spec/liveness/state-counts/dataset.md b/packages/spec/liveness/state-counts/dataset.md index ffaf19bda9b..605c97366fa 100644 --- a/packages/spec/liveness/state-counts/dataset.md +++ b/packages/spec/liveness/state-counts/dataset.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `dataset` | 32 | 0 | 0 | 0 | 0 | 32 | +| `dataset` | 32 | 0 | 0 | 0 | 2 | 34 | diff --git a/packages/spec/liveness/state-counts/doc.md b/packages/spec/liveness/state-counts/doc.md index 314a315a461..827b54dba27 100644 --- a/packages/spec/liveness/state-counts/doc.md +++ b/packages/spec/liveness/state-counts/doc.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `doc` | 15 | 0 | 0 | 0 | 0 | 15 | +| `doc` | 15 | 0 | 0 | 0 | 2 | 17 | diff --git a/packages/spec/liveness/state-counts/email_template.md b/packages/spec/liveness/state-counts/email_template.md index df1fc9eb239..3d9d0a6cf8f 100644 --- a/packages/spec/liveness/state-counts/email_template.md +++ b/packages/spec/liveness/state-counts/email_template.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `email_template` | 19 | 0 | 0 | 2 | 0 | 21 | +| `email_template` | 19 | 0 | 0 | 2 | 2 | 23 | diff --git a/packages/spec/liveness/state-counts/flow.md b/packages/spec/liveness/state-counts/flow.md index b72830085ef..52cafd01c7c 100644 --- a/packages/spec/liveness/state-counts/flow.md +++ b/packages/spec/liveness/state-counts/flow.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `flow` | 36 | 0 | 0 | 0 | 0 | 36 | +| `flow` | 36 | 0 | 0 | 0 | 2 | 38 | diff --git a/packages/spec/liveness/state-counts/mapping.md b/packages/spec/liveness/state-counts/mapping.md index 9543eb8a502..eed9ad72705 100644 --- a/packages/spec/liveness/state-counts/mapping.md +++ b/packages/spec/liveness/state-counts/mapping.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `mapping` | 20 | 0 | 0 | 0 | 0 | 20 | +| `mapping` | 20 | 0 | 0 | 0 | 2 | 22 | diff --git a/packages/spec/liveness/state-counts/page.md b/packages/spec/liveness/state-counts/page.md index d66b358c1b2..2bbef13717d 100644 --- a/packages/spec/liveness/state-counts/page.md +++ b/packages/spec/liveness/state-counts/page.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `page` | 34 | 0 | 0 | 1 | 7 | 42 | +| `page` | 34 | 0 | 0 | 1 | 9 | 44 | diff --git a/packages/spec/liveness/state-counts/report.md b/packages/spec/liveness/state-counts/report.md index 480128f1475..c4b052c5696 100644 --- a/packages/spec/liveness/state-counts/report.md +++ b/packages/spec/liveness/state-counts/report.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `report` | 40 | 0 | 0 | 1 | 0 | 41 | +| `report` | 40 | 0 | 0 | 1 | 2 | 43 | diff --git a/packages/spec/liveness/state-counts/translation.md b/packages/spec/liveness/state-counts/translation.md index 368254b51fd..4e45674bce4 100644 --- a/packages/spec/liveness/state-counts/translation.md +++ b/packages/spec/liveness/state-counts/translation.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `translation` | 42 | 0 | 0 | 0 | 0 | 42 | +| `translation` | 42 | 0 | 0 | 0 | 2 | 44 | diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index 7f1fa28f5ae..7a6c82fc4fd 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -335,6 +335,23 @@ "verifiedAt": "2026-08-01", "evidence": "ui/apps/console/src/pages/settings/useSettingsLabel.ts (:104 at objectui @940ba24)", "note": "cross-namespace Settings chrome strings (source badges by resolution layer); the client scans every namespace carrying a settingsCommon block." + }, + "overrides": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "PLANNED RECORD (ADR-0146 D2, the spec card N-08): the override declaration `overrides: { package, basedOn }`, one field set (`OverrideDeclarationFields`, packages/spec/src/kernel/override-declaration.zod.ts) spread on the thirteen types `OVERRIDE_TYPE_CLASSIFICATION` classifies `overridable`. CARRIED today, measured: it survives the metadata save's schema parse, `defineStack()` and `GET /packages/:id/export` (packages/spec/src/kernel/override-declaration.test.ts; packages/runtime/src/domains/packages-export-override-declaration.test.ts). ACTED ON by nothing yet: the write gate that admits a declared override from the paired `.custom` and refuses an undeclared same-named item, and the effective read that serves the declared document over the base's, are later cards of ADR-0146's Rollout (P1). Flip to `live` on the commit that lands the reader, citing it.", + "children": { + "package": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row): the base package id the declaring item replaces an item of. The pair predicate the write gate will read is `isCustomizationPairOf` (packages/spec/src/kernel/customization-package.ts). The schema refuses a `.custom` id here at parse." + }, + "basedOn": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `overrides` (see that row). RECORDED, NEVER JUDGED by contract (ADR-0146 D2, 'Decided here' item 2): no door compares it with the installed base, produces an upgrade diff from it, or refuses on it. Its whole reading is a person's (Studio's override chip, the export); it stays `planned` until that reader lands, and must never gain a gate." + } + } } } } diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index 11edee3ccf2..5af8969ab70 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (212 schemas) +## 3. Schema Inventory by Domain (213 schemas) Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number @@ -86,7 +86,7 @@ here is verifiable from the installed package. | Domain | Count | Key Schemas | |--------|-------|-------------| | system | 37 | Auth, Cache, Demo Staff, Dev Login, Email / SMS Config, Encryption, Fate Supplement (organization ownership), HTTP Server, License, Logging, Metrics | -| kernel | 30 | Plugin, Manifest, Events (6 sub-modules), Feature, Context, Package Registry | +| kernel | 31 | Plugin, Manifest, Events (6 sub-modules), Feature, Context, Package Registry, Override Declaration | | data | 32 | Object, Field, Picklist, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube, Seed | | api | 33 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage), Record Page, App Metadata Bundle | | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 086bb631224..d34b8bd97de 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { OverrideDeclarationFields } from '../kernel/override-declaration.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; /** @@ -1361,6 +1362,8 @@ export const FlowSchema = lazySchema(() => strictObject( // ADR-0010 — runtime protection envelope (internal — set by loader). ...MetadataProtectionFields, + // ADR-0146 D2 — the override declaration (authored; travels with the item). + ...OverrideDeclarationFields, }).superRefine((flow, ctx) => { // Two hand-authored id spaces, one rule each, one shape — a later occurrence diff --git a/packages/spec/src/data/field-customization.test.ts b/packages/spec/src/data/field-customization.test.ts new file mode 100644 index 00000000000..4ebf66415c5 --- /dev/null +++ b/packages/spec/src/data/field-customization.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0146 D10 — the `__c` reservation and the base-field display-property + * whitelist, measured against `FieldSchema`. + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import { BASE_FIELD_DISPLAY_WHITELIST, EXTENSION_FIELD_SUFFIX, isExtensionFieldName } from './field-customization'; +import { FieldSchema, SelectOptionSchema } from './field.zod'; + +/** The object shape behind a (possibly optional) schema. */ +function shapeOf(schema: unknown): Record { + const s = schema as { shape?: Record }; + if (!s.shape) throw new Error('not an object schema'); + return s.shape; +} + +/** + * The properties D10 and D6 name as the vendor's, plus every `FieldSchema` key + * whose change alters DDL, a stored value, a validation verdict or a + * permission — the four things a whitelisted property must leave alone. + */ +const STRUCTURAL_FIELD_KEYS = [ + // D10 / D6, named + 'type', 'required', 'unique', 'reference', 'defaultValue', 'expression', 'options', + // DDL / storage + 'name', 'storage', 'multiple', 'maxLength', 'precision', 'scale', 'picklist', 'referenceVia', + 'deleteBehavior', 'referenceDeletedWhen', 'autonumberFormat', 'externalId', 'valueDomain', 'returnType', + // validation verdicts + 'minLength', 'min', 'max', 'requiredWhen', 'accept', 'maxSize', + // permission + 'requiredPermissions', 'readonly', 'readonlyWhen', 'maskingRule', 'hidden', 'internal', 'system', +] as const; +const STRUCTURAL_OPTION_KEYS = ['value'] as const; + +describe('the `__c` reservation', () => { + it('the suffix is `__c` (maintainer: 「扩展字段用 __c 后缀,只有 .custom 包能用。」)', () => { + expect(EXTENSION_FIELD_SUFFIX).toBe('__c'); + }); + + it.each([ + ['region__c', true], + ['annual_budget__c', true], + ['region', false], + ['region_c', false], + ['region__c_2', false], + ['__c', false], + ['', false], + [undefined, false], + ])('isExtensionFieldName(%j) → %s', (name, verdict) => { + expect(isExtensionFieldName(name)).toBe(verdict); + }); + + it('an extension field name is a valid field name — the reservation needs no grammar change', () => { + expect(shapeOf(FieldSchema).name!.safeParse('region__c').success).toBe(true); + }); +}); + +describe('the base-field display-property whitelist', () => { + const fieldShape = shapeOf(FieldSchema); + const optionShape = shapeOf(SelectOptionSchema); + + it('is the class ADR-0146 D10 names: label, description, help text, placeholder, option labels', () => { + expect([...BASE_FIELD_DISPLAY_WHITELIST.field]).toEqual(['label', 'description', 'inlineHelpText', 'placeholder']); + expect([...BASE_FIELD_DISPLAY_WHITELIST.option]).toEqual(['label']); + }); + + it('is closed: frozen at both levels', () => { + expect(Object.isFrozen(BASE_FIELD_DISPLAY_WHITELIST)).toBe(true); + expect(Object.isFrozen(BASE_FIELD_DISPLAY_WHITELIST.field)).toBe(true); + expect(Object.isFrozen(BASE_FIELD_DISPLAY_WHITELIST.option)).toBe(true); + }); + + it.each(BASE_FIELD_DISPLAY_WHITELIST.field.map((k) => [k]))('field member `%s` exists on FieldSchema and is text', (key) => { + expect(Object.keys(fieldShape)).toContain(key); + expect(fieldShape[key]!.safeParse('Display text').success).toBe(true); + }); + + it.each(BASE_FIELD_DISPLAY_WHITELIST.option.map((k) => [k]))('option member `%s` exists on an option of FieldSchema.options and is text', (key) => { + expect(Object.keys(optionShape)).toContain(key); + expect(optionShape[key]!.safeParse('Display text').success).toBe(true); + }); + + it('the option schema measured is the one FieldSchema.options holds', () => { + const parsed = FieldSchema.parse({ type: 'select', options: [{ label: 'Open', value: 'open' }] }); + expect(parsed.options?.[0]).toEqual(expect.objectContaining(SelectOptionSchema.parse({ label: 'Open', value: 'open' }))); + }); + + it('every structural key the test names is a real FieldSchema key (the list cannot rot into a no-op)', () => { + for (const key of STRUCTURAL_FIELD_KEYS) expect(Object.keys(fieldShape), key).toContain(key); + for (const key of STRUCTURAL_OPTION_KEYS) expect(Object.keys(optionShape), key).toContain(key); + }); + + it('no member is structural', () => { + const structural = new Set(STRUCTURAL_FIELD_KEYS); + expect(BASE_FIELD_DISPLAY_WHITELIST.field.filter((k) => structural.has(k))).toEqual([]); + const structuralOption = new Set(STRUCTURAL_OPTION_KEYS); + expect(BASE_FIELD_DISPLAY_WHITELIST.option.filter((k) => structuralOption.has(k))).toEqual([]); + }); +}); diff --git a/packages/spec/src/data/field-customization.ts b/packages/spec/src/data/field-customization.ts new file mode 100644 index 00000000000..52cb532b888 --- /dev/null +++ b/packages/spec/src/data/field-customization.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # Customer extensions of a base object's fields (ADR-0146 D10) + * + * Objects are extended, never replaced. A customer's extension of a base + * object lives in the base's paired `.custom` package, under two rules + * this module spells once: + * + * 1. **Extension fields end in `__c`, and only a `.custom` package may declare + * one** (maintainer, verbatim: 「扩展字段用 __c 后缀,只有 .custom 包能用。」). + * {@link EXTENSION_FIELD_SUFFIX} and {@link isExtensionFieldName} are the + * reservation; refusing the suffix outside `.custom` (at publish, at + * `os validate`, at the install door) is the enforcement card's, not this + * module's. + * 2. **A base field changes only within a display-property whitelist** (Q7). + * {@link BASE_FIELD_DISPLAY_WHITELIST} is that whitelist: the closed constant + * the write gate reads, never a judgement it makes. + * + * Constants and one pure predicate, imports nothing: the shape that carries a + * per-field patch on an object extension, and the merge that applies it, are + * not declared here. + */ + +/** + * The suffix that reserves a field name to the customer's layer: a customer's + * field on `crm_account` is `region__c`, so a vendor's next version can never + * take that name (ADR-0146 D10). A field one vendor package adds to another + * vendor's object keeps the plain spelling; the suffix names the customer's + * layer and nothing else. + */ +export const EXTENSION_FIELD_SUFFIX = '__c'; + +/** + * Whether `name` is spelled as a customer extension field: it ends in + * {@link EXTENSION_FIELD_SUFFIX} after a non-empty stem (`region__c` yes, + * `region` no, a bare `__c` no). Judges the suffix only; whether the name is a + * valid field name at all is `FieldSchema`'s question. + */ +export function isExtensionFieldName(name: unknown): boolean { + return typeof name === 'string' + && name.length > EXTENSION_FIELD_SUFFIX.length + && name.endsWith(EXTENSION_FIELD_SUFFIX); +} + +/** + * The base-field display-property whitelist (ADR-0146 D10, Q7; "Decided here" + * item 4): the properties of a base field a customer's extension may patch. + * + * A property is admitted by D10's test, never by convenience: changing it + * alters **no DDL, no stored value, no validation verdict and no permission**. + * Each member below is a `FieldSchema` key (or, under `option`, a key of one + * entry of a field's `options`) measured against that test: + * + * | member | `FieldSchema` key | why it passes | + * |:--|:--|:--| + * | label | `label` | display text; no column, value, check or grant reads it | + * | description | `description` | tooltip text | + * | help text | `inlineHelpText` | help text shown beside the input | + * | placeholder | `placeholder` | placeholder text of an empty input | + * | option label | `options[].label` | display text of an option, keyed by its stored `value`, which stays the vendor's | + * + * Everything else on a base field — `type`, `required`, `unique`, `reference`, + * `defaultValue`, formulas (`expression`), `options` membership and each + * option's `value` — stays the vendor's (ADR-0146 D6) and is refused on the + * extension with a prescription naming this whitelist. + * + * Deliberately the class D10 names and nothing more. Other display-only keys + * may pass the same test (`rows`, `group`, `widget`, an option's `color`); each + * enters by a recorded decision, because widening a published whitelist is + * reversible and narrowing it is not. `field-customization.test.ts` checks that + * every member exists on `FieldSchema` and that none is structural. + */ +export const BASE_FIELD_DISPLAY_WHITELIST = Object.freeze({ + /** Keys of the base field itself. */ + field: Object.freeze(['label', 'description', 'inlineHelpText', 'placeholder'] as const), + /** Keys of one entry of the base field's `options`, the entry addressed by its stored `value`. */ + option: Object.freeze(['label'] as const), +}); + +/** A base field key the extension may patch. */ +export type BaseFieldDisplayProperty = (typeof BASE_FIELD_DISPLAY_WHITELIST.field)[number]; +/** A key of one base field option the extension may patch. */ +export type BaseFieldOptionDisplayProperty = (typeof BASE_FIELD_DISPLAY_WHITELIST.option)[number]; diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 281e4c9288b..72c2d50dc88 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -180,6 +180,9 @@ export * from './api-derivation'; // so the two can never drift apart (#7521). export * from './managed-api-affordance'; export * from './field.zod'; +// [ADR-0146 D10] The customer extension rules on a base object's fields: the +// `__c` reservation and the base-field display-property whitelist. +export * from './field-customization'; // [ADR-0144 Open item 3] The CLDR fraction digits per currency — the checked-in // CLDR snapshot whose key set is the `iso_4217_currency` value domain. Its // digit counts are CLDR's, which differ from ISO 4217's minor units for some diff --git a/packages/spec/src/data/mapping.zod.ts b/packages/spec/src/data/mapping.zod.ts index 6e2ff1171da..c141571237a 100644 --- a/packages/spec/src/data/mapping.zod.ts +++ b/packages/spec/src/data/mapping.zod.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { strictObject } from '../shared/strict-object'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { OverrideDeclarationFields } from '../kernel/override-declaration.zod'; // `QuerySchema` left with `extractQuery` in 17.0.0 (#4509) — a mapping no // longer carries a query of its own. @@ -442,6 +443,8 @@ export const MappingSchema = lazySchema(() => strictObject({ // they were dropped on every parse: protection metadata lost on round-trip, // and a hard 422 the day this shape closed — which is today. ...MetadataProtectionFields, + // ADR-0146 D2 — the override declaration (authored; travels with the item). + ...OverrideDeclarationFields, })); export type Mapping = z.input; diff --git a/packages/spec/src/kernel/customization-package.test.ts b/packages/spec/src/kernel/customization-package.test.ts new file mode 100644 index 00000000000..7dc41c83d30 --- /dev/null +++ b/packages/spec/src/kernel/customization-package.test.ts @@ -0,0 +1,230 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0146 D2, D3, D6, D9, D10 — the paired `.custom` package's identity + * rules and the closed overridable-type list, as table tests. + */ + +import { describe, expect, it } from 'vitest'; +import { + CUSTOMIZATION_NAMESPACE_SUFFIX, + MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH, + OVERRIDABLE_METADATA_TYPES, + OVERRIDE_TYPE_CLASSIFICATION, + customizationBasePackageId, + customizationDependencyRange, + customizationNamespace, + customizationPackageId, + getOverrideTypeClassification, + isCustomizationPackage, + isCustomizationPairOf, + isOverridableMetadataType, +} from './customization-package'; +import { CUSTOMIZATION_PACKAGE_SUFFIX } from './override-declaration.zod'; +import { ManifestSchema, MANIFEST_ID_PATTERN } from './manifest.zod'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from './metadata-plugin.zod'; +import { deriveNamespaceFromPackageId } from './namespace-prefix'; + +describe('the pair\'s package id', () => { + it('the suffix is `.custom` (maintainer: 「同意 后缀改成 .custom」)', () => { + expect(CUSTOMIZATION_PACKAGE_SUFFIX).toBe('.custom'); + }); + + it.each([ + ['hotcrm', 'hotcrm.custom'], + ['com.acme.crm', 'com.acme.crm.custom'], + ['com.example.showcase', 'com.example.showcase.custom'], + ['com.example.help-desk', 'com.example.help-desk.custom'], + ])('customizationPackageId(%s) → %s, a valid manifest id', (base, pair) => { + expect(customizationPackageId(base)).toEqual({ ok: true, value: pair }); + expect(MANIFEST_ID_PATTERN.test(pair)).toBe(true); + }); + + it.each([ + ['', 'base_id_missing'], + [undefined, 'base_id_missing'], + [42, 'base_id_missing'], + ['hotcrm.custom', 'base_is_customization_package'], + ['com.example.showcase.custom', 'base_is_customization_package'], + ['Com.Acme', 'base_id_invalid'], + ['@acme/crm', 'base_id_invalid'], + ['com..acme', 'base_id_invalid'], + ])('customizationPackageId(%j) is refused: %s', (base, reason) => { + expect(customizationPackageId(base)).toEqual({ ok: false, reason }); + }); + + it.each([ + ['hotcrm.custom', 'hotcrm'], + ['com.example.showcase.custom', 'com.example.showcase'], + ['com.acme.crm', null], + ['custom', null], + ['.custom', null], + ['a.custom.custom', null], + ['Com.Acme.custom', null], + [undefined, null], + ])('customizationBasePackageId(%j) → %j', (id, base) => { + expect(customizationBasePackageId(id)).toBe(base); + expect(isCustomizationPackage(id)).toBe(base !== null); + }); + + it('base-of is the exact inverse of pair-of on every valid base', () => { + for (const base of ['hotcrm', 'com.acme.crm', 'com.example.showcase', 'org.example.help-desk']) { + const pair = customizationPackageId(base); + expect(pair.ok).toBe(true); + if (pair.ok) expect(customizationBasePackageId(pair.value)).toBe(base); + } + }); + + it.each([ + ['com.acme.crm.custom', 'com.acme.crm', true], + ['hotcrm.custom', 'hotcrm', true], + // A dependent package is not the pair, whatever it depends on (ruling A). + ['com.partner.crm-addon', 'com.acme.crm', false], + // The pair of a DIFFERENT base. + ['com.acme.crm.custom', 'com.acme.crmx', false], + ['com.acme.crmx.custom', 'com.acme.crm', false], + // The base itself is not its own pair. + ['com.acme.crm', 'com.acme.crm', false], + // A pair is never paired again. + ['com.acme.crm.custom.custom', 'com.acme.crm.custom', false], + ])('isCustomizationPairOf(%s, %s) → %s', (pair, base, verdict) => { + expect(isCustomizationPairOf(pair, base)).toBe(verdict); + }); +}); + +describe('the pair\'s namespace — `_custom`, never derived', () => { + it('the derived namespace of a pair id is the wrong one (the reason the rule exists)', () => { + expect(deriveNamespaceFromPackageId('hotcrm.custom')).toBe('custom'); + expect(customizationNamespace('crm')).toEqual({ ok: true, value: 'crm_custom' }); + }); + + it('13 is the grammar\'s own limit: 20 characters less the 7 of `_custom`', () => { + const grammar = ManifestSchema.shape.namespace; + const at = 'a'.repeat(MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH) + CUSTOMIZATION_NAMESPACE_SUFFIX; + const past = 'a'.repeat(MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH + 1) + CUSTOMIZATION_NAMESPACE_SUFFIX; + expect(MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH).toBe(13); + expect(grammar.safeParse(at).success).toBe(true); + expect(grammar.safeParse(past).success).toBe(false); + }); + + it.each([ + ['crm', 'crm_custom'], + ['feature_flags', 'feature_flags_custom'], // 13 characters — the longest base in the repository today + ['abcdefghijklm', 'abcdefghijklm_custom'], + ])('customizationNamespace(%s) → %s', (base, pair) => { + expect(customizationNamespace(base)).toEqual({ ok: true, value: pair }); + expect(ManifestSchema.shape.namespace.safeParse(pair).success).toBe(true); + }); + + it.each([ + ['abcdefghijklmn', 'base_namespace_too_long'], // 14 characters + ['a_twenty_char_namesp', 'base_namespace_too_long'], + ['', 'base_namespace_missing'], + [undefined, 'base_namespace_missing'], + ['Crm', 'base_namespace_invalid'], + ['1crm', 'base_namespace_invalid'], + ])('customizationNamespace(%j) is refused: %s — no truncation, no hashing', (base, reason) => { + expect(customizationNamespace(base)).toEqual({ ok: false, reason }); + }); +}); + +describe('the pair\'s dependency range — `^` of the base version', () => { + it.each([ + ['3.2.1', '^3'], + ['1.0.0', '^1'], + ['0.4.0', '^0'], + ['10.0.0', '^10'], + ['2.0.0-beta.1', '^2'], + ['1.4.0+build.7', '^1'], + ['1.0.0-rc.1+exp.sha.5114f85', '^1'], + ])('customizationDependencyRange(%s) → %s', (version, range) => { + expect(customizationDependencyRange(version)).toEqual({ ok: true, value: range }); + }); + + it.each([['v1.0.0'], ['1.0'], ['latest'], [''], ['01.1.1'], [undefined], [3]])( + 'customizationDependencyRange(%j) is refused: base_version_not_semver', + (version) => { + expect(customizationDependencyRange(version)).toEqual({ ok: false, reason: 'base_version_not_semver' }); + }, + ); +}); + +describe('the closed overridable-type list, pinned to the registry', () => { + const registryTypes: string[] = DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type).sort(); + const classified = Object.keys(OVERRIDE_TYPE_CLASSIFICATION).sort(); + + it('classifies every registry type — a type added to the registry without a row here fails', () => { + expect(registryTypes.filter((t) => !classified.includes(t))).toEqual([]); + }); + + it('classifies nothing the registry does not hold', () => { + expect(classified.filter((t) => !registryTypes.includes(t))).toEqual([]); + }); + + it('every runtime-creatable type has a classification (the registry\'s `allowRuntimeCreate` closure)', () => { + const runtimeCreatable = DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => e.allowRuntimeCreate).map((e) => e.type); + expect(runtimeCreatable.length).toBeGreaterThan(0); + for (const type of runtimeCreatable) expect(getOverrideTypeClassification(type), type).toBeDefined(); + }); + + it('every overridable type is runtime-creatable (D2: "Studio or the metadata API can author")', () => { + const byType = new Map(DEFAULT_METADATA_TYPE_REGISTRY.map((e) => [e.type, e])); + for (const type of OVERRIDABLE_METADATA_TYPES) expect(byType.get(type)?.allowRuntimeCreate, type).toBe(true); + }); + + it('a `not_runtime_creatable` row is one the registry really closes', () => { + const byType = new Map(DEFAULT_METADATA_TYPE_REGISTRY.map((e) => [e.type, e])); + for (const [type, row] of Object.entries(OVERRIDE_TYPE_CLASSIFICATION)) { + if (row.basis === 'not_runtime_creatable') expect(byType.get(type)?.allowRuntimeCreate, type).toBe(false); + } + }); + + it('D6: permission sets, positions and capabilities are excluded whatever the registry says', () => { + for (const type of ['permission', 'position', 'capability'] as const) { + expect(OVERRIDE_TYPE_CLASSIFICATION[type]).toEqual({ disposition: 'excluded', basis: 'adr_0146_d6_security_catalog' }); + expect(isOverridableMetadataType(type)).toBe(false); + } + }); + + it('D10: `object` is extended, never replaced', () => { + expect(OVERRIDE_TYPE_CLASSIFICATION.object.disposition).toBe('extend_only'); + expect(isOverridableMetadataType('object')).toBe(false); + }); + + it('D2: the code-only types stay outside', () => { + for (const type of ['field', 'hook', 'job', 'api', 'agent'] as const) { + expect(OVERRIDE_TYPE_CLASSIFICATION[type].basis).toBe('adr_0126_code_only'); + expect(isOverridableMetadataType(type)).toBe(false); + } + }); + + it('D9: flow carries its four conditions and action its ledger switch plus conditions 1–3, as data', () => { + expect(OVERRIDE_TYPE_CLASSIFICATION.flow.conditions).toEqual([ + 'served_over_code_package', 'rebound_in_one_reload', 'ledger_identity_inherited', 'version_pinning', + ]); + expect(OVERRIDE_TYPE_CLASSIFICATION.action.conditions).toEqual([ + 'action_ledger_switch', 'served_over_code_package', 'rebound_in_one_reload', 'ledger_identity_inherited', + ]); + const conditioned = Object.entries(OVERRIDE_TYPE_CLASSIFICATION) + .filter(([, row]) => 'conditions' in row).map(([type]) => type).sort(); + expect(conditioned).toEqual(['action', 'flow']); + }); + + it('the four types ADR-0146 does not name are excluded on the basis the maintainer ruled (#28404)', () => { + expect(OVERRIDE_TYPE_CLASSIFICATION.seed).toEqual({ disposition: 'excluded', basis: 'adr_0126_one_shot' }); + expect(OVERRIDE_TYPE_CLASSIFICATION.datasource).toEqual({ disposition: 'excluded', basis: 'setup_owned_connection' }); + expect(OVERRIDE_TYPE_CLASSIFICATION.tool).toEqual({ disposition: 'excluded', basis: 'behavioural_switch_first' }); + expect(OVERRIDE_TYPE_CLASSIFICATION.skill).toEqual({ disposition: 'excluded', basis: 'behavioural_switch_first' }); + // No row is left undecided: a type added later is classified with a + // decided basis in the same change. + const bases = new Set(Object.values(OVERRIDE_TYPE_CLASSIFICATION).map((row) => row.basis as string)); + expect(bases.has('undecided')).toBe(false); + }); + + it('isOverridableMetadataType answers false for a name that is no metadata type', () => { + expect(isOverridableMetadataType('view')).toBe(true); + expect(isOverridableMetadataType('not_a_type')).toBe(false); + expect(isOverridableMetadataType('toString')).toBe(false); + expect(getOverrideTypeClassification(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/kernel/customization-package.ts b/packages/spec/src/kernel/customization-package.ts new file mode 100644 index 00000000000..1961bd29774 --- /dev/null +++ b/packages/spec/src/kernel/customization-package.ts @@ -0,0 +1,382 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { MANIFEST_ID_PATTERN, ManifestSchema } from './manifest.zod'; +import type { MetadataType } from './metadata-plugin.zod'; +import { CUSTOMIZATION_PACKAGE_SUFFIX } from './override-declaration.zod'; +import { SEMVER_2_0_0_VERSION_PATTERN } from './version-grammar'; + +/** + * # The paired customization package (ADR-0146 D2, D3, D6) + * + * Every installed base gets one paired writable package, `.custom`, that + * holds the customer's changes to it. This module is the ONE spelling of the + * pair's identity rules — the id, the namespace, the dependency range — and of + * the closed list of metadata types a declared override applies to. The doors + * of ADR-0146's Rollout read it: the ensure door creates the pair from + * {@link customizationPackageId}, {@link customizationNamespace} and + * {@link customizationDependencyRange}; the write gate asks + * {@link isCustomizationPairOf} and {@link OVERRIDE_TYPE_CLASSIFICATION}; the + * uninstall door asks {@link customizationBasePackageId}. ⛔ None of them + * re-derives any of it: a second spelling is a declaration the first one cannot + * be checked against. + * + * Pure functions over strings, no I/O, no registry read: whether a pair is + * INSTALLED is the door's question, never this module's. + * + * Every derivation answers a discriminated result, never a throw and never a + * guess: `{ ok: true, value }`, or `{ ok: false, reason }` naming why the input + * has no answer, so a door refuses with a sentence instead of inventing a + * value (no truncation, no hashing). + */ + +// ───────────────────────────────────────────────────────────────────── +// Results +// ───────────────────────────────────────────────────────────────────── + +/** A derivation that produced its value. */ +export interface CustomizationDerived { + ok: true; + value: string; +} + +/** + * Why a base has no paired package id: + * - `base_id_missing` — the base id is empty or not a string; + * - `base_is_customization_package` — the base is itself a `.custom` package, + * and a pair is never paired again; + * - `base_id_invalid` — `.custom` would not be a valid package id + * (`MANIFEST_ID_PATTERN`: lowercase dot-separated segments). + */ +export type CustomizationPackageIdRefusal = + | 'base_id_missing' + | 'base_is_customization_package' + | 'base_id_invalid'; + +/** + * Why a base namespace has no pair namespace: + * - `base_namespace_missing` — empty or not a string; + * - `base_namespace_too_long` — longer than + * {@link MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH}, so `_custom` + * exceeds the manifest namespace grammar's 20 characters; + * - `base_namespace_invalid` — `_custom` fails the manifest namespace + * grammar for any other reason (an uppercase letter, a leading digit). + */ +export type CustomizationNamespaceRefusal = + | 'base_namespace_missing' + | 'base_namespace_too_long' + | 'base_namespace_invalid'; + +/** Why a base version yields no dependency range: it is not a SemVer 2.0.0 version. */ +export type CustomizationDependencyRangeRefusal = 'base_version_not_semver'; + +/** A derivation that has no value, and the named reason. */ +export interface CustomizationRefused { + ok: false; + reason: R; +} + +export type CustomizationDerivation = CustomizationDerived | CustomizationRefused; + +// ───────────────────────────────────────────────────────────────────── +// The pair's package id (ADR-0146 D3) +// ───────────────────────────────────────────────────────────────────── + +/** + * The paired customization package id of a base: `.custom`. + * + * | base | answer | + * |:--|:--| + * | `hotcrm` | `hotcrm.custom` | + * | `com.example.showcase` | `com.example.showcase.custom` | + * | `hotcrm.custom` | refused — `base_is_customization_package` | + * | `Com.Acme`, `@acme/crm` | refused — `base_id_invalid` | + */ +export function customizationPackageId( + baseId: unknown, +): CustomizationDerivation { + if (typeof baseId !== 'string' || baseId.length === 0) return { ok: false, reason: 'base_id_missing' }; + if (baseId.endsWith(CUSTOMIZATION_PACKAGE_SUFFIX)) return { ok: false, reason: 'base_is_customization_package' }; + const pair = `${baseId}${CUSTOMIZATION_PACKAGE_SUFFIX}`; + if (!MANIFEST_ID_PATTERN.test(pair)) return { ok: false, reason: 'base_id_invalid' }; + return { ok: true, value: pair }; +} + +/** + * The base of a paired customization package id, or `null` when `id` is not + * one: `com.example.showcase.custom` → `com.example.showcase`. Exactly the + * inverse of {@link customizationPackageId}: an id is a pair iff some base + * derives it, so `a.custom.custom` (whose would-be base is itself a pair) and + * the bare `.custom` have no base. + */ +export function customizationBasePackageId(id: unknown): string | null { + if (typeof id !== 'string' || !id.endsWith(CUSTOMIZATION_PACKAGE_SUFFIX)) return null; + const base = id.slice(0, -CUSTOMIZATION_PACKAGE_SUFFIX.length); + const derived = customizationPackageId(base); + return derived.ok && derived.value === id ? base : null; +} + +/** Whether `id` is a paired customization package id (some base derives it). */ +export function isCustomizationPackage(id: unknown): boolean { + return customizationBasePackageId(id) !== null; +} + +/** + * Whether `pairId` is THE paired customization package of `baseId` — the + * predicate the write gate reads for an override declaration: only the paired + * `.custom` declares an override of ``'s items (the ruling recorded + * on ADR-0146's amendment card, ruling A). + */ +export function isCustomizationPairOf(pairId: unknown, baseId: unknown): boolean { + const derived = customizationPackageId(baseId); + return derived.ok && derived.value === pairId; +} + +// ───────────────────────────────────────────────────────────────────── +// The pair's namespace (ADR-0146 D3, Q4) +// ───────────────────────────────────────────────────────────────────── + +/** The suffix the pair's namespace adds to the base's: `crm` → `crm_custom`. */ +export const CUSTOMIZATION_NAMESPACE_SUFFIX = '_custom'; + +/** + * The longest base namespace that has a pair namespace: the manifest namespace + * grammar admits 20 characters, and {@link CUSTOMIZATION_NAMESPACE_SUFFIX} + * takes 7. `customization-package.test.ts` pins this number against the + * grammar itself (`ManifestSchema.shape.namespace`), so the two cannot drift. + */ +export const MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH = 13; + +/** + * The pair's namespace: `_custom`, set explicitly by the ensure door + * and never derived from the pair's id (ADR-0146 D3: + * `deriveNamespaceFromPackageId('hotcrm.custom')` answers `custom`, which is + * never written). + * + * A base namespace longer than {@link MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH} + * gets NO pair namespace — `base_namespace_too_long`, and the ensure door + * refuses that base with a sentence. ⛔ No truncation and no hashing: either is + * a second spelling of the pair's namespace that the declaration cannot be + * checked against. (ADR-0146 is silent on this edge; it was decided by the + * spec card and listed for the maintainer.) + * + * The answer is judged against the manifest's own namespace grammar + * (`ManifestSchema.shape.namespace`), so a value this returns is always one a + * manifest accepts. + */ +export function customizationNamespace( + baseNamespace: unknown, +): CustomizationDerivation { + if (typeof baseNamespace !== 'string' || baseNamespace.length === 0) { + return { ok: false, reason: 'base_namespace_missing' }; + } + if (baseNamespace.length > MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH) { + return { ok: false, reason: 'base_namespace_too_long' }; + } + const pair = `${baseNamespace}${CUSTOMIZATION_NAMESPACE_SUFFIX}`; + if (!ManifestSchema.shape.namespace.safeParse(pair).success) { + return { ok: false, reason: 'base_namespace_invalid' }; + } + return { ok: true, value: pair }; +} + +// ───────────────────────────────────────────────────────────────────── +// The pair's dependency range (ADR-0146 D3, "Decided here" item 1) +// ───────────────────────────────────────────────────────────────────── + +/** + * The range the pair declares on its base in `manifest.dependencies`: + * `^` of the base's `manifest.version` at creation (ADR-0146 D3 and + * "Decided here" item 1) — it admits the base's later minor and patch versions + * at a re-install and refuses a different major. + * + * | base version | answer | + * |:--|:--| + * | `3.2.1` | `^3` | + * | `0.4.0` | `^0` (every `0.x`) | + * | `2.0.0-beta.1`, `1.4.0+build.7` | `^2`, `^1` — the major of the numeric core | + * | `v1.0.0`, `1.0`, `latest` | refused — `base_version_not_semver` | + * + * A prerelease or build-metadata base answers the major of its numeric core, + * the ADR's words read literally. Note what that range then admits: the + * ADR-0141 D2 install check reads an installed version without a prerelease + * tag only, so a pair whose base is installed at a prerelease is re-installable + * once the base is at a release of that major. + */ +export function customizationDependencyRange( + baseVersion: unknown, +): CustomizationDerivation { + if (typeof baseVersion !== 'string') return { ok: false, reason: 'base_version_not_semver' }; + const match = SEMVER_2_0_0_VERSION_PATTERN.exec(baseVersion); + if (!match) return { ok: false, reason: 'base_version_not_semver' }; + return { ok: true, value: `^${match[1]}` }; +} + +// ───────────────────────────────────────────────────────────────────── +// The overridable-type list (ADR-0146 D2, D6, D9, D10) +// ───────────────────────────────────────────────────────────────────── + +/** + * What a declared override may do to a type's base item: + * - `overridable` — a `.custom` item of the base item's name, carrying + * `overrides`, replaces it whole (subject to `conditions`); + * - `extend_only` — the base item is extended, never replaced (`object`, + * ADR-0146 D10); + * - `excluded` — no override channel. + */ +export type OverrideDisposition = 'overridable' | 'extend_only' | 'excluded'; + +/** + * A condition ADR-0146 D9 sets before an override door for a behavioural type + * opens. Carried as data so the write gate reads it and never re-derives it: + * - `served_over_code_package` — D9.1: the declared item is armed over the + * loader's body for the name; + * - `rebound_in_one_reload` — D9.2: the trigger is moved from the base's + * definition to the override's in one reload step; + * - `ledger_identity_inherited` — D9.3: one `sys_metadata_activation` row + * switches whichever definition is armed for the name; + * - `version_pinning` — D9.4: a suspended run resumes on the version it started + * on (flow only); + * - `action_ledger_switch` — D9's `action` paragraph: the action ledger switch + * lands first (outside ADR-0146's epic). + */ +export type OverrideCondition = + | 'served_over_code_package' + | 'rebound_in_one_reload' + | 'ledger_identity_inherited' + | 'version_pinning' + | 'action_ledger_switch'; + +/** + * Where a classification comes from: + * - `adr_0146_d2_listed` — named in D2's "Which types" list; + * - `adr_0146_d6_security_catalog` — D6: one name per deployment (permission + * sets, positions, capabilities); + * - `adr_0146_d10_extended` — D10: objects are extended, never replaced; + * - `adr_0126_code_only` — D2 and D6: the types ADR-0126 §3 places outside the + * regimes as code gain no override channel; + * - `not_package_shipped` — D2's first sentence fails: packages never ship the + * type as a standalone document; + * - `not_runtime_creatable` — D2's first sentence fails: neither Studio nor the + * metadata API can author the type (`allowRuntimeCreate: false`); + * - `adr_0126_one_shot` — ADR-0126 §3 places the type outside as one-shot: it + * is applied once at install or publish and holds nothing durable to + * override; a customization adds its own item under a new name instead + * (`seed`); + * - `setup_owned_connection` — ADR-0146 D1 gives the type to Setup, not to the + * metadata a package carries, and ADR-0126 §3 makes it origin-gated: one + * physical connection is environment configuration that must not travel in + * an exported `.custom` package (`datasource`); + * - `behavioural_switch_first` — a behavioural type takes `action`'s path + * (ADR-0146 D9): its ledger switch lands first and an override door opens + * later, under D9-style conditions written for it then (`tool`, `skill`; + * ADR-0126 §3 pre-charts both for disable + clone). + * + * The maintainer ruled the last three on #28404 (「同意」, 2026-10-31): no row + * is left undecided, and a type added later is classified with a decided basis + * in the same change. + */ +export type OverrideClassificationBasis = + | 'adr_0146_d2_listed' + | 'adr_0146_d6_security_catalog' + | 'adr_0146_d10_extended' + | 'adr_0126_code_only' + | 'not_package_shipped' + | 'not_runtime_creatable' + | 'adr_0126_one_shot' + | 'setup_owned_connection' + | 'behavioural_switch_first'; + +/** One type's override classification. */ +export interface OverrideTypeClassification { + readonly disposition: OverrideDisposition; + readonly basis: OverrideClassificationBasis; + /** D9 conditions an override door for this type waits for (behavioural types only). */ + readonly conditions?: readonly OverrideCondition[]; +} + +const listed: OverrideTypeClassification = { disposition: 'overridable', basis: 'adr_0146_d2_listed' }; +const securityCatalog: OverrideTypeClassification = { disposition: 'excluded', basis: 'adr_0146_d6_security_catalog' }; +const codeOnly: OverrideTypeClassification = { disposition: 'excluded', basis: 'adr_0126_code_only' }; +const notRuntimeCreatable: OverrideTypeClassification = { disposition: 'excluded', basis: 'not_runtime_creatable' }; +const behaviouralSwitchFirst: OverrideTypeClassification = { disposition: 'excluded', basis: 'behavioural_switch_first' }; + +/** + * The CLOSED override classification of every metadata type — the constant the + * write gate reads, never a judgement it makes (ADR-0146 D2: "the closed list + * is a constant the write gate reads"). + * + * Total over `MetadataType` at compile time and pinned to + * `DEFAULT_METADATA_TYPE_REGISTRY` by `customization-package.test.ts`, which + * fails when a registry type is added without a row here, when a row names no + * registry type, and when an `overridable` row is not runtime-creatable. + * + * Four runtime-creatable types ADR-0146 does not name (`seed`, `datasource`, + * `tool`, `skill`) are `excluded` with the basis the maintainer ruled on + * #28404 (「同意」, 2026-10-31). + */ +export const OVERRIDE_TYPE_CLASSIFICATION = { + // ── D2's list ────────────────────────────────────────────────────── + view: listed, + dashboard: listed, + report: listed, + translation: listed, + email_template: listed, + page: listed, + app: listed, + dataset: listed, + doc: listed, + book: listed, + mapping: listed, + flow: { + disposition: 'overridable', + basis: 'adr_0146_d2_listed', + conditions: ['served_over_code_package', 'rebound_in_one_reload', 'ledger_identity_inherited', 'version_pinning'], + }, + action: { + disposition: 'overridable', + basis: 'adr_0146_d2_listed', + conditions: ['action_ledger_switch', 'served_over_code_package', 'rebound_in_one_reload', 'ledger_identity_inherited'], + }, + // ── D10: extended, never replaced ────────────────────────────────── + object: { disposition: 'extend_only', basis: 'adr_0146_d10_extended' }, + // ── D6: the security catalog's one-name-per-deployment types ────── + permission: securityCatalog, + position: securityCatalog, + capability: securityCatalog, + // ── D2 / D6: code-only types stay outside ────────────────────────── + field: codeOnly, + hook: codeOnly, + job: codeOnly, + api: codeOnly, + agent: codeOnly, + // ── D2's first sentence: not a package-shipped, runtime-authorable document + external_catalog: { disposition: 'excluded', basis: 'not_package_shipped' }, + picklist: notRuntimeCreatable, + sharing_rule: notRuntimeCreatable, + queue: notRuntimeCreatable, + assignment_rule: notRuntimeCreatable, + // ── Not named by ADR-0146 — ruled by the maintainer on #28404 ────── + seed: { disposition: 'excluded', basis: 'adr_0126_one_shot' }, + datasource: { disposition: 'excluded', basis: 'setup_owned_connection' }, + tool: behaviouralSwitchFirst, + skill: behaviouralSwitchFirst, +} as const satisfies Record; + +/** The metadata types a declared override applies to (`disposition: 'overridable'`). */ +export const OVERRIDABLE_METADATA_TYPES: readonly MetadataType[] = Object.freeze( + (Object.keys(OVERRIDE_TYPE_CLASSIFICATION) as MetadataType[]).filter( + (type) => OVERRIDE_TYPE_CLASSIFICATION[type].disposition === 'overridable', + ), +); + +/** The classification of `type`, or `undefined` for a name that is no metadata type. */ +export function getOverrideTypeClassification(type: unknown): OverrideTypeClassification | undefined { + return typeof type === 'string' && Object.prototype.hasOwnProperty.call(OVERRIDE_TYPE_CLASSIFICATION, type) + ? OVERRIDE_TYPE_CLASSIFICATION[type as MetadataType] + : undefined; +} + +/** Whether a declared override applies to `type` (its disposition is `overridable`). */ +export function isOverridableMetadataType(type: unknown): boolean { + return getOverrideTypeClassification(type)?.disposition === 'overridable'; +} diff --git a/packages/spec/src/kernel/index.ts b/packages/spec/src/kernel/index.ts index baf1437875c..07d2f09a19f 100644 --- a/packages/spec/src/kernel/index.ts +++ b/packages/spec/src/kernel/index.ts @@ -36,6 +36,11 @@ export * from './platform-capabilities'; export * from './metadata-loader.zod'; export * from './metadata-plugin.zod'; export * from './metadata-protection.zod'; +// [ADR-0146 D2, D3] The override declaration every overridable type spreads, +// and the paired `.custom` package's identity rules and the closed +// overridable-type list the doors read. +export * from './override-declaration.zod'; +export * from './customization-package'; // The read path's OWN annotations (`_diagnostics`, `_draft`) — the underscore // keys that, unlike the protection envelope above, must never survive back into // a persisted body or a strict re-parse (#4326, cloud#971). diff --git a/packages/spec/src/kernel/override-declaration.test.ts b/packages/spec/src/kernel/override-declaration.test.ts new file mode 100644 index 00000000000..74f29e82b99 --- /dev/null +++ b/packages/spec/src/kernel/override-declaration.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0146 D2 — the override declaration `overrides: { package, basedOn }`. + * + * Three facts, each measured rather than assumed: + * + * 1. the shape accepts the declaration and refuses every malformed one; + * 2. every type the closed list classifies `overridable` carries the key + * through the parse the metadata door's save runs (the stored body), and a + * type it excludes refuses it by name; + * 3. the key survives `defineStack()` and the package export's view channel. + * (The export's own top-level strip is pinned in `@objectstack/runtime`, + * `domains/packages-export-override-declaration.test.ts`.) + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { OverrideDeclarationSchema, OverrideDeclarationFields, CUSTOMIZATION_PACKAGE_SUFFIX } from './override-declaration.zod'; +import { OVERRIDE_TYPE_CLASSIFICATION, OVERRIDABLE_METADATA_TYPES } from './customization-package'; +import { getMetadataTypeSchema } from './metadata-type-schemas'; +import { getMetadataCreateSeed } from './metadata-create-seeds'; +import { MetadataProtectionFields } from './metadata-protection.zod'; +import { AssembledViewArtifactSchema } from '../ui/assembled-views.zod'; +import { defineStack } from '../stack.zod'; +import type { MetadataType } from './metadata-plugin.zod'; + +const DECLARATION = { package: 'com.acme.crm', basedOn: '3.2.0' } as const; + +/** The issue paths and codes of a failed parse, for a located assertion. */ +function issuesOf(result: { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[]; keys?: string[] }> } }) { + return (result.error?.issues ?? []).map((i) => ({ code: i.code, path: i.path.join('.'), keys: i.keys })); +} + +describe('OverrideDeclarationSchema — the shape', () => { + it('accepts { package, basedOn }', () => { + expect(OverrideDeclarationSchema.parse(DECLARATION)).toEqual(DECLARATION); + }); + + it('accepts a dotted base id and a prerelease basedOn — basedOn is recorded, never judged', () => { + const d = { package: 'com.example.showcase', basedOn: 'anything a person wrote' }; + expect(OverrideDeclarationSchema.safeParse(d).success).toBe(true); + }); + + it.each([ + ['no package', { basedOn: '3.2.0' }, 'package'], + ['no basedOn', { package: 'com.acme.crm' }, 'basedOn'], + ['an empty package', { package: '', basedOn: '3.2.0' }, 'package'], + ['an empty basedOn', { package: 'com.acme.crm', basedOn: '' }, 'basedOn'], + ['a non-string basedOn', { package: 'com.acme.crm', basedOn: 3 }, 'basedOn'], + ])('refuses %s, at that key', (_label, body, key) => { + const r = OverrideDeclarationSchema.safeParse(body); + expect(r.success).toBe(false); + expect(issuesOf(r).map((i) => i.path)).toContain(key); + }); + + it('refuses an unknown key by name — a misspelt key is never dropped', () => { + const r = OverrideDeclarationSchema.safeParse({ ...DECLARATION, basedon: '3.2.0' }); + expect(r.success).toBe(false); + expect(issuesOf(r)).toContainEqual(expect.objectContaining({ code: 'unrecognized_keys', keys: ['basedon'] })); + }); + + it(`refuses a \`${CUSTOMIZATION_PACKAGE_SUFFIX}\` package — the declaration names the BASE, never a pair`, () => { + const r = OverrideDeclarationSchema.safeParse({ package: `com.acme.crm${CUSTOMIZATION_PACKAGE_SUFFIX}`, basedOn: '3.2.0' }); + expect(r.success).toBe(false); + expect(issuesOf(r)).toContainEqual(expect.objectContaining({ code: 'invalid_format', path: 'package' })); + }); + + it('accepts an id that merely contains the suffix\'s letters', () => { + for (const id of ['com.acme.customx', 'com.acme.custom-crm', 'custom']) { + expect(OverrideDeclarationSchema.safeParse({ package: id, basedOn: '1.0.0' }).success, id).toBe(true); + } + }); + + it('publishes the refusal into the JSON Schema as a pattern (no dropped refinement)', () => { + const json = z.toJSONSchema(OverrideDeclarationSchema) as unknown as { properties: { package: { pattern?: string } } }; + const pattern = new RegExp(json.properties.package.pattern ?? ''); + expect(pattern.test('com.acme.crm')).toBe(true); + expect(pattern.test(`com.acme.crm${CUSTOMIZATION_PACKAGE_SUFFIX}`)).toBe(false); + }); + + it('describes basedOn as recorded and never judged (ADR-0146 D2)', () => { + const described = OverrideDeclarationSchema.shape.basedOn.description ?? ''; + expect(described).toMatch(/Recorded, never judged/); + expect(OverrideDeclarationFields.overrides.description ?? '').toMatch(/recorded and never judged/); + }); + + it('is NOT part of the ADR-0010 envelope the registration and clone paths strip', () => { + // `Object.keys(MetadataProtectionFields)` is the set `artifact-collections.ts` + // and `flow-clone.ts` drop as loader-stamped state. The declaration is authored. + expect(Object.keys(MetadataProtectionFields)).not.toContain('overrides'); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// The stored body: the parse the metadata door's save runs +// ───────────────────────────────────────────────────────────────────── + +/** Minimal valid bodies for the overridable types with no create seed. */ +const EXTRA_FIXTURES: Partial>> = { + report: { name: 'sales_by_stage', label: 'Sales by Stage', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'] }, + app: { name: 'crm', label: 'CRM', navigation: [] }, + doc: { name: 'crm_lead_guide', content: '# Lead Guide' }, + book: { name: 'crm_guide', groups: [{ key: 'start', label: 'Start', include: 'crm_*' }] }, +}; + +function fixtureFor(type: MetadataType): Record { + const seed = getMetadataCreateSeed(type) as Record | undefined; + const body = seed ?? EXTRA_FIXTURES[type]; + if (!body) throw new Error(`no fixture for overridable type '${type}'`); + return structuredClone(body); +} + +/** The flattened list overlay — the package-less overlay row's shape ADR-0146 D11 migrates. */ +const LIST_OVERLAY = { + type: 'grid', + data: { provider: 'object', object: 'crm_account' }, + columns: ['name'], + name: 'crm_account.default', + viewKind: 'list', + object: 'crm_account', +}; + +describe('the stored body — every overridable type carries `overrides` through its metadata schema', () => { + it('the overridable list is the thirteen types ADR-0146 D2 names', () => { + expect([...OVERRIDABLE_METADATA_TYPES].sort()).toEqual([ + 'action', 'app', 'book', 'dashboard', 'dataset', 'doc', 'email_template', + 'flow', 'mapping', 'page', 'report', 'translation', 'view', + ]); + }); + + it.each(OVERRIDABLE_METADATA_TYPES.map((t) => [t]))('%s keeps `overrides` on parse', (type) => { + const schema = getMetadataTypeSchema(type)!; + const body = { ...fixtureFor(type as MetadataType), overrides: { ...DECLARATION } }; + const r = schema.safeParse(body); + expect(issuesOf(r)).toEqual([]); + expect((r.data as Record).overrides).toEqual(DECLARATION); + }); + + it.each(OVERRIDABLE_METADATA_TYPES.map((t) => [t]))('%s refuses a malformed declaration, located under `overrides`', (type) => { + const schema = getMetadataTypeSchema(type)!; + const r = schema.safeParse({ ...fixtureFor(type as MetadataType), overrides: { package: 'com.acme.crm' } }); + expect(r.success).toBe(false); + expect(JSON.stringify(r.error?.issues)).toContain('basedOn'); + }); + + it('a flattened view overlay (the migrated package-less row) keeps `overrides` too', () => { + const r = getMetadataTypeSchema('view')!.safeParse({ ...LIST_OVERLAY, overrides: { ...DECLARATION } }); + expect(issuesOf(r)).toEqual([]); + expect((r.data as Record).overrides).toEqual(DECLARATION); + }); + + // The types the closed list excludes and that have a create seed: the key is + // not declared on them, so a body carrying it is refused by name — never + // silently stored as an override no door honours. + const excludedWithSeed = (Object.keys(OVERRIDE_TYPE_CLASSIFICATION) as MetadataType[]) + .filter((t) => OVERRIDE_TYPE_CLASSIFICATION[t].disposition !== 'overridable' && getMetadataCreateSeed(t) !== undefined); + + it('measures at least the D6 security type, the extend-only type and a code-only type among them', () => { + expect(excludedWithSeed).toEqual(expect.arrayContaining(['permission', 'object', 'hook'])); + }); + + it.each(excludedWithSeed.map((t) => [t]))('%s (not overridable) refuses `overrides` as an unknown key', (type) => { + const schema = getMetadataTypeSchema(type)!; + const r = schema.safeParse({ ...(getMetadataCreateSeed(type) as object), overrides: { ...DECLARATION } }); + expect(r.success).toBe(false); + expect(issuesOf(r)).toContainEqual(expect.objectContaining({ code: 'unrecognized_keys', keys: ['overrides'] })); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// defineStack() and the export's view channel +// ───────────────────────────────────────────────────────────────────── + +describe('`overrides` survives defineStack()', () => { + // Strict (the default): every collection is parsed by its item schema and + // the cross-reference checks run, so the key is judged, not skipped. + const OBJ = 'crm_custom_ticket'; + const o = () => ({ ...DECLARATION }); + const stack = defineStack({ + manifest: { id: 'com.acme.crm.custom', name: 'crm_custom', version: '1.0.0', type: 'app', namespace: 'crm_custom' }, + objects: [{ name: OBJ, label: 'Ticket', fields: { subject: { type: 'text', label: 'Subject' } } }], + apps: [{ ...EXTRA_FIXTURES.app!, overrides: o() }], + pages: [{ ...fixtureFor('page'), object: OBJ, overrides: o() }], + dashboards: [{ ...fixtureFor('dashboard'), overrides: o() }], + datasets: [{ ...fixtureFor('dataset'), name: 'tickets', object: OBJ, overrides: o() }], + reports: [{ name: 'tickets_by_subject', label: 'Tickets', type: 'summary', dataset: 'tickets', rows: ['subject'], values: ['count'], overrides: o() }], + actions: [{ ...fixtureFor('action'), overrides: o() }], + flows: [{ ...fixtureFor('flow'), overrides: o() }], + emailTemplates: [{ ...fixtureFor('email_template'), overrides: o() }], + docs: [{ ...EXTRA_FIXTURES.doc!, overrides: o() }], + books: [{ ...EXTRA_FIXTURES.book!, overrides: o() }], + mappings: [{ ...fixtureFor('mapping'), targetObject: OBJ, fieldMapping: [{ source: 'Subject', target: 'subject' }], overrides: o() }], + } as never) as unknown as Record>>; + + it.each([ + 'apps', 'pages', 'dashboards', 'reports', 'datasets', 'actions', 'flows', 'emailTemplates', 'docs', 'books', 'mappings', + ])('stack.%s[0].overrides is the declaration as written', (collection) => { + expect(stack[collection]?.[0]?.overrides).toEqual(DECLARATION); + }); + + it('a malformed declaration is refused by the strict build, not carried', () => { + expect(() => defineStack({ + manifest: { id: 'com.acme.crm.custom', name: 'crm_custom', version: '1.0.0', type: 'app', namespace: 'crm_custom' }, + flows: [{ ...fixtureFor('flow'), overrides: { package: 'com.acme.crm' } }], + } as never)).toThrow(/basedOn/); + }); +}); + +describe('`overrides` survives the export\'s view channel (`viewItems:`)', () => { + it.each([ + ['a standalone ViewItem', () => fixtureFor('view')], + ['a flattened list overlay', () => structuredClone(LIST_OVERLAY)], + ])('%s keeps `overrides` through AssembledViewArtifactSchema', (_label, body) => { + const r = AssembledViewArtifactSchema.safeParse({ ...body(), overrides: { ...DECLARATION } }); + expect(issuesOf(r)).toEqual([]); + expect((r.data as Record).overrides).toEqual(DECLARATION); + }); +}); diff --git a/packages/spec/src/kernel/override-declaration.zod.ts b/packages/spec/src/kernel/override-declaration.zod.ts new file mode 100644 index 00000000000..3ca02facb0f --- /dev/null +++ b/packages/spec/src/kernel/override-declaration.zod.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { strictObject } from '../shared/strict-object'; + +/** + * ## The override declaration (ADR-0146 D2) + * + * A customization of an installed base package (HotCRM) is an item of the + * base's paired writable package `.custom` (ADR-0146 D3). An item of that + * package that bears a base item's name and carries + * + * ```ts + * overrides: { package: '', basedOn: '' } + * ``` + * + * replaces the base's item WHOLE for every read that serves the base's name + * (ADR-0146 D8). The declaration lives in the item body, so it travels with the + * item through export and git: what a developer writes into `hotcrm.custom` in a + * repository and what an administrator does in Studio produce the same + * document. Only the paired `.custom` package declares an override (the + * ruling recorded on #28390, ruling A); the pairing helpers in + * `customization-package.ts` are what a door asks. + * + * ### Why a leaf module + * + * It imports nothing but zod and `strictObject`, on purpose. Every overridable + * type's schema spreads {@link OverrideDeclarationFields}, and some of those + * schemas are imported by `manifest.zod.ts` (`ui/app.zod.ts`); importing the + * manifest or the registry from here would close a module cycle that the eager + * schema build (`OS_EAGER_SCHEMAS=1`) evaluates in temporal-dead-zone order. + * The helpers that need the manifest grammar live in `customization-package.ts`, + * which no schema imports. + * + * ### What this module does NOT do + * + * It declares the shape. Admitting a declared override, refusing an undeclared + * same-named item, refusing a declaration from a package that is not the pair, + * and serving the declared document are the doors' (ADR-0146 Rollout, P1: + * the write gate, the effective read, the ensure door). Which types may carry + * the declaration is the closed list `OVERRIDE_TYPE_CLASSIFICATION`, never a + * judgement a door makes. + */ + +/** + * The suffix that names a base package's paired customization package: + * `hotcrm` → `hotcrm.custom`, `com.example.showcase` → + * `com.example.showcase.custom` (ADR-0146 D3; maintainer, verbatim: + * 「同意 后缀改成 .custom」). + * + * Declared here, in the leaf the schemas import, so the declaration's own + * refusal ("a pair is never a base") and the pairing helpers in + * `customization-package.ts` read one spelling. ⛔ Never spell `'.custom'` + * anywhere else: import this constant or ask a helper. + */ +export const CUSTOMIZATION_PACKAGE_SUFFIX = '.custom'; + +/** {@link CUSTOMIZATION_PACKAGE_SUFFIX}, escaped for a regular expression. */ +const ESCAPED_CUSTOMIZATION_PACKAGE_SUFFIX = CUSTOMIZATION_PACKAGE_SUFFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** Any non-empty string that does not end in {@link CUSTOMIZATION_PACKAGE_SUFFIX}. */ +const NOT_A_CUSTOMIZATION_PACKAGE_ID = new RegExp(`^(?![\\s\\S]*${ESCAPED_CUSTOMIZATION_PACKAGE_SUFFIX}$)[\\s\\S]+$`); + +/** + * The override declaration's body: which base package's item this item + * replaces, and which version of that item the author started from. + * + * Closed: an unknown key is refused by name, because a misspelt `basedon` + * dropped in silence would leave a declaration that records nothing. + */ +export const OverrideDeclarationSchema = strictObject({ + surface: 'this override declaration', + history: + 'New with ADR-0146: the shape was closed from its first release, so a misspelt key is ' + + 'refused instead of dropped.', + aliases: { packageId: 'package', base: 'package', basePackage: 'package', version: 'basedOn', baseVersion: 'basedOn' }, +}, { + package: z.string() + .min(1) + // A pattern, not a `.refine()`: a pattern publishes into the JSON Schema + // (`json-schema/**`), so a document the file accepts is one the parse + // accepts. Built from the constant, so the suffix has one spelling. + .regex(NOT_A_CUSTOMIZATION_PACKAGE_ID, { + message: + `\`overrides.package\` names the BASE package whose item this item replaces, never a \`${CUSTOMIZATION_PACKAGE_SUFFIX}\` package: ` + + `the declaring item lives in the base's paired \`${CUSTOMIZATION_PACKAGE_SUFFIX}\` package and names the base it overrides ` + + `(write \`com.acme.crm\`, not \`com.acme.crm${CUSTOMIZATION_PACKAGE_SUFFIX}\`).`, + }) + .describe( + 'The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). ' + + 'Only an item of the base\'s paired ".custom" package declares an override.', + ), + basedOn: z.string() + .min(1) + .describe( + 'Recorded, never judged: the base package\'s `manifest.version` this override was taken from (e.g. "3.2.0"), ' + + 'not a history version of the item. ' + + 'No door compares it with the installed base, no upgrade diff is produced from it, and nothing is ' + + 'refused on it; it tells a person re-basing the override where it started (ADR-0146 D2).', + ), +}).describe( + 'Override declaration (ADR-0146 D2): this item, of the base\'s paired ".custom" package and bearing a ' + + 'base item\'s name, replaces that base item whole for every read that serves the name.', +); + +export type OverrideDeclaration = z.input; +/** Post-parse shape of {@link OverrideDeclaration}. */ +export type OverrideDeclarationParsed = z.infer; + +/** + * Raw shape spliced into the schema of every type whose + * `OVERRIDE_TYPE_CLASSIFICATION` disposition is `'overridable'`, in the style of + * `MetadataProtectionFields`. + * + * ⛔ Not spread into `MetadataProtectionFields`, and deliberately so: that + * envelope is spread into types ADR-0146 excludes (`permission`, `position`, + * `capability`, the code-only types, `object`), and it is the set the + * registration and clone paths strip as loader-stamped state + * (`Object.keys(MetadataProtectionFields)`). The declaration is AUTHORED — it + * must survive registration and export — so it is its own spread, on the + * overridable types only. + * + * @example + * ```ts + * // An item of `com.acme.crm.custom`: + * { + * name: 'crm_account_welcome', + * subject: 'Welcome aboard', + * // …the whole document… + * overrides: { package: 'com.acme.crm', basedOn: '3.2.0' }, + * } + * ``` + */ +export const OverrideDeclarationFields = { + /** + * Declares that this item replaces the base package's item of the same name + * (ADR-0146 D2). `basedOn` is recorded and never judged. + */ + overrides: OverrideDeclarationSchema.optional().describe( + 'Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of ".custom" replaces the ' + + 'base package\'s same-named item whole. `basedOn` is recorded and never judged.', + ), +} as const; diff --git a/packages/spec/src/system/book.zod.ts b/packages/spec/src/system/book.zod.ts index 6bb1240e6c6..3a93b150ba2 100644 --- a/packages/spec/src/system/book.zod.ts +++ b/packages/spec/src/system/book.zod.ts @@ -5,6 +5,7 @@ import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; import { retiredKey } from '../shared/retired-key'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { OverrideDeclarationFields } from '../kernel/override-declaration.zod'; /** * Package Documentation Navigation — the `book` element (ADR-0046 §6). @@ -172,6 +173,8 @@ export const BookSchema = lazySchema(() => // were dropped on every parse — protection metadata lost on round-trip, and // a hard 422 waiting for the day this shape is closed. ...MetadataProtectionFields, + // ADR-0146 D2 — the override declaration (authored; travels with the item). + ...OverrideDeclarationFields, }), ); diff --git a/packages/spec/src/system/doc.zod.ts b/packages/spec/src/system/doc.zod.ts index 4a77eb10fcf..d1756c46a16 100644 --- a/packages/spec/src/system/doc.zod.ts +++ b/packages/spec/src/system/doc.zod.ts @@ -27,6 +27,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { OverrideDeclarationFields } from '../kernel/override-declaration.zod'; export const DocSchema = lazySchema(() => strictObject({ surface: 'this doc', @@ -151,6 +152,8 @@ export const DocSchema = lazySchema(() => strictObject({ // on every parse — the inverse drift that made `permission` 422 when it went // strict (#4001 findings log, entry 2). ...MetadataProtectionFields, + // ADR-0146 D2 — the override declaration (authored; travels with the item). + ...OverrideDeclarationFields, })); export type Doc = z.input; export type DocTranslation = NonNullable[string]; diff --git a/packages/spec/src/system/email-template.zod.ts b/packages/spec/src/system/email-template.zod.ts index d42b4f01e41..600e2b27285 100644 --- a/packages/spec/src/system/email-template.zod.ts +++ b/packages/spec/src/system/email-template.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { OverrideDeclarationFields } from '../kernel/override-declaration.zod'; import { lazySchema } from '../shared/lazy-schema'; import { retiredKey } from '../shared/retired-key'; import { strictObject } from '../shared/strict-object'; @@ -264,6 +265,8 @@ export const EmailTemplateDefinitionSchema = lazySchema(() => strictObject({ // ADR-0010 — runtime protection envelope (internal — set by loader). ...MetadataProtectionFields, + // ADR-0146 D2 — the override declaration (authored; travels with the item). + ...OverrideDeclarationFields, })); diff --git a/packages/spec/src/system/flows-translation-face.test.ts b/packages/spec/src/system/flows-translation-face.test.ts index 246eb0722fe..a712699fa2e 100644 --- a/packages/spec/src/system/flows-translation-face.test.ts +++ b/packages/spec/src/system/flows-translation-face.test.ts @@ -116,6 +116,10 @@ const RUNNER_SLOTS: Readonly> = { 'flow._packageId': noReader(FRAMEWORK_FIELD), 'flow._packageVersion': noReader(FRAMEWORK_FIELD), 'flow._lockDocsUrl': noReader(FRAMEWORK_FIELD), + 'flow.overrides': noReader( + 'the override declaration (ADR-0146 D2): a base package id and the base version the override was taken from, ' + + 'read by the customization doors and never drawn for a person running the flow', + ), // FlowNodeSchema (its `config` is judged per type below) 'node.id': noReader('the node id — the address of `screens.`, `refusals.` and `approvals.`'), diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 1483a9da01f..9f8d1a15911 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -668,6 +668,68 @@ const LEDGER: ReadonlyArray = [ // the first arm form rather than for this ledger. `scope` has no live writer // of `shared` or `personal`, so there is no platform-written state to give as // the reason; `owner` and `hidden` are no longer writable at all. + + // ADR-0146 D2 — the override declaration lands spec-first: `planned` in the + // liveness ledger, admitted by no write gate and served by no read until the + // ADR-0146 doors land. Studio writes it when an administrator customizes a + // base item into its `.custom` package; a form control an author fills + // by hand would declare an override nothing yet honours. + { + kind: 'omit', + type: 'report', + path: ROOT_PATH, + key: 'overrides', + why: 'declared, not enforced yet — liveness verdict `planned`: the override declaration (ADR-0146 D2) parses, is stored and travels through export, but no door admits or serves it until the write gate and the effective read land. Studio writes it when it saves a customization into `.custom`; a hand-filled control is not how it is authored', + }, + { + kind: 'omit', + type: 'dataset', + path: ROOT_PATH, + key: 'overrides', + why: 'declared, not enforced yet — liveness verdict `planned`: the override declaration (ADR-0146 D2) parses, is stored and travels through export, but no door admits or serves it until the write gate and the effective read land. Studio writes it when it saves a customization into `.custom`; a hand-filled control is not how it is authored', + }, + { + kind: 'omit', + type: 'app', + path: ROOT_PATH, + key: 'overrides', + why: 'declared, not enforced yet — liveness verdict `planned`: the override declaration (ADR-0146 D2) parses, is stored and travels through export, but no door admits or serves it until the write gate and the effective read land. Studio writes it when it saves a customization into `.custom`; a hand-filled control is not how it is authored', + }, + { + kind: 'omit', + type: 'dashboard', + path: ROOT_PATH, + key: 'overrides', + why: 'declared, not enforced yet — liveness verdict `planned`: the override declaration (ADR-0146 D2) parses, is stored and travels through export, but no door admits or serves it until the write gate and the effective read land. Studio writes it when it saves a customization into `.custom`; a hand-filled control is not how it is authored', + }, + { + kind: 'omit', + type: 'action', + path: ROOT_PATH, + key: 'overrides', + why: 'declared, not enforced yet — liveness verdict `planned`: the override declaration (ADR-0146 D2) parses, is stored and travels through export, but no door admits or serves it until the write gate and the effective read land. Studio writes it when it saves a customization into `.custom`; a hand-filled control is not how it is authored', + }, + { + kind: 'omit', + type: 'page', + path: ROOT_PATH, + key: 'overrides', + why: 'declared, not enforced yet — liveness verdict `planned`: the override declaration (ADR-0146 D2) parses, is stored and travels through export, but no door admits or serves it until the write gate and the effective read land. Studio writes it when it saves a customization into `.custom`; a hand-filled control is not how it is authored', + }, + { + kind: 'omit', + type: 'flow', + path: ROOT_PATH, + key: 'overrides', + why: 'declared, not enforced yet — liveness verdict `planned`: the override declaration (ADR-0146 D2) parses, is stored and travels through export, but no door admits or serves it until the write gate and the effective read land. Studio writes it when it saves a customization into `.custom`; a hand-filled control is not how it is authored', + }, + { + kind: 'omit', + type: 'email_template', + path: ROOT_PATH, + key: 'overrides', + why: 'declared, not enforced yet — liveness verdict `planned`: the override declaration (ADR-0146 D2) parses, is stored and travels through export, but no door admits or serves it until the write gate and the effective read land. Studio writes it when it saves a customization into `.custom`; a hand-filled control is not how it is authored', + }, ]; // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index dcc458e0584..babf8e8747e 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -10,6 +10,7 @@ import { lazySchema } from '../shared/lazy-schema'; import { retiredKey } from '../shared/retired-key'; import { strictObject } from '../shared/strict-object'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { OverrideDeclarationFields } from '../kernel/override-declaration.zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { textSlotTemplateRefusal } from '../automation/flow-text-slot-template'; @@ -2472,6 +2473,8 @@ export const TranslationItemSchema = lazySchema(() => strictObject({ // dropped on every parse — and `authored-translation-sync` has to strip them // by hand on the read side precisely because the schema could not hold them. ...MetadataProtectionFields, + // ADR-0146 D2 — the override declaration (authored; travels with the item). + ...OverrideDeclarationFields, }).describe('One locale of translations — the `translation` metadata type')); /** A single `translation` metadata item. */ diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 77eca4ff6a4..acabab5e6ff 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -49,6 +49,7 @@ import { MEMBERSHIP_REACH_NAMES, lowerRequiresMembershipReach } from '../identit import { strictUnknownKeyError } from '../shared/suggestions.zod'; import { strictObject } from '../shared/strict-object'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { OverrideDeclarationFields } from '../kernel/override-declaration.zod'; import { lazySchema } from '../shared/lazy-schema'; import { aiJsonSchemaSlot } from '../shared/ai-json-schema-slot'; import { ACTION_TARGET_ALIASES } from './action-target-aliases'; @@ -2018,6 +2019,8 @@ const actionObject = () => strictObject({ // parse. This is the LAST name on the undeclared-envelope debt list the // structural walk opened with eight of. ...MetadataProtectionFields, + // ADR-0146 D2 — the override declaration (authored; travels with the item). + ...OverrideDeclarationFields, }); /** diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 38db77c59d7..3aace1a5f5c 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -8,6 +8,7 @@ import { ElementApiDataSourceSchema } from './page.zod'; import { strictObject, type StrictObjectOptions } from '../shared/strict-object'; import { lazySchema } from '../shared/lazy-schema'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { OverrideDeclarationFields } from '../kernel/override-declaration.zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { requiredOneOf } from '../shared/refinement-projection'; @@ -2108,6 +2109,8 @@ export const AppSchema = lazySchema(() => strictObject( // ADR-0010 — runtime protection envelope (internal — set by loader). ...MetadataProtectionFields, + // ADR-0146 D2 — the override declaration (authored; travels with the item). + ...OverrideDeclarationFields, }).superRefine(checkAppLandingItems)); /** diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index a53c250ef30..73f489dcea7 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { OverrideDeclarationFields } from '../kernel/override-declaration.zod'; import { strictObject } from '../shared/strict-object'; import type { KeySetGuidance } from '../shared/suggestions.zod'; import { FilterConditionSchema } from '../data/filter.zod'; @@ -1797,6 +1798,8 @@ export const DashboardSchema = lazySchema(() => strictObject({ // ADR-0010 — runtime protection envelope (internal — set by loader). ...MetadataProtectionFields, + // ADR-0146 D2 — the override declaration (authored; travels with the item). + ...OverrideDeclarationFields, })); diff --git a/packages/spec/src/ui/dataset.zod.ts b/packages/spec/src/ui/dataset.zod.ts index 1271b7ec733..c34139dae35 100644 --- a/packages/spec/src/ui/dataset.zod.ts +++ b/packages/spec/src/ui/dataset.zod.ts @@ -5,6 +5,7 @@ import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { OverrideDeclarationFields } from '../kernel/override-declaration.zod'; import { analyticsCarrierFilter } from './analytics-carrier-filter'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { I18nLabelSchema } from './i18n.zod'; @@ -681,6 +682,8 @@ export const DatasetSchema = lazySchema(() => strictObject({ // ADR-0010 — runtime protection envelope (internal — set by loader). ...MetadataProtectionFields, + // ADR-0146 D2 — the override declaration (authored; travels with the item). + ...OverrideDeclarationFields, }).superRefine((ds, ctx) => { // Measure names must be unique (presentations reference them by name). const measureNames = new Set(); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 1b8eacc1520..7ddc0699d72 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -22,6 +22,7 @@ import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; import { ruleArrayFilterError } from './filter-rule-array'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { OverrideDeclarationFields } from '../kernel/override-declaration.zod'; import { PAGE_TYPE_DEFAULT, misplacedDefaultPageRefusal } from './default-record-page'; /** @@ -2169,6 +2170,8 @@ export const PageSchema = lazySchema(() => strictObject({ // parse — protection metadata lost on round-trip, and a hard 422 the day this // shape closed. ...MetadataProtectionFields, + // ADR-0146 D2 — the override declaration (authored; travels with the item). + ...OverrideDeclarationFields, }) // ADR-0080/0081 + ADR-0078 (completeness): an html/react page with no // `source` is silently inert — fail loudly at author time, never render empty. diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index 4c78418edec..fa90ea35a7d 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { OverrideDeclarationFields } from '../kernel/override-declaration.zod'; import { analyticsCarrierFilter } from './analytics-carrier-filter'; import { ChartConfigSchema } from './chart.zod'; import { DATE_RANGE_DEFAULT_RANGES, SINGLE_SERIES_CHART_TYPES } from './dashboard.zod'; @@ -759,6 +760,8 @@ export const ReportSchema = lazySchema(() => strictObject({ // ADR-0010 — runtime protection envelope (internal — set by loader). ...MetadataProtectionFields, + // ADR-0146 D2 — the override declaration (authored; travels with the item). + ...OverrideDeclarationFields, }).superRefine((r, ctx) => { // ADR-0021 single-form: a report is dataset-bound. A `joined` report carries diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 0476f5bc570..dae4b859064 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -51,6 +51,7 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { OverrideDeclarationFields } from '../kernel/override-declaration.zod'; import { closedObject, strictObject, strictObjectError, type StrictObjectOptions } from '../shared/strict-object'; import { SnakeCaseIdentifierSchema, SystemIdentifierSchema, QUALIFIED_ITEM_NAME_PATTERN } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; @@ -5902,6 +5903,8 @@ function viewItemBaseShape() { ), // ADR-0010 — runtime protection envelope (internal — set by loader). ...MetadataProtectionFields, + // ADR-0146 D2 — the override declaration (authored; travels with the item). + ...OverrideDeclarationFields, }; } @@ -6551,6 +6554,8 @@ function flattenedViewOverlayFields(kind: K) { hidden: retiredKey(VIEW_ITEM_HIDDEN_RETIRED), protection: ProtectionSchema.optional(), ...MetadataProtectionFields, + // ADR-0146 D2 — the override declaration (authored; travels with the item). + ...OverrideDeclarationFields, // Structural guards — a flattened overlay is neither a record nor a container. config: z.undefined().optional(), list: z.undefined().optional(), diff --git a/skills/objectstack-ai/references/_index.md b/skills/objectstack-ai/references/_index.md index b62a2339f15..3f51b69e791 100644 --- a/skills/objectstack-ai/references/_index.md +++ b/skills/objectstack-ai/references/_index.md @@ -24,6 +24,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Exports: FieldType, SelectOptionSchema, LocationCoordinatesSchema, CurrencyConfigSchema, UniqueScopeSchema - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) +- `node_modules/@objectstack/spec/src/kernel/override-declaration.zod.ts` — The override declaration (ADR-0146 D2) - `node_modules/@objectstack/spec/src/shared/conditional-formatting.zod.ts` — The **single declaration** of a conditional formatting rule — - `node_modules/@objectstack/spec/src/shared/enums.zod.ts` — Exports: SortDirectionEnum, SortItemSchema, MutationEventEnum, IsolationLevelEnum - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol diff --git a/skills/objectstack-automation/references/_index.md b/skills/objectstack-automation/references/_index.md index 4a56e7aafaa..80d4ac602c8 100644 --- a/skills/objectstack-automation/references/_index.md +++ b/skills/objectstack-automation/references/_index.md @@ -27,6 +27,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/data/context-tokens.zod.ts` — Context Tokens — the declarative placeholders that resolve against the - `node_modules/@objectstack/spec/src/data/date-macros.zod.ts` — Date Macro Tokens — the declarative placeholders the UI substitutes - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) +- `node_modules/@objectstack/spec/src/kernel/override-declaration.zod.ts` — The override declaration (ADR-0146 D2) - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — Exports: SystemIdentifierSchema, SnakeCaseIdentifierSchema, MetadataItemNameSchema - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) diff --git a/skills/objectstack-data/references/_index.md b/skills/objectstack-data/references/_index.md index d036e57f460..8c52375e4f4 100644 --- a/skills/objectstack-data/references/_index.md +++ b/skills/objectstack-data/references/_index.md @@ -42,6 +42,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/identity/eval-user.zod.ts` — EvalUser — the one user-context contract (ADR-0068 D1). - `node_modules/@objectstack/spec/src/kernel/execution-context.zod.ts` — Exports: AuthGateCodeSchema, AuthGateScopeSchema, ExecutionContextSchema - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) +- `node_modules/@objectstack/spec/src/kernel/override-declaration.zod.ts` — The override declaration (ADR-0146 D2) - `node_modules/@objectstack/spec/src/security/explain.zod.ts` — [ADR-0090 D6] Access-explanation contract — `explain(principal, object, - `node_modules/@objectstack/spec/src/security/rls.zod.ts` — Row-Level Security (RLS) Protocol - `node_modules/@objectstack/spec/src/shared/conditional-formatting.zod.ts` — The **single declaration** of a conditional formatting rule — diff --git a/skills/objectstack-ui/references/_index.md b/skills/objectstack-ui/references/_index.md index e83ae7f34c1..c37f9ef870c 100644 --- a/skills/objectstack-ui/references/_index.md +++ b/skills/objectstack-ui/references/_index.md @@ -36,6 +36,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/identity/eval-user.zod.ts` — EvalUser — the one user-context contract (ADR-0068 D1). - `node_modules/@objectstack/spec/src/kernel/execution-context.zod.ts` — Exports: AuthGateCodeSchema, AuthGateScopeSchema, ExecutionContextSchema - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) +- `node_modules/@objectstack/spec/src/kernel/override-declaration.zod.ts` — The override declaration (ADR-0146 D2) - `node_modules/@objectstack/spec/src/security/explain.zod.ts` — [ADR-0090 D6] Access-explanation contract — `explain(principal, object, - `node_modules/@objectstack/spec/src/security/permission.zod.ts` — Exports: ObjectAccessScopeSchema, ObjectPermissionSchema, EffectiveObjectPermissionSchema, AdminScopeSchema, FieldPermissionSchema - `node_modules/@objectstack/spec/src/security/rls.zod.ts` — Row-Level Security (RLS) Protocol