diff --git a/.changeset/30904-marketplace-taxonomy.md b/.changeset/30904-marketplace-taxonomy.md new file mode 100644 index 00000000000..61d5c6acbb2 --- /dev/null +++ b/.changeset/30904-marketplace-taxonomy.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': minor +'create-objectstack': patch +--- + +feat(spec)!: one marketplace taxonomy — business functions, industries, a solution type and a maturity flag replace the single `category` (#30904) + +Clause-②: yes (narrowing) — new facet keys and `maturity` widen; `category` retired and refused by name narrows; a contract review runs before the queue +Metadata-protocol: narrowed + + + +**BREAKING** for a package source, publish request or listing that still writes `category`. + +- **`@objectstack/spec`.** The marketplace taxonomy is defined once, in `@objectstack/spec/marketplace` (`taxonomy.zod.ts`): `businessFunctions` (1-3 distinct values, the primary one first: `sales`, `marketing`, `customer-service`, `finance`, `hr`, `supply-chain`, `administration`, `project`, `legal`, `it`, `collaboration`), `industries` (up to 3, optional; absent means cross-industry), `solutionType` (`app` | `template` | `connector`; absent means `app`) and `maturity` (`beta` | `stable`; absent means `stable`), on `PackageSchema`, `CreatePackageRequestSchema`, `UpdatePackageRequestSchema`, `TemplateManifestSchema` (`objectstack.manifest.json`) and `MarketplaceListingSchema`, where `businessFunctions` is required. A search filters by `businessFunctions` / `industries` / `solutionTypes` / `maturity` and reads one facet count list per facet. `explainSolutionTypeContradiction` is the publish cross-check (a `template` needs the `unmanaged` install mode, a `connector` a declared connector) and `LEGACY_CATEGORY_MAPPING` says what every old category value becomes. +- **Retired.** `category` on all six schemas, and `MarketplaceSearchResponse.facets.categories`, are `retiredKey()` tombstones: `tsc` refuses them and a parse refuses them with the prescription. `MarketplaceCategorySchema` / `MarketplaceCategory` and `PackageCategorySchema` / `PackageCategory` are removed. `RETIRED_KEYS_BY_MAJOR[18]` lists the six keys, `RETIRED_DEFS_BY_MAJOR[18]` both defs, and the upgrade guide carries the semantic entry `marketplace-category-retired`. No ADR-0087 conversion: none of these shapes is a stack collection member, so `os migrate meta` has nothing to rewrite; a runtime boot never reads them. +- **`@objectstack/cli`.** `os package publish` refuses a `category` in `objectstack.manifest.json`, or the now-hidden `--category` flag, before any network call, naming the facets for the value it held. It judges each facet with the spec's schema, checks a declared `solutionType` against the built artifact, and sends the facets as `business_functions`, `industries`, `solution_type` and `maturity`; an absent facet is not sent. +- **`create-objectstack`.** The blank template no longer ships `category: "starter"`, which every project scaffolded from it inherited and which the hosted publish route refused. + +**The remedy.** FROM `"category": ""` TO the facets: delete `category` and declare `businessFunctions` (and, where they apply, `industries` and `solutionType`) in `objectstack.manifest.json`. `os package publish` prints the mapping for the value you had — for example `"finance"` → `"businessFunctions": ["finance"], "solutionType": "app"` — and `LEGACY_CATEGORY_MAPPING` lists them all. A `"starter"` copied from the blank template is simply deleted. A TypeScript caller that imported `MarketplaceCategorySchema` or `PackageCategorySchema` uses `MarketplaceBusinessFunctionSchema` and `MarketplaceSolutionTypeSchema` instead. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index d4dbcb11d63..d89853bea0d 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -3264,7 +3264,6 @@ candidates with how to pass `--env`. | `-v, --version ` | — | Version to publish. Default: `artifact.manifest.version`, else `0.0.0-dev.` + a timestamp | | `--display-name ` | — | Name shown in the Marketplace (default `artifact.manifest.name`) | | `--description ` | — | Short package description | -| `--category ` | — | Marketplace category slug (`crm`, `hr`, `devtools`, …) | | `--visibility ` | — | `org` (installable across your organization) · `private` (explicit grants only) · `marketplace` (public after review). Omitted: not sent, so the control plane decides — an existing package keeps its visibility, a new one gets the control plane's default (`org` on ObjectOS Cloud) | | `--org ` | `OS_ORG_ID` | `owner_org_id`. Required with a bearer key in service mode; ignored in user mode | | `--env ` | `OS_ENVIRONMENT_ID` | Environment to install the new version into. Defaults to the environment [`os environments switch`](#cloud-environments) — or `os environments create --activate` — recorded **for this control plane** in `~/.objectstack/cloud.json`, then to your organization's only environment | @@ -3300,9 +3299,15 @@ and rebuild. **`objectstack.manifest.json` supplies the listing fields.** When that file is present in the working directory, publish reads `manifestId`, `displayName`, -`description`, `category`, `tags`, `iconUrl`, `homepageUrl`, `license`, -`readmePath` and a `translations` map from it, so a listing need not be retyped -as flags on every publish. **CLI flags always win.** `tags` has no flag and is +`description`, `tags`, `iconUrl`, `homepageUrl`, `license`, `readmePath`, a +`translations` map and the marketplace facets — `businessFunctions`, +`industries`, `solutionType` and `maturity` — from it, so a listing need not be +retyped as flags on every publish. **CLI flags always win.** Each facet is +checked against the spec's schema before anything is sent, and a declared +`solutionType` against the built artifact: a `template` needs the `unmanaged` +install mode, a `connector` a declared connector. The retired `category` — in +the file, or as `--category` — is refused before any network call, with the +facets that replace the value it held. `tags` has no flag and is sent as written: the control plane judges the list, and a refusal is its 400, printed as it is. A top-level `tagline` is used only as the description, when neither `--description` nor the file's `description` gives one; otherwise it is diff --git a/content/docs/deployment/marketplace-handbook.mdx b/content/docs/deployment/marketplace-handbook.mdx index b555a68b59c..103706a21c5 100644 --- a/content/docs/deployment/marketplace-handbook.mdx +++ b/content/docs/deployment/marketplace-handbook.mdx @@ -18,7 +18,7 @@ D1/D2). Modules, plugins and other contributions ship **inside** that App — se [Extras](#extras-dependencies-and-modules) below. The listing is a package row in the catalog plus its versions: the row carries -the name, description, category, icon, README and translations, and each +the name, description, business functions and other taxonomy facets, icon, README and translations, and each version is a snapshot of your compiled artifact. ## The listing file: `objectstack.manifest.json` @@ -34,7 +34,10 @@ the declared keys below are the ones publish actually sends. | `manifestId` | The package id (reverse-domain, immutable once published) | `--manifest-id` | | `displayName` | The name on the card | `--display-name` | | `description` | The short description | `--description` | -| `category` | The catalog category slug (`crm`, `hr`, `finance`, …) | `--category` | +| `businessFunctions` | The business functions the app serves: 1-3 values, the primary one first (the values: [Marketplace Taxonomy reference](/docs/references/marketplace/taxonomy)) | — | +| `industries` | Up to 3 industries the app is built for; leave it out for a cross-industry app | — | +| `solutionType` | `app` (you keep upgrading it), `template` (the customer's to change — needs the `unmanaged` install mode) or `connector` (needs a declared connector); publish checks it against your built package. Left out: `app` | — | +| `maturity` | `beta` or `stable`; left out: `stable` | — | | `tags` | The listing tags, sent as written. The catalog validates and stores them and refuses a bad list with a 400, which publish prints as it is; `[]` clears the stored tags, and leaving the key out keeps them | — | | `iconUrl` | A public http(s) icon URL | `--icon-url`, or `--icon-file` to upload one | | `homepageUrl` | Your project or docs URL | `--homepage-url` | @@ -42,16 +45,19 @@ the declared keys below are the ones publish actually sends. | `readmePath` | A markdown file, relative to the manifest, read at publish time | `--readme-file`, `--readme` | | `translations` | Per-locale `displayName`, `description`, `tagline`, `readme` and `screenshotCaptions` | — | +The four taxonomy keys are defined once, in `@objectstack/spec/marketplace`. Publish validates them and sends them; the hosted catalog's facet filters, its submission rule (at least one business function) and the console's facet chips and Beta badge arrive as each side adopts the taxonomy, so until then a value you declare is accepted and not yet shown. + An official ObjectStack listing's `manifestId` is `app.objectstack.` (`app.objectstack.hotcrm`, `app.objectstack.connector-github`); a partner listing uses its own reverse domain, `app..`. [HotCRM](https://github.com/objectstack-ai/hotcrm/blob/main/objectstack.manifest.json) -is a worked example for the keys it carries: +is a worked example for the keys it carries (shown with the business functions +that replace its retired `category`): ```json { "manifestId": "app.objectstack.hotcrm", "displayName": "HotCRM", - "category": "crm", + "businessFunctions": ["sales", "customer-service", "marketing"], "license": "Apache-2.0", "iconUrl": "https://raw.githubusercontent.com/objectstack-ai/hotcrm/main/assets/icon.svg", "homepageUrl": "https://github.com/objectstack-ai/hotcrm", @@ -189,7 +195,7 @@ the detail. 1. **Finding the app.** In the Console, **Setup → Browse Marketplace** opens the **App Marketplace** page, which lists the approved apps; clicking one opens - its page with the README, version, category and license. A runtime started + its page with the README, version, business functions and license. A runtime started with `OS_CLOUD_URL=off` shows **App Marketplace is turned off** instead — [Cloud connection](/docs/deployment/cloud-connection) covers connecting one. 2. **Installing.** Into a cloud environment, **Install to cloud…** opens a diff --git a/content/docs/protocol/kernel/plugin-spec.mdx b/content/docs/protocol/kernel/plugin-spec.mdx index 26a71f2908b..073307ba9b2 100644 --- a/content/docs/protocol/kernel/plugin-spec.mdx +++ b/content/docs/protocol/kernel/plugin-spec.mdx @@ -136,8 +136,9 @@ export default definePlugin({ // MARKETPLACE METADATA marketplace: { - // Category for marketplace listing - category: 'crm', + // Business functions for the marketplace listing (the taxonomy in + // @objectstack/spec/marketplace; the first is primary) + businessFunctions: ['sales'], // Screenshots screenshots: [ diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 1cdae78ede4..5a59ffee1eb 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 — 1631 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1635 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/. */} @@ -27,14 +27,14 @@ counts are sums of the rows they head. Regenerate with | [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) | 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. | +| [Marketplace Protocol](/docs/references/marketplace) | 5 | 34 | 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. | | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 39 | 295 | 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** | **211** | **1631** | 14 protocol modules | +| **Total** | **212** | **1635** | 14 protocol modules | --- @@ -249,15 +249,16 @@ Plugin lifecycle and manifests, capabilities and security, metadata loading, ser ## Marketplace Protocol -**Source:** `packages/spec/src/marketplace/` · **Import:** `@objectstack/spec/marketplace` · **4 pages, 30 schemas** +**Source:** `packages/spec/src/marketplace/` · **Import:** `@objectstack/spec/marketplace` · **5 pages, 34 schemas** The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. | File | Schemas | | :--- | :--- | -| [`marketplace.zod.ts`](/docs/references/marketplace/marketplace) | `ArtifactDownloadResponse`, `ArtifactReference`, `ListingStatus`, `MarketplaceCategory`, `MarketplaceInstallRequest`, `MarketplaceInstallResponse`, `MarketplaceListing`, `MarketplaceSearchRequest`, `MarketplaceSearchResponse`, `PackageSubmission`, `PricingModel`, `Publisher`, `PublisherVerification` | -| [`package.zod.ts`](/docs/references/marketplace/package) | `CreatePackageRequest`, `Package`, `PackageCategory`, `PackageLocale`, `PackagePublisher`, `PackageTranslation`, `PackageTranslations`, `PackageVisibility`, `UpdatePackageRequest` | +| [`marketplace.zod.ts`](/docs/references/marketplace/marketplace) | `ArtifactDownloadResponse`, `ArtifactReference`, `ListingStatus`, `MarketplaceInstallRequest`, `MarketplaceInstallResponse`, `MarketplaceListing`, `MarketplaceSearchRequest`, `MarketplaceSearchResponse`, `PackageSubmission`, `PricingModel`, `Publisher`, `PublisherVerification` | +| [`package.zod.ts`](/docs/references/marketplace/package) | `CreatePackageRequest`, `Package`, `PackageLocale`, `PackagePublisher`, `PackageTranslation`, `PackageTranslations`, `PackageVisibility`, `UpdatePackageRequest` | | [`package-version.zod.ts`](/docs/references/marketplace/package-version) | `CreatePackageVersionRequest`, `PackageDependency`, `PackageManifest`, `PackageVersion`, `PackageVersionStatus`, `PublishPackageVersionRequest`, `UpdatePackageVersionRequest` | +| [`taxonomy.zod.ts`](/docs/references/marketplace/taxonomy) | `MarketplaceBusinessFunction`, `MarketplaceBusinessFunctions`, `MarketplaceIndustries`, `MarketplaceIndustry`, `MarketplaceSolutionType`, `PackageMaturity` | | [`template-manifest.zod.ts`](/docs/references/marketplace/template-manifest) | `TemplateManifest` | --- diff --git a/content/docs/references/marketplace/index.mdx b/content/docs/references/marketplace/index.mdx index b5ba880b76d..619ec1508ff 100644 --- a/content/docs/references/marketplace/index.mdx +++ b/content/docs/references/marketplace/index.mdx @@ -1,7 +1,7 @@ --- title: Marketplace Protocol — schema reference navTitle: Marketplace Protocol -description: "The ObjectStack Marketplace Protocol in 4 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." +description: "The ObjectStack Marketplace Protocol in 5 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/. */} @@ -12,5 +12,6 @@ This section contains all protocol schemas for the marketplace layer of ObjectSt + diff --git a/content/docs/references/marketplace/marketplace.mdx b/content/docs/references/marketplace/marketplace.mdx index eaca79f001a..2a3f5e10ae5 100644 --- a/content/docs/references/marketplace/marketplace.mdx +++ b/content/docs/references/marketplace/marketplace.mdx @@ -56,8 +56,8 @@ no gate refuses a package that declares none, or more than one. ## TypeScript Usage ```typescript -import { ArtifactDownloadResponseSchema, ArtifactReferenceSchema, ListingStatusSchema, MarketplaceCategorySchema, MarketplaceInstallRequestSchema, MarketplaceInstallResponseSchema, MarketplaceListingSchema, MarketplaceSearchRequestSchema, MarketplaceSearchResponseSchema, PackageSubmissionSchema, PricingModelSchema, PublisherSchema, PublisherVerificationSchema } from '@objectstack/spec/marketplace'; -import type { ArtifactDownloadResponse, ArtifactReference, ListingStatus, MarketplaceCategory, MarketplaceInstallRequest, MarketplaceInstallResponse, MarketplaceListing, MarketplaceSearchRequest, MarketplaceSearchResponse, PackageSubmission, PricingModel, Publisher, PublisherVerification } from '@objectstack/spec/marketplace'; +import { ArtifactDownloadResponseSchema, ArtifactReferenceSchema, ListingStatusSchema, MarketplaceInstallRequestSchema, MarketplaceInstallResponseSchema, MarketplaceListingSchema, MarketplaceSearchRequestSchema, MarketplaceSearchResponseSchema, PackageSubmissionSchema, PricingModelSchema, PublisherSchema, PublisherVerificationSchema } from '@objectstack/spec/marketplace'; +import type { ArtifactDownloadResponse, ArtifactReference, ListingStatus, MarketplaceInstallRequest, MarketplaceInstallResponse, MarketplaceListing, MarketplaceSearchRequest, MarketplaceSearchResponse, PackageSubmission, PricingModel, Publisher, PublisherVerification } from '@objectstack/spec/marketplace'; // Validate data const result = ArtifactDownloadResponseSchema.parse(data); @@ -116,31 +116,6 @@ Marketplace listing status * `unlisted` ---- - -## MarketplaceCategory - -Marketplace package category - -### Allowed Values - -* `crm` -* `erp` -* `hr` -* `finance` -* `project` -* `collaboration` -* `analytics` -* `integration` -* `automation` -* `ai` -* `security` -* `developer-tools` -* `ui-theme` -* `storage` -* `other` - - --- ## MarketplaceInstallRequest @@ -204,7 +179,11 @@ Public-facing package listing on the marketplace | **name** | `string` | ✅ | Display name | | **tagline** | `string` | optional | Short tagline (max 120 chars) | | **description** | `string` | optional | Full description (Markdown) | -| **category** | `Enum<'crm' \| 'erp' \| 'hr' \| 'finance' \| 'project' \| 'collaboration' \| 'analytics' \| 'integration' \| 'automation' \| 'ai' \| 'security' \| 'developer-tools' \| 'ui-theme' \| 'storage' \| 'other'>` | ✅ | Package category | +| **category** | `never` | optional | [REMOVED] `MarketplaceListing.category` was removed in @objectstack/spec 18 (ADR-0087) — one category had to say both who a package is for and what it is, and the platform and the cloud catalog spelled it from lists that disagreed. Delete the key. Declare `businessFunctions` (1-3 business functions, the primary one first), optionally `industries` (up to 3; absent means cross-industry) and `solutionType` (`app`, `template` or `connector`; absent means `app`). `LEGACY_CATEGORY_MAPPING` in @objectstack/spec/marketplace gives the facets for every old category value. | +| **businessFunctions** | `Enum<'sales' \| 'marketing' \| 'customer-service' \| 'finance' \| 'hr' \| 'supply-chain' \| 'administration' \| 'project' \| 'legal' \| 'it' \| 'collaboration'>[]` | ✅ | Business functions the package serves: 1-3 distinct values, the first is primary | +| **industries** | `Enum<'manufacturing' \| 'trade-distribution' \| 'retail' \| 'logistics' \| 'professional-services' \| 'financial-services' \| 'healthcare' \| 'education' \| 'public-sector' \| 'real-estate-construction'>[]` | optional | Industries the package is built for: up to 3 distinct values; absent means cross-industry | +| **solutionType** | `Enum<'app' \| 'template' \| 'connector'>` | optional (default: `"app"`) | What a marketplace package is: a managed app, an unmanaged template, or a connector | +| **maturity** | `Enum<'beta' \| 'stable'>` | optional (default: `"stable"`) | Package maturity: beta shows a badge on the listing; absent means stable | | **tags** | `string[]` | optional | Search tags | | **iconUrl** | `string` | optional | Package icon URL | | **screenshots** | `{ url: string; caption?: string }[]` | optional | Screenshots | @@ -266,7 +245,11 @@ Marketplace search request | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **query** | `string` | optional | Full-text search query | -| **category** | `Enum<'crm' \| 'erp' \| 'hr' \| 'finance' \| 'project' \| 'collaboration' \| 'analytics' \| 'integration' \| 'automation' \| 'ai' \| 'security' \| 'developer-tools' \| 'ui-theme' \| 'storage' \| 'other'>` | optional | Filter by category | +| **category** | `never` | optional | [REMOVED] `MarketplaceSearchRequest.category` was removed in @objectstack/spec 18 (ADR-0087) — one category had to say both who a package is for and what it is, and the platform and the cloud catalog spelled it from lists that disagreed. Delete the key. Declare `businessFunctions` (1-3 business functions, the primary one first), optionally `industries` (up to 3; absent means cross-industry) and `solutionType` (`app`, `template` or `connector`; absent means `app`). `LEGACY_CATEGORY_MAPPING` in @objectstack/spec/marketplace gives the facets for every old category value. | +| **businessFunctions** | `Enum<'sales' \| 'marketing' \| 'customer-service' \| 'finance' \| 'hr' \| 'supply-chain' \| 'administration' \| 'project' \| 'legal' \| 'it' \| 'collaboration'>[]` | optional | Filter: any of these business functions | +| **industries** | `Enum<'manufacturing' \| 'trade-distribution' \| 'retail' \| 'logistics' \| 'professional-services' \| 'financial-services' \| 'healthcare' \| 'education' \| 'public-sector' \| 'real-estate-construction'>[]` | optional | Filter: any of these industries | +| **solutionTypes** | `Enum<'app' \| 'template' \| 'connector'>[]` | optional | Filter: any of these solution types | +| **maturity** | `Enum<'beta' \| 'stable'>` | optional | Filter by maturity | | **tags** | `string[]` | optional | Filter by tags | | **pricing** | `Enum<'free' \| 'freemium' \| 'paid' \| 'subscription' \| 'usage-based' \| 'contact-sales'>` | optional | Filter by pricing model | | **publisherVerification** | `Enum<'unverified' \| 'pending' \| 'verified' \| 'trusted' \| 'partner'>` | optional | Filter by publisher verification level | @@ -291,7 +274,7 @@ Marketplace search response | **total** | `integer` | ✅ | Total matching results | | **page** | `integer` | ✅ | Current page number | | **pageSize** | `integer` | ✅ | Items per page | -| **facets** | `{ categories?: object[]; pricing?: object[] }` | optional | Aggregation facets for refining search | +| **facets** | `{ businessFunctions?: object[]; industries?: object[]; solutionTypes?: object[]; pricing?: object[] }` | optional | Aggregation facets for refining search | ### Nested Shape: `MarketplaceSearchResponse.items[number]` @@ -307,7 +290,11 @@ Public-facing package listing on the marketplace | **name** | `string` | ✅ | Display name | | **tagline** | `string` | optional | Short tagline (max 120 chars) | | **description** | `string` | optional | Full description (Markdown) | -| **category** | `Enum<'crm' \| 'erp' \| 'hr' \| 'finance' \| 'project' \| 'collaboration' \| 'analytics' \| …>` | ✅ | Package category | +| **category** | `never` | optional | [REMOVED] `MarketplaceListing.category` was removed in @objectstack/spec 18 (ADR-0087) — one category had to say both who a package is for and what it is, and the platform and the cloud catalog spelled it from lists that disagreed. Delete the key. Declare `businessFunctions` (1-3 business functions, the primary one first), optionally `industries` (up to 3; absent means cross-industry) and `solutionType` (`app`, `template` or `connector`; absent means `app`). `LEGACY_CATEGORY_MAPPING` in @objectstack/spec/marketplace gives the facets for every old category value. | +| **businessFunctions** | `Enum<'sales' \| 'marketing' \| 'customer-service' \| 'finance' \| 'hr' \| 'supply-chain' \| …>[]` | ✅ | Business functions the package serves: 1-3 distinct values, the first is primary | +| **industries** | `Enum<'manufacturing' \| 'trade-distribution' \| 'retail' \| 'logistics' \| …>[]` | optional | Industries the package is built for: up to 3 distinct values; absent means cross-industry | +| **solutionType** | `Enum<'app' \| 'template' \| 'connector'>` | optional (default: `"app"`) | What a marketplace package is: a managed app, an unmanaged template, or a connector | +| **maturity** | `Enum<'beta' \| 'stable'>` | optional (default: `"stable"`) | Package maturity: beta shows a badge on the listing; absent means stable | | **tags** | `string[]` | optional | Search tags | | **iconUrl** | `string` | optional | Package icon URL | | **screenshots** | `{ url: string; caption?: string }[]` | optional | Screenshots | @@ -324,6 +311,16 @@ Public-facing package listing on the marketplace | **updatedAt** | `string` | optional | Last updated timestamp | | **translations** | `Record` | optional | Locale-keyed overrides for name / tagline / description / screenshot captions | +### Nested Shape: `MarketplaceSearchResponse.facets` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **categories** | `never` | optional | [REMOVED] `MarketplaceSearchResponse.facets.categories` was removed in @objectstack/spec 18 (ADR-0087) — one category had to say both who a package is for and what it is, and the platform and the cloud catalog spelled it from lists that disagreed. Delete the key. Declare `businessFunctions` (1-3 business functions, the primary one first), optionally `industries` (up to 3; absent means cross-industry) and `solutionType` (`app`, `template` or `connector`; absent means `app`). `LEGACY_CATEGORY_MAPPING` in @objectstack/spec/marketplace gives the facets for every old category value. | +| **businessFunctions** | `{ value: Enum<'sales' \| 'marketing' \| 'customer-service' \| 'finance' \| 'hr' \| 'supply-chain' \| …>; count: integer }[]` | optional | | +| **industries** | `{ value: Enum<'manufacturing' \| 'trade-distribution' \| 'retail' \| 'logistics' \| …>; count: integer }[]` | optional | | +| **solutionTypes** | `{ value: Enum<'app' \| 'template' \| 'connector'>; count: integer }[]` | optional | | +| **pricing** | `{ model: Enum<'free' \| 'freemium' \| 'paid' \| 'subscription' \| 'usage-based' \| 'contact-sales'>; count: integer }[]` | optional | | + --- diff --git a/content/docs/references/marketplace/meta.json b/content/docs/references/marketplace/meta.json index 57bd5c1825c..34e8396cd01 100644 --- a/content/docs/references/marketplace/meta.json +++ b/content/docs/references/marketplace/meta.json @@ -6,6 +6,8 @@ "package-version", "template-manifest", "---Marketplace---", - "marketplace" + "marketplace", + "---More---", + "taxonomy" ] } \ No newline at end of file diff --git a/content/docs/references/marketplace/package.mdx b/content/docs/references/marketplace/package.mdx index d77b90caca8..e87221018b9 100644 --- a/content/docs/references/marketplace/package.mdx +++ b/content/docs/references/marketplace/package.mdx @@ -29,8 +29,8 @@ See `docs/adr/0003-package-as-first-class-citizen.md` for the full rationale. ## TypeScript Usage ```typescript -import { CreatePackageRequestSchema, PackageSchema, PackageCategorySchema, PackageLocaleSchema, PackagePublisherSchema, PackageTranslationSchema, PackageTranslationsSchema, PackageVisibilitySchema, UpdatePackageRequestSchema } from '@objectstack/spec/marketplace'; -import type { CreatePackageRequest, Package, PackageCategory, PackageLocale, PackagePublisher, PackageTranslation, PackageTranslations, PackageVisibility, UpdatePackageRequest } from '@objectstack/spec/marketplace'; +import { CreatePackageRequestSchema, PackageSchema, PackageLocaleSchema, PackagePublisherSchema, PackageTranslationSchema, PackageTranslationsSchema, PackageVisibilitySchema, UpdatePackageRequestSchema } from '@objectstack/spec/marketplace'; +import type { CreatePackageRequest, Package, PackageLocale, PackagePublisher, PackageTranslation, PackageTranslations, PackageVisibility, UpdatePackageRequest } from '@objectstack/spec/marketplace'; // Validate data const result = CreatePackageRequestSchema.parse(data); @@ -52,7 +52,11 @@ Register a new package in the Control Plane | **displayName** | `string` | ✅ | Display name shown in Studio and Marketplace | | **description** | `string` | optional | Short package description | | **visibility** | `Enum<'private' \| 'org' \| 'marketplace'>` | optional | Package visibility: private = owner org only; org = all envs in owner org; marketplace = public registry | -| **category** | `string` | optional | Package category for marketplace discovery (e.g. "crm", "hr", "finance", "devtools") | +| **category** | `never` | optional | [REMOVED] `CreatePackageRequest.category` was removed in @objectstack/spec 18 (ADR-0087) — one category had to say both who a package is for and what it is, and the platform and the cloud catalog spelled it from lists that disagreed. Delete the key. Declare `businessFunctions` (1-3 business functions, the primary one first), optionally `industries` (up to 3; absent means cross-industry) and `solutionType` (`app`, `template` or `connector`; absent means `app`). `LEGACY_CATEGORY_MAPPING` in @objectstack/spec/marketplace gives the facets for every old category value. | +| **businessFunctions** | `Enum<'sales' \| 'marketing' \| 'customer-service' \| 'finance' \| 'hr' \| 'supply-chain' \| 'administration' \| 'project' \| 'legal' \| 'it' \| 'collaboration'>[]` | optional | Business functions the package serves: 1-3 distinct values, the first is primary | +| **industries** | `Enum<'manufacturing' \| 'trade-distribution' \| 'retail' \| 'logistics' \| 'professional-services' \| 'financial-services' \| 'healthcare' \| 'education' \| 'public-sector' \| 'real-estate-construction'>[]` | optional | Industries the package is built for: up to 3 distinct values; absent means cross-industry | +| **solutionType** | `Enum<'app' \| 'template' \| 'connector'>` | optional | What a marketplace package is: a managed app, an unmanaged template, or a connector | +| **maturity** | `Enum<'beta' \| 'stable'>` | optional | Package maturity: beta shows a badge on the listing; absent means stable | | **tags** | `string[]` | optional | | | **iconUrl** | `string` | optional | | | **homepageUrl** | `string` | optional | | @@ -91,7 +95,11 @@ Per-locale overrides for a package listing | **description** | `string` | optional | Short package description | | **readme** | `string` | optional | Long-form package documentation (markdown) | | **visibility** | `Enum<'private' \| 'org' \| 'marketplace'>` | optional (default: `"org"`) | Package visibility: private = owner org only; org = all envs in owner org; marketplace = public registry | -| **category** | `string` | optional | Package category for marketplace discovery (e.g. "crm", "hr", "finance", "devtools") | +| **category** | `never` | optional | [REMOVED] `Package.category` was removed in @objectstack/spec 18 (ADR-0087) — one category had to say both who a package is for and what it is, and the platform and the cloud catalog spelled it from lists that disagreed. Delete the key. Declare `businessFunctions` (1-3 business functions, the primary one first), optionally `industries` (up to 3; absent means cross-industry) and `solutionType` (`app`, `template` or `connector`; absent means `app`). `LEGACY_CATEGORY_MAPPING` in @objectstack/spec/marketplace gives the facets for every old category value. | +| **businessFunctions** | `Enum<'sales' \| 'marketing' \| 'customer-service' \| 'finance' \| 'hr' \| 'supply-chain' \| 'administration' \| 'project' \| 'legal' \| 'it' \| 'collaboration'>[]` | optional | Business functions the package serves: 1-3 distinct values, the first is primary | +| **industries** | `Enum<'manufacturing' \| 'trade-distribution' \| 'retail' \| 'logistics' \| 'professional-services' \| 'financial-services' \| 'healthcare' \| 'education' \| 'public-sector' \| 'real-estate-construction'>[]` | optional | Industries the package is built for: up to 3 distinct values; absent means cross-industry | +| **solutionType** | `Enum<'app' \| 'template' \| 'connector'>` | optional (default: `"app"`) | What a marketplace package is: a managed app, an unmanaged template, or a connector | +| **maturity** | `Enum<'beta' \| 'stable'>` | optional (default: `"stable"`) | Package maturity: beta shows a badge on the listing; absent means stable | | **tags** | `string[]` | optional | Search and filter tags | | **iconUrl** | `string` | optional | Package icon URL | | **homepageUrl** | `string` | optional | Package homepage URL | @@ -116,15 +124,6 @@ Per-locale overrides for a package listing | **screenshotCaptions** | `Record` | optional | Per-index screenshot caption overrides | ---- - -## PackageCategory - -Package category for marketplace discovery (e.g. "crm", "hr", "finance", "devtools") - -**Type:** `string` - - --- ## PackageLocale @@ -201,7 +200,11 @@ Update mutable package metadata | **description** | `string` | optional | Short package description | | **readme** | `string` | optional | Long-form package documentation (markdown) | | **visibility** | `Enum<'private' \| 'org' \| 'marketplace'>` | optional | Package visibility: private = owner org only; org = all envs in owner org; marketplace = public registry | -| **category** | `string` | optional | Package category for marketplace discovery (e.g. "crm", "hr", "finance", "devtools") | +| **category** | `never` | optional | [REMOVED] `UpdatePackageRequest.category` was removed in @objectstack/spec 18 (ADR-0087) — one category had to say both who a package is for and what it is, and the platform and the cloud catalog spelled it from lists that disagreed. Delete the key. Declare `businessFunctions` (1-3 business functions, the primary one first), optionally `industries` (up to 3; absent means cross-industry) and `solutionType` (`app`, `template` or `connector`; absent means `app`). `LEGACY_CATEGORY_MAPPING` in @objectstack/spec/marketplace gives the facets for every old category value. | +| **businessFunctions** | `Enum<'sales' \| 'marketing' \| 'customer-service' \| 'finance' \| 'hr' \| 'supply-chain' \| 'administration' \| 'project' \| 'legal' \| 'it' \| 'collaboration'>[]` | optional | Business functions the package serves: 1-3 distinct values, the first is primary | +| **industries** | `Enum<'manufacturing' \| 'trade-distribution' \| 'retail' \| 'logistics' \| 'professional-services' \| 'financial-services' \| 'healthcare' \| 'education' \| 'public-sector' \| 'real-estate-construction'>[]` | optional | Industries the package is built for: up to 3 distinct values; absent means cross-industry | +| **solutionType** | `Enum<'app' \| 'template' \| 'connector'>` | optional | What a marketplace package is: a managed app, an unmanaged template, or a connector | +| **maturity** | `Enum<'beta' \| 'stable'>` | optional | Package maturity: beta shows a badge on the listing; absent means stable | | **tags** | `string[]` | optional | | | **iconUrl** | `string` | optional | | | **homepageUrl** | `string` | optional | | diff --git a/content/docs/references/marketplace/taxonomy.mdx b/content/docs/references/marketplace/taxonomy.mdx new file mode 100644 index 00000000000..530b7225287 --- /dev/null +++ b/content/docs/references/marketplace/taxonomy.mdx @@ -0,0 +1,151 @@ +--- +title: Taxonomy — Marketplace Protocol reference +navTitle: Taxonomy +description: "Marketplace taxonomy — the ONE classification of a listed package. Reference for every Taxonomy property with its type and default." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Marketplace taxonomy — the ONE classification of a listed package. + +A package used to carry a single `category`, and that one value had to say +two different things at once: who the package is FOR (a business function) +and what it IS (a full app, a template, a connector). The platform declared +it as a closed enum on the listing (`MarketplaceCategorySchema`) and as an +open string on the package (`PackageCategorySchema`), the cloud catalog kept +a third list, and the scenario library copied the third — four lists that +agreed on five values. A connector filed under `integrations` lost the +business function it served; one `operations` value held three functions. + +The maintainer's ruling replaced it with three facets and one separate flag, +defined here once and read by every surface — the CLI publish path, the cloud +catalog and the console marketplace — so there is no second list to drift: + +| Facet | Key | Arity | Required | +|:--|:--|:--|:--| +| Business function | `businessFunctions` | 1–3, ordered, the first is primary | to submit to the marketplace | +| Industry | `industries` | 0–3 | never — absent means cross-industry | +| Solution type | `solutionType` | one | no — absent means `app` | +| Maturity (not a facet) | `maturity` | one | no — absent means `stable` | + +Filtering semantics the catalog applies: OR inside one facet, AND across +facets. Free-text `tags` stay for search only and generate no filter. + +## Growing a facet + +Values are append-only. Renaming or removing one is an ADR-0087 retirement +with a data migration. Adding one needs a real listed package, or a ruled +scenario card, that needs it, and the console's labels in the same change. +Keep each facet near a dozen values; merge before you grow. + +## What is deliberately absent + +- No `general` / `cross-industry` industry: absence already means that, and a + catch-all value is the one every author (an AI author first) would tick. +- No `theme` solution type: ADR-0019 lists only apps, and a theme ships + inside one. +- No company-size facet: nothing in the catalog or the scenario library uses + one. +- No `other` value anywhere: an unclassifiable package is a question for its + author, never a bucket. + + +**Source:** `packages/spec/src/marketplace/taxonomy.zod.ts` + + +## TypeScript Usage + +```typescript +import { MarketplaceBusinessFunctionSchema, MarketplaceBusinessFunctionsSchema, MarketplaceIndustriesSchema, MarketplaceIndustrySchema, MarketplaceSolutionTypeSchema, PackageMaturitySchema } from '@objectstack/spec/marketplace'; +import type { MarketplaceBusinessFunction, MarketplaceBusinessFunctions, MarketplaceIndustries, MarketplaceIndustry, MarketplaceSolutionType, PackageMaturity } from '@objectstack/spec/marketplace'; + +// Validate data +const result = MarketplaceBusinessFunctionSchema.parse(data); +``` + +--- + +## MarketplaceBusinessFunction + +Business function a marketplace package serves + +### Allowed Values + +* `sales` +* `marketing` +* `customer-service` +* `finance` +* `hr` +* `supply-chain` +* `administration` +* `project` +* `legal` +* `it` +* `collaboration` + + +--- + +## MarketplaceBusinessFunctions + +Business functions the package serves: 1-3 distinct values, the first is primary + +**Type:** `Enum<'sales' | 'marketing' | 'customer-service' | 'finance' | 'hr' | 'supply-chain' | 'administration' | 'project' | 'legal' | 'it' | 'collaboration'>[]` + + +--- + +## MarketplaceIndustries + +Industries the package is built for: up to 3 distinct values; absent means cross-industry + +**Type:** `Enum<'manufacturing' | 'trade-distribution' | 'retail' | 'logistics' | 'professional-services' | 'financial-services' | 'healthcare' | 'education' | 'public-sector' | 'real-estate-construction'>[]` + + +--- + +## MarketplaceIndustry + +Industry a marketplace package is built for + +### Allowed Values + +* `manufacturing` +* `trade-distribution` +* `retail` +* `logistics` +* `professional-services` +* `financial-services` +* `healthcare` +* `education` +* `public-sector` +* `real-estate-construction` + + +--- + +## MarketplaceSolutionType + +What a marketplace package is: a managed app, an unmanaged template, or a connector + +### Allowed Values + +* `app` +* `template` +* `connector` + + +--- + +## PackageMaturity + +Package maturity: beta shows a badge on the listing; absent means stable + +### Allowed Values + +* `beta` +* `stable` + + +--- + diff --git a/content/docs/references/marketplace/template-manifest.mdx b/content/docs/references/marketplace/template-manifest.mdx index 2281f384c54..1f520065111 100644 --- a/content/docs/references/marketplace/template-manifest.mdx +++ b/content/docs/references/marketplace/template-manifest.mdx @@ -45,7 +45,11 @@ objectstack.manifest.json — template / package source descriptor | **displayName** | `string` | ✅ | Display name shown in Studio and Marketplace | | **description** | `string` | optional | Short package description | | **visibility** | `Enum<'private' \| 'org' \| 'marketplace'>` | optional | Package visibility: private = owner org only; org = all envs in owner org; marketplace = public registry | -| **category** | `string` | optional | Package category for marketplace discovery (e.g. "crm", "hr", "finance", "devtools") | +| **category** | `never` | optional | [REMOVED] `TemplateManifest.category` was removed in @objectstack/spec 18 (ADR-0087) — one category had to say both who a package is for and what it is, and the platform and the cloud catalog spelled it from lists that disagreed. Delete the key. Declare `businessFunctions` (1-3 business functions, the primary one first), optionally `industries` (up to 3; absent means cross-industry) and `solutionType` (`app`, `template` or `connector`; absent means `app`). `LEGACY_CATEGORY_MAPPING` in @objectstack/spec/marketplace gives the facets for every old category value. | +| **businessFunctions** | `Enum<'sales' \| 'marketing' \| 'customer-service' \| 'finance' \| 'hr' \| 'supply-chain' \| 'administration' \| 'project' \| 'legal' \| 'it' \| 'collaboration'>[]` | optional | Business functions the package serves: 1-3 distinct values, the first is primary | +| **industries** | `Enum<'manufacturing' \| 'trade-distribution' \| 'retail' \| 'logistics' \| 'professional-services' \| 'financial-services' \| 'healthcare' \| 'education' \| 'public-sector' \| 'real-estate-construction'>[]` | optional | Industries the package is built for: up to 3 distinct values; absent means cross-industry | +| **solutionType** | `Enum<'app' \| 'template' \| 'connector'>` | optional | What a marketplace package is: a managed app, an unmanaged template, or a connector | +| **maturity** | `Enum<'beta' \| 'stable'>` | optional | Package maturity: beta shows a badge on the listing; absent means stable | | **tags** | `string[]` | optional | | | **iconUrl** | `string` | optional | | | **homepageUrl** | `string` | optional | | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/marketplace.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/marketplace.md index dbe439127ab..d45dc313179 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/marketplace.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/marketplace.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `marketplace/` | 29 | +| `marketplace/` | 31 | diff --git a/docs/qa/platform-checklist/coverage.json b/docs/qa/platform-checklist/coverage.json index ce70b79f1b8..65a8782dea5 100644 --- a/docs/qa/platform-checklist/coverage.json +++ b/docs/qa/platform-checklist/coverage.json @@ -385,6 +385,9 @@ "ai.console-ai-surface-gating", "platform-core.marketplace-install-local-lifecycle" ] + }, + "template_manifest": { + "waived": "WAIVED 2026-11-06 — no item in this checklist can drive it, measured rather than asserted. TemplateManifestSchema (packages/spec/src/marketplace/template-manifest.zod.ts) is `objectstack.manifest.json`, the package-listing file; its one runtime reader is `os package publish` (packages/cli/src/commands/package/publish.ts), whose every request goes to a HOSTED control plane (`/api/v1/cloud/packages`) that a local app booted for this checklist does not serve, and its other reader is create-objectstack at scaffold time. The CLI half is pinned where it can fail: packages/cli/test/package-publish-taxonomy.test.ts (the retired `category` refused before any network call, each facet judged and sent under its wire name, the solutionType cross-check against the built artifact) and packages/cli/test/package-publish-uncarried-manifest-keys.test.ts (every schema key sent, warned about, refused or scaffold-only); every shipped file is parsed by `pnpm --filter @objectstack/spec check:template-manifests`. The catalog half — storing, filtering and showing the facets — is the cloud checklist's (objectstack-ai/cloud#3661) and the console marketplace card's. WHAT RETIRES THIS WAIVER — the console marketplace card lands facet filters this checklist can drive against a catalog it can boot, in which case author the browse-and-filter item and flip this entry to items." } } } diff --git a/docs/qa/scenarios/README.md b/docs/qa/scenarios/README.md index 5040a854d65..6313b3a668d 100644 --- a/docs/qa/scenarios/README.md +++ b/docs/qa/scenarios/README.md @@ -31,7 +31,7 @@ question's computed truth; a card that does not load is red in CI. |:---|:---|:---| | 1 | `prompt` | `{ short, detailed }`, each `{ zh?, en? }` with at least one language. | | 2 | `expect` | The structural expectation the deterministic scorer reads, field for field: `objects`, `minFieldsPerObject?`, `selectFields?` (`{ object, field, minOptions }`), `lookups?` (`{ object, field, to }`), `dashboards?`, `appNavTargets?`. | -| 3 | `capabilities`, `category` | Tags `.`, where `` is a closed set: the basenames of `docs/qa/platform-checklist/areas/*.json`, read from the tree. `category` is a closed enum (the marketplace taxonomy): `app, crm, sales, marketing, finance, hr, operations, project, productivity, data, integrations, devtools, utility, other`. | +| 3 | `capabilities`, `businessFunctions` | Tags `.`, where `` is a closed set: the basenames of `docs/qa/platform-checklist/areas/*.json`, read from the tree. `businessFunctions` is the marketplace taxonomy's business-function facet, read from `@objectstack/spec/marketplace` (`MarketplaceBusinessFunctionsSchema`, defined once in `packages/spec/src/marketplace/taxonomy.zod.ts`): 1-3 distinct values, the primary one first — the card is classified the way the template it graduates into will be listed. | | 4 | `run.seed`, `run.questions`, `run.personas` | Seed rows per object; at least five read-only questions, each with a **declarative** truth spec; at least two personas, each a role, the objects or fields it must NOT see, and optionally a row `scope`. | | 5 | `run.operations`, `run.guardrails`, `run.agentActions?` | At least two writes, each acting as a persona and declaring its `outcome`; the five guardrail checks; optionally the agent-callable business actions. | | 6 | `iterate` | A one-sentence change request plus the `expectDelta` it should add to `expect`. | diff --git a/docs/qa/scenarios/asset_lifecycle.json b/docs/qa/scenarios/asset_lifecycle.json index a9bec15bbfd..a20e96cc64f 100644 --- a/docs/qa/scenarios/asset_lifecycle.json +++ b/docs/qa/scenarios/asset_lifecycle.json @@ -67,7 +67,7 @@ "ai.ask-data", "ai.agent-actions" ], - "category": "operations", + "businessFunctions": ["administration"], "run": { "seed": { "department": [ diff --git a/docs/qa/scenarios/crm_account_with_contacts.json b/docs/qa/scenarios/crm_account_with_contacts.json index a87d03f1c9c..1aafc87d9a8 100644 --- a/docs/qa/scenarios/crm_account_with_contacts.json +++ b/docs/qa/scenarios/crm_account_with_contacts.json @@ -16,7 +16,7 @@ "lookups": [{"object": "contact", "field": "account", "to": "account"}] }, "capabilities": ["records-forms.lookup", "records-forms.select-options", "ai.ask-data", "access-security.field-permissions", "access-security.row-scope"], - "category": "crm", + "businessFunctions": ["sales"], "run": { "seed": { "account": [ diff --git a/docs/qa/scenarios/invoice_with_line_items.json b/docs/qa/scenarios/invoice_with_line_items.json index 772e9f5cd29..a10b9026556 100644 --- a/docs/qa/scenarios/invoice_with_line_items.json +++ b/docs/qa/scenarios/invoice_with_line_items.json @@ -24,7 +24,7 @@ "access-security.field-permissions", "access-security.row-scope" ], - "category": "finance", + "businessFunctions": ["finance"], "run": { "seed": { "account": [ diff --git a/docs/qa/scenarios/leave_balances.json b/docs/qa/scenarios/leave_balances.json index 4a68c197bbc..f9dbe79f380 100644 --- a/docs/qa/scenarios/leave_balances.json +++ b/docs/qa/scenarios/leave_balances.json @@ -54,7 +54,7 @@ "ai.ask-data", "ai.agent-actions" ], - "category": "hr", + "businessFunctions": ["hr"], "run": { "seed": { "department": [ diff --git a/docs/qa/scenarios/order_to_cash.json b/docs/qa/scenarios/order_to_cash.json index a36c1ce0b46..fe06b2768a5 100644 --- a/docs/qa/scenarios/order_to_cash.json +++ b/docs/qa/scenarios/order_to_cash.json @@ -63,7 +63,7 @@ "ai.ask-data", "ai.agent-actions" ], - "category": "sales", + "businessFunctions": ["sales", "finance"], "run": { "seed": { "employee": [ diff --git a/docs/qa/scenarios/project_with_tasks.json b/docs/qa/scenarios/project_with_tasks.json index e95c2a6ff0f..8a4ae06edec 100644 --- a/docs/qa/scenarios/project_with_tasks.json +++ b/docs/qa/scenarios/project_with_tasks.json @@ -22,7 +22,7 @@ "ai.ask-data", "access-security.row-scope" ], - "category": "project", + "businessFunctions": ["project"], "run": { "seed": { "project": [ diff --git a/docs/qa/scenarios/requisition_to_receipt.json b/docs/qa/scenarios/requisition_to_receipt.json index de0d74ec340..31012a548ac 100644 --- a/docs/qa/scenarios/requisition_to_receipt.json +++ b/docs/qa/scenarios/requisition_to_receipt.json @@ -74,7 +74,7 @@ "ai.ask-data", "ai.agent-actions" ], - "category": "operations", + "businessFunctions": ["supply-chain"], "run": { "seed": { "department": [ diff --git a/docs/qa/scenarios/service_desk.json b/docs/qa/scenarios/service_desk.json index 777bf75607a..d76dca0bf67 100644 --- a/docs/qa/scenarios/service_desk.json +++ b/docs/qa/scenarios/service_desk.json @@ -71,7 +71,7 @@ "ai.ask-data", "ai.agent-actions" ], - "category": "operations", + "businessFunctions": ["customer-service"], "run": { "seed": { "customer": [ diff --git a/docs/qa/scenarios/travel_expense.json b/docs/qa/scenarios/travel_expense.json index 9956277665f..16aab401ca9 100644 --- a/docs/qa/scenarios/travel_expense.json +++ b/docs/qa/scenarios/travel_expense.json @@ -54,7 +54,7 @@ "ai.ask-data", "ai.agent-actions" ], - "category": "finance", + "businessFunctions": ["finance"], "run": { "seed": { "department": [ diff --git a/packages/apps/connector-docusign/objectstack.manifest.json b/packages/apps/connector-docusign/objectstack.manifest.json index 764c091cc74..9029bad1725 100644 --- a/packages/apps/connector-docusign/objectstack.manifest.json +++ b/packages/apps/connector-docusign/objectstack.manifest.json @@ -5,7 +5,8 @@ "specVersion": "^18.0.0", "displayName": "DocuSign eSignature", "description": "Send documents for signature, read envelopes and follow their signature status from flows, over DocuSign's eSignature OpenAPI document. Developer sandbox; your administrator registers their own DocuSign integration key.", - "category": "productivity", + "businessFunctions": ["collaboration", "legal"], + "solutionType": "connector", "tags": ["docusign", "esignature", "openapi", "connector", "oauth"], "license": "Apache-2.0", "iconUrl": "https://raw.githubusercontent.com/objectstack-ai/objectstack/main/packages/apps/connector-docusign/assets/icon.svg", diff --git a/packages/apps/connector-docusign/test/listing.test.ts b/packages/apps/connector-docusign/test/listing.test.ts index 66a542987ba..8a079fdc118 100644 --- a/packages/apps/connector-docusign/test/listing.test.ts +++ b/packages/apps/connector-docusign/test/listing.test.ts @@ -18,7 +18,8 @@ import { createHash } from 'node:crypto'; import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { TemplateManifestSchema } from '@objectstack/spec/marketplace'; +import { explainSolutionTypeContradiction, TemplateManifestSchema } from '@objectstack/spec/marketplace'; +import { resolveInstallModes } from '@objectstack/spec/kernel'; import { DeclarativeConnectorEntrySchema } from '@objectstack/spec/integration'; import stack from '../objectstack.config.js'; import { @@ -93,6 +94,16 @@ describe('connector-docusign listing', () => { expect(manifest.manifestId).toBe(stack.manifest!.id); }); + it('is listed as a connector, and the stack declares the connector that makes it one', () => { + // `os package publish` runs the same check against the built artifact and + // refuses a `connector` that declares none. + expect(manifest.solutionType).toBe('connector'); + expect(explainSolutionTypeContradiction('connector', { + installModes: resolveInstallModes(stack.manifest), + connectorCount: (stack.connectors ?? []).length, + })).toBeUndefined(); + }); + it('declares no key the publisher ignores', () => { // The handbook: the platform decides `publisher` and `isStarter`; the CLI never sends them. expect(manifest).not.toHaveProperty('publisher'); diff --git a/packages/apps/connector-docusign/vitest.config.ts b/packages/apps/connector-docusign/vitest.config.ts index dce0f58c1b4..d4bff1eda34 100644 --- a/packages/apps/connector-docusign/vitest.config.ts +++ b/packages/apps/connector-docusign/vitest.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ { find: /^@objectstack\/spec$/, replacement: path.resolve(HERE, '../../spec/src/index.ts') }, { find: /^@objectstack\/spec\/data$/, replacement: path.resolve(HERE, '../../spec/src/data/index.ts') }, { find: /^@objectstack\/spec\/integration$/, replacement: path.resolve(HERE, '../../spec/src/integration/index.ts') }, + { find: /^@objectstack\/spec\/kernel$/, replacement: path.resolve(HERE, '../../spec/src/kernel/index.ts') }, { find: /^@objectstack\/spec\/marketplace$/, replacement: path.resolve(HERE, '../../spec/src/marketplace/index.ts') }, { find: /^@objectstack\/verify$/, replacement: path.resolve(HERE, '../../verify/src/index.ts') }, ], diff --git a/packages/apps/connector-github/objectstack.manifest.json b/packages/apps/connector-github/objectstack.manifest.json index b1b94b0295d..cb7058d6652 100644 --- a/packages/apps/connector-github/objectstack.manifest.json +++ b/packages/apps/connector-github/objectstack.manifest.json @@ -5,7 +5,8 @@ "specVersion": "^18.0.0", "displayName": "GitHub", "description": "Search and read GitHub issues and pull requests from flows, through GitHub's remote MCP server. Read only.", - "category": "devtools", + "businessFunctions": ["it"], + "solutionType": "connector", "tags": ["github", "mcp", "connector", "issues"], "license": "Apache-2.0", "iconUrl": "https://raw.githubusercontent.com/objectstack-ai/objectstack/main/packages/apps/connector-github/assets/icon.svg", diff --git a/packages/apps/connector-github/test/listing.test.ts b/packages/apps/connector-github/test/listing.test.ts index cc4daef583d..d8ae2ed3f33 100644 --- a/packages/apps/connector-github/test/listing.test.ts +++ b/packages/apps/connector-github/test/listing.test.ts @@ -13,7 +13,8 @@ import { describe, it, expect } from 'vitest'; import { readFileSync, existsSync, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { TemplateManifestSchema } from '@objectstack/spec/marketplace'; +import { explainSolutionTypeContradiction, TemplateManifestSchema } from '@objectstack/spec/marketplace'; +import { resolveInstallModes } from '@objectstack/spec/kernel'; import stack from '../objectstack.config.js'; const manifestPath = fileURLToPath(new URL('../objectstack.manifest.json', import.meta.url)); @@ -59,6 +60,16 @@ describe('connector-github listing', () => { expect(manifest.manifestId).toBe(stack.manifest!.id); }); + it('is listed as a connector, and the stack declares the connector that makes it one', () => { + // `os package publish` runs the same check against the built artifact and + // refuses a `connector` that declares none. + expect(manifest.solutionType).toBe('connector'); + expect(explainSolutionTypeContradiction('connector', { + installModes: resolveInstallModes(stack.manifest), + connectorCount: (stack.connectors ?? []).length, + })).toBeUndefined(); + }); + it('declares no key the publisher ignores', () => { // The handbook: the platform decides `publisher` and `isStarter`; the CLI never sends them. expect(manifest).not.toHaveProperty('publisher'); diff --git a/packages/apps/connector-github/vitest.config.ts b/packages/apps/connector-github/vitest.config.ts index a0367924c0c..cb49bec5720 100644 --- a/packages/apps/connector-github/vitest.config.ts +++ b/packages/apps/connector-github/vitest.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ { find: /^@objectstack\/connector-mcp$/, replacement: path.resolve(HERE, '../../connectors/connector-mcp/src/index.ts') }, { find: /^@objectstack\/spec$/, replacement: path.resolve(HERE, '../../spec/src/index.ts') }, { find: /^@objectstack\/spec\/integration$/, replacement: path.resolve(HERE, '../../spec/src/integration/index.ts') }, + { find: /^@objectstack\/spec\/kernel$/, replacement: path.resolve(HERE, '../../spec/src/kernel/index.ts') }, { find: /^@objectstack\/spec\/marketplace$/, replacement: path.resolve(HERE, '../../spec/src/marketplace/index.ts') }, { find: /^@objectstack\/verify$/, replacement: path.resolve(HERE, '../../verify/src/index.ts') }, ], diff --git a/packages/apps/connector-stripe/objectstack.manifest.json b/packages/apps/connector-stripe/objectstack.manifest.json index 387d81ef611..97a06944eef 100644 --- a/packages/apps/connector-stripe/objectstack.manifest.json +++ b/packages/apps/connector-stripe/objectstack.manifest.json @@ -5,7 +5,8 @@ "specVersion": "^18.0.0", "displayName": "Stripe", "description": "Read Stripe customers, charges, invoices and payment intents from flows, over Stripe's own OpenAPI document. Read only.", - "category": "finance", + "businessFunctions": ["finance"], + "solutionType": "connector", "tags": ["stripe", "openapi", "connector", "payments"], "license": "Apache-2.0", "iconUrl": "https://raw.githubusercontent.com/objectstack-ai/objectstack/main/packages/apps/connector-stripe/assets/icon.svg", diff --git a/packages/apps/connector-stripe/test/listing.test.ts b/packages/apps/connector-stripe/test/listing.test.ts index 0c840d2cab4..256e9e3ed2e 100644 --- a/packages/apps/connector-stripe/test/listing.test.ts +++ b/packages/apps/connector-stripe/test/listing.test.ts @@ -16,7 +16,8 @@ import { describe, it, expect } from 'vitest'; import { createHash } from 'node:crypto'; import { readFileSync, existsSync, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { TemplateManifestSchema } from '@objectstack/spec/marketplace'; +import { explainSolutionTypeContradiction, TemplateManifestSchema } from '@objectstack/spec/marketplace'; +import { resolveInstallModes } from '@objectstack/spec/kernel'; import stack from '../objectstack.config.js'; import { STRIPE_READ_OPERATIONS, StripeConnector } from '../src/connectors/stripe.connector.js'; @@ -70,6 +71,16 @@ describe('connector-stripe listing', () => { expect(manifest.manifestId).toBe(stack.manifest!.id); }); + it('is listed as a connector, and the stack declares the connector that makes it one', () => { + // `os package publish` runs the same check against the built artifact and + // refuses a `connector` that declares none. + expect(manifest.solutionType).toBe('connector'); + expect(explainSolutionTypeContradiction('connector', { + installModes: resolveInstallModes(stack.manifest), + connectorCount: (stack.connectors ?? []).length, + })).toBeUndefined(); + }); + it('declares no key the publisher ignores', () => { // The handbook: the platform decides `publisher` and `isStarter`; the CLI never sends them. expect(manifest).not.toHaveProperty('publisher'); diff --git a/packages/apps/connector-stripe/vitest.config.ts b/packages/apps/connector-stripe/vitest.config.ts index d61eee3cef8..dc9507fe435 100644 --- a/packages/apps/connector-stripe/vitest.config.ts +++ b/packages/apps/connector-stripe/vitest.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ { find: /^@objectstack\/connector-openapi$/, replacement: path.resolve(HERE, '../../connectors/connector-openapi/src/index.ts') }, { find: /^@objectstack\/spec$/, replacement: path.resolve(HERE, '../../spec/src/index.ts') }, { find: /^@objectstack\/spec\/integration$/, replacement: path.resolve(HERE, '../../spec/src/integration/index.ts') }, + { find: /^@objectstack\/spec\/kernel$/, replacement: path.resolve(HERE, '../../spec/src/kernel/index.ts') }, { find: /^@objectstack\/spec\/marketplace$/, replacement: path.resolve(HERE, '../../spec/src/marketplace/index.ts') }, { find: /^@objectstack\/verify$/, replacement: path.resolve(HERE, '../../verify/src/index.ts') }, ], diff --git a/packages/cli/src/commands/package/publish.ts b/packages/cli/src/commands/package/publish.ts index f863b98b65a..77058e567f5 100644 --- a/packages/cli/src/commands/package/publish.ts +++ b/packages/cli/src/commands/package/publish.ts @@ -32,7 +32,15 @@ import { readFile } from 'node:fs/promises'; import { resolve as resolvePath, basename, dirname, isAbsolute } from 'node:path'; import { Args, Command, Flags } from '@oclif/core'; -import { PackageSchema } from '@objectstack/spec/marketplace'; +import { + CreatePackageRequestSchema, + explainSolutionTypeContradiction, + LEGACY_CATEGORY_MAPPING, + MarketplaceBusinessFunctionSchema, + PackageSchema, + type MarketplaceSolutionType, +} from '@objectstack/spec/marketplace'; +import { ManifestSchema, resolveInstallModes } from '@objectstack/spec/kernel'; import { printHeader, printKV, printSuccess, printError, printStep, printWarning, isExitSignal } from '../../utils/format.js'; import { DEFAULT_CLOUD_URL, resolveCloudCredential } from '../../utils/cloud-config.js'; import { installTargetLine, resolveInstallTarget, type InstallTargetResolution } from '../../utils/install-target.js'; @@ -229,6 +237,93 @@ function deriveVersion(artifact: any, fallback: string | undefined): string { return `0.0.0-dev.${t}`; } +/** + * The refusal for a retired `category`, from the manifest or the hidden + * `--category` flag, naming the facets that replace it — for the value given + * when `LEGACY_CATEGORY_MAPPING` knows it. + * + * Refused, never dropped: an author who wrote a category meant to classify the + * package, and silently publishing it unclassified would hide that. Refused + * before any network call, so nothing is half-published. + */ +export function retiredCategoryRefusal(source: 'manifest' | 'flag', value: unknown): string { + const where = source === 'flag' + ? '`--category` was removed' + : 'objectstack.manifest.json declares `category`, which was removed'; + const head = `${where} in @objectstack/spec 18: the marketplace taxonomy is \`businessFunctions\` ` + + '(1-3 business functions, the primary one first), optional `industries` (up to 3) and `solutionType` ' + + '(`app`, `template` or `connector`).'; + const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''; + const mapping = raw ? LEGACY_CATEGORY_MAPPING[raw] : undefined; + const functions = MarketplaceBusinessFunctionSchema.options.join(', '); + const fix = 'Delete `category` and declare the facets in objectstack.manifest.json.'; + if (!mapping) { + return `${head} ${fix} Business functions: ${functions}.`; + } + // `starter` reaches this command from a project scaffolded off the blank + // template, which shipped it as its own marker. It never classified the + // project, so the template type the catalog maps it to is not the answer here. + if (raw === 'starter') { + return `${head} "starter" was the blank template's own marker, copied into projects scaffolded from it; ` + + `it does not classify your package. ${fix} Business functions: ${functions}.`; + } + const tag = 'tag' in mapping && mapping.tag ? ` Keep "${mapping.tag}" in \`tags\` if it helps search.` : ''; + if (mapping.kind === 'unlistable') { + return `${head} "${raw}" has no replacement: ${mapping.reason}. Delete \`category\`.`; + } + if (mapping.kind === 'manual') { + return `${head} "${raw}" maps to "solutionType": "${mapping.solutionType}", and its business functions are ` + + `yours to choose (1-3 of: ${functions}). ${fix}${tag}`; + } + const declared = mapping.businessFunctions.map((f) => `"${f}"`).join(', '); + const review = mapping.review ? ' Check them: this old value covered several functions.' : ''; + return `${head} "${raw}" maps to "businessFunctions": [${declared}], "solutionType": "${mapping.solutionType}".` + + `${review} ${fix}${tag}`; +} + +/** The facet keys `objectstack.manifest.json` declares, and the wire key each is sent under. */ +const TAXONOMY_WIRE_KEYS = { + businessFunctions: 'business_functions', + industries: 'industries', + solutionType: 'solution_type', + maturity: 'maturity', +} as const; + +/** + * Read the marketplace facets off `objectstack.manifest.json` and check them + * against the built artifact: each key's own shape (the spec's schema for it), + * then the declared `solutionType` against what the artifact ships + * (`explainSolutionTypeContradiction`). Returns the body keys to send, or the + * refusal. An absent key is not sent, so a re-publish leaves the stored value + * alone. + */ +export function readPublishTaxonomy( + manifest: Record, + artifact: any, +): { ok: true; body: Record } | { ok: false; message: string } { + const body: Record = {}; + for (const [key, wire] of Object.entries(TAXONOMY_WIRE_KEYS)) { + if (manifest[key] === undefined) continue; + const field = CreatePackageRequestSchema.shape[key as keyof typeof TAXONOMY_WIRE_KEYS]; + const parsed = field.safeParse(manifest[key]); + if (!parsed.success) { + const why = parsed.error.issues.map((issue) => issue.message).join('; '); + return { ok: false, message: `objectstack.manifest.json \`${key}\` is not valid: ${why}` }; + } + body[wire] = parsed.data; + } + const solutionType = body.solution_type as MarketplaceSolutionType | undefined; + if (solutionType !== undefined) { + const modes = ManifestSchema.shape.installModes.safeParse(artifact?.manifest?.installModes); + const contradiction = explainSolutionTypeContradiction(solutionType, { + installModes: resolveInstallModes(modes.success ? { installModes: modes.data } : undefined), + connectorCount: Array.isArray(artifact?.connectors) ? artifact.connectors.length : 0, + }); + if (contradiction !== undefined) return { ok: false, message: contradiction }; + } + return { ok: true, body }; +} + /** * Load `objectstack.manifest.json` from `cwd` if it exists. Returns the * parsed JSON plus the directory it was loaded from (used to resolve @@ -420,8 +515,12 @@ export default class PackagePublish extends Command { description: Flags.string({ description: 'Short package description', }), + // REMOVED with the spec's `category`. Still declared, hidden, so passing + // it is answered with the facets that replace it rather than oclif's bare + // "Nonexistent flag". category: Flags.string({ - description: 'Marketplace category slug (e.g. crm, hr, devtools)', + description: 'REMOVED: declare businessFunctions / industries / solutionType in objectstack.manifest.json', + hidden: true, }), // ⛔ No `default:` here. The package upsert is also the RE-publish path, and // the control plane patches an existing package's visibility whenever the @@ -545,13 +644,31 @@ export default class PackagePublish extends Command { printSuccess(`Loaded artifact (${(artifactRaw.length / 1024).toFixed(1)} KB)`); // Load `objectstack.manifest.json` (optional). Provides declarative - // defaults for manifestId / displayName / category / tagline / readme + // defaults for manifestId / displayName / the facets / tagline / readme // / per-locale translations etc. CLI flags always win over manifest. const tplManifest = await tryLoadTemplateManifest(process.cwd()); if (tplManifest) { printStep(`Loaded objectstack.manifest.json`); } const m = tplManifest?.data ?? {}; + // The retired `category`, from either source, is refused before + // anything else is read or sent — see retiredCategoryRefusal. + if (flags.category !== undefined) { + printError(retiredCategoryRefusal('flag', flags.category)); + this.exit(1); + return; + } + if (m.category !== undefined) { + printError(retiredCategoryRefusal('manifest', m.category)); + this.exit(1); + return; + } + const taxonomy = readPublishTaxonomy(m, artifact); + if (!taxonomy.ok) { + printError(taxonomy.message); + this.exit(1); + return; + } for (const warning of uncarriedManifestWarnings(m)) printWarning(warning); const baseDir = tplManifest?.baseDir ?? process.cwd(); @@ -682,8 +799,10 @@ export default class PackagePublish extends Command { } else if (tagline) { pkgBody.description = tagline; } - const cat = flags.category ?? (typeof m.category === 'string' ? m.category : undefined); - if (cat) pkgBody.category = cat; + // The marketplace facets (`businessFunctions`, `industries`, + // `solutionType`, `maturity`), already checked above. `category` never + // reaches this point. + Object.assign(pkgBody, taxonomy.body); // Listing tags, as the author wrote them. The hosted route owns the // shape and the bounds (it trims, de-duplicates and caps them) and // answers a 400 that is printed as it is; this command keeps no copy of @@ -853,7 +972,8 @@ export default class PackagePublish extends Command { console.log(''); console.log( ' Fix the items above on the sys_package row (use --readme / --readme-file,\n' + - ' --icon-url, --description, --category) and re-run with --submit or --auto-approve.', + ' --icon-url, --description; the facets in objectstack.manifest.json) and re-run with\n' + + ' --submit or --auto-approve.', ); } this.exit(1); diff --git a/packages/cli/test/package-publish-taxonomy.test.ts b/packages/cli/test/package-publish-taxonomy.test.ts new file mode 100644 index 00000000000..03c77e66038 --- /dev/null +++ b/packages/cli/test/package-publish-taxonomy.test.ts @@ -0,0 +1,239 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os package publish` and the marketplace taxonomy (`@objectstack/spec/marketplace`, + * `taxonomy.zod.ts`). + * + * - The retired `category` — in `objectstack.manifest.json` or as the hidden + * `--category` flag — is refused before any network call, naming the facets + * that replace it, for the value given when the spec's mapping knows it. + * - The facets (`businessFunctions`, `industries`, `solutionType`, `maturity`) + * are judged by the spec's own schema for each key and sent under their wire + * names; an absent key is not sent. + * - A declared `solutionType` is checked against the built artifact: a + * `template` needs the `unmanaged` install mode, a `connector` a declared + * connector. + * + * Each case drives the real command and reads the request body it sends — or + * asserts that nothing was sent at all. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import PackagePublish, { readPublishTaxonomy, retiredCategoryRefusal } from '../src/commands/package/publish.js'; + +type Call = { url: string; body: any }; + +function stubCloud(): Call[] { + const calls: Call[] = []; + vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => { + calls.push({ url, body: JSON.parse(init.body) }); + const data = url.endsWith('/versions') + ? { id: 'ver_1', version: '1.2.0', listing_status: 'draft' } + : { id: 'pkg_1', created: true, visibility: 'org' }; + return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data }) } as any; + })); + return calls; +} + +const ARTIFACT = { manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.2.0' }, objects: [] }; + +describe('os package publish — the marketplace taxonomy', () => { + let dir = ''; + const prevEnv = { url: process.env.OS_CLOUD_URL, key: process.env.OS_CLOUD_API_KEY }; + const prevCwd = process.cwd(); + + afterEach(async () => { + process.chdir(prevCwd); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + process.env.OS_CLOUD_URL = prevEnv.url; + process.env.OS_CLOUD_API_KEY = prevEnv.key; + if (dir) await rm(dir, { recursive: true, force: true }); + dir = ''; + }); + + async function projectWith(manifest: Record, artifact: Record = ARTIFACT): Promise { + dir = await mkdtemp(join(tmpdir(), 'package-publish-taxonomy-')); + const path = join(dir, 'objectstack.json'); + await writeFile(path, JSON.stringify(artifact)); + await writeFile(join(dir, 'objectstack.manifest.json'), JSON.stringify(manifest)); + process.chdir(dir); + process.env.OS_CLOUD_URL = 'http://cloud.test'; + process.env.OS_CLOUD_API_KEY = 'tok_123'; + return path; + } + + function captureOutput(): string[] { + const lines: string[] = []; + for (const channel of ['log', 'error', 'warn'] as const) { + vi.spyOn(console, channel).mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }); + } + return lines; + } + + /** Run the command; the exit code it ended with (undefined when it did not exit). */ + async function run(argv: string[]): Promise { + try { + await PackagePublish.run(argv); + return undefined; + } catch (err: any) { + return err?.oclif?.exit ?? err?.exitCode; + } + } + + const BASE = { name: 'acme-crm', specVersion: '^18.0.0' }; + + describe('the retired category', () => { + it('refuses a manifest `category` before any network call, naming the facets for its value', async () => { + const path = await projectWith({ ...BASE, category: 'finance' }); + const calls = stubCloud(); + const out = captureOutput(); + + expect(await run([path])).toBe(1); + expect(calls).toEqual([]); + const text = out.join('\n'); + expect(text).toContain('objectstack.manifest.json declares `category`, which was removed'); + expect(text).toContain('"finance" maps to "businessFunctions": ["finance"], "solutionType": "app"'); + }); + + it('refuses the hidden --category flag the same way', async () => { + const path = await projectWith({ ...BASE }); + const calls = stubCloud(); + const out = captureOutput(); + + expect(await run([path, '--category', 'devtools'])).toBe(1); + expect(calls).toEqual([]); + expect(out.join('\n')).toContain('`--category` was removed'); + expect(out.join('\n')).toContain('"devtools" maps to "businessFunctions": ["it"]'); + }); + + it('tells a project scaffolded from the blank template that `starter` never classified it', async () => { + const path = await projectWith({ ...BASE, category: 'starter', isStarter: true }); + const calls = stubCloud(); + const out = captureOutput(); + + expect(await run([path])).toBe(1); + expect(calls).toEqual([]); + expect(out.join('\n')).toContain('"starter" was the blank template\'s own marker'); + expect(out.join('\n')).not.toContain('"solutionType": "template"'); + }); + }); + + describe('retiredCategoryRefusal', () => { + it('names the author\'s choice for a value with no certain business function, and keeps the old word for search', () => { + const refusal = retiredCategoryRefusal('manifest', 'analytics'); + expect(refusal).toContain('"analytics" maps to "solutionType": "app", and its business functions are yours to choose'); + expect(refusal).toContain('Keep "analytics" in `tags`'); + }); + + it('says a value ADR-0019 never lists has no replacement', () => { + expect(retiredCategoryRefusal('manifest', 'ui-theme')).toMatch(/"ui-theme" has no replacement: .*ADR-0019/); + }); + + it('lists the business functions for a value the mapping does not know', () => { + expect(retiredCategoryRefusal('flag', 'travel-agency')).toMatch(/Business functions: sales, marketing, customer-service/); + }); + }); + + describe('the facets', () => { + it('sends every declared facet under its wire name', async () => { + const path = await projectWith({ + ...BASE, + businessFunctions: ['sales', 'customer-service'], + industries: ['retail'], + solutionType: 'app', + maturity: 'beta', + }); + const calls = stubCloud(); + captureOutput(); + + expect(await run([path])).toBeUndefined(); + expect(calls[0].body).toMatchObject({ + business_functions: ['sales', 'customer-service'], + industries: ['retail'], + solution_type: 'app', + maturity: 'beta', + }); + expect('category' in calls[0].body).toBe(false); + }); + + it('sends no facet the manifest does not declare, so a re-publish leaves the stored values alone', async () => { + const path = await projectWith({ ...BASE }); + const calls = stubCloud(); + captureOutput(); + + expect(await run([path])).toBeUndefined(); + for (const wire of ['business_functions', 'industries', 'solution_type', 'maturity']) { + expect(wire in calls[0].body, wire).toBe(false); + } + }); + + it('refuses a facet its spec schema refuses, before any network call', async () => { + const path = await projectWith({ ...BASE, businessFunctions: ['sales', 'finance', 'hr', 'legal'] }); + const calls = stubCloud(); + const out = captureOutput(); + + expect(await run([path])).toBe(1); + expect(calls).toEqual([]); + expect(out.join('\n')).toMatch(/`businessFunctions` is not valid: .*at most 3 business functions/); + }); + }); + + describe('the solutionType cross-check against the built artifact', () => { + it('refuses a template whose artifact permits no unmanaged install', async () => { + const path = await projectWith({ ...BASE, solutionType: 'template' }); + const calls = stubCloud(); + const out = captureOutput(); + + expect(await run([path])).toBe(1); + expect(calls).toEqual([]); + expect(out.join('\n')).toContain("permits only ['managed']"); + }); + + it('sends a template whose artifact permits an unmanaged install', async () => { + const path = await projectWith( + { ...BASE, solutionType: 'template' }, + { ...ARTIFACT, manifest: { ...ARTIFACT.manifest, installModes: ['managed', 'unmanaged'] } }, + ); + const calls = stubCloud(); + captureOutput(); + + expect(await run([path])).toBeUndefined(); + expect(calls[0].body.solution_type).toBe('template'); + }); + + it('refuses a connector whose artifact declares no connector', async () => { + const path = await projectWith({ ...BASE, solutionType: 'connector' }); + const calls = stubCloud(); + const out = captureOutput(); + + expect(await run([path])).toBe(1); + expect(calls).toEqual([]); + expect(out.join('\n')).toContain('declares no connector'); + }); + + it('sends a connector whose artifact declares one', async () => { + const path = await projectWith( + { ...BASE, solutionType: 'connector' }, + { ...ARTIFACT, connectors: [{ name: 'stripe', provider: 'openapi' }] }, + ); + const calls = stubCloud(); + captureOutput(); + + expect(await run([path])).toBeUndefined(); + expect(calls[0].body.solution_type).toBe('connector'); + }); + }); + + describe('readPublishTaxonomy', () => { + it('judges the artifact\'s install modes through the spec\'s resolver: an unreadable value counts as the default', () => { + const verdict = readPublishTaxonomy({ solutionType: 'template' }, { manifest: { installModes: 'unmanaged' } }); + expect(verdict.ok).toBe(false); + }); + }); +}); diff --git a/packages/cli/test/package-publish-uncarried-manifest-keys.test.ts b/packages/cli/test/package-publish-uncarried-manifest-keys.test.ts index 853b9fbc281..65405422ee5 100644 --- a/packages/cli/test/package-publish-uncarried-manifest-keys.test.ts +++ b/packages/cli/test/package-publish-uncarried-manifest-keys.test.ts @@ -58,9 +58,15 @@ function stubCloud(refusePackage?: { status: number; body: unknown }): Call[] { * schema gains lands in none of these and fails the partition test below. */ const SENT = [ - 'manifestId', 'displayName', 'description', 'category', 'tags', 'iconUrl', + 'manifestId', 'displayName', 'description', 'tags', 'iconUrl', 'homepageUrl', 'license', 'translations', 'readmePath', + 'businessFunctions', 'industries', 'solutionType', 'maturity', ] as const; +/** + * Retired keys the schema still declares as tombstones: publish refuses them, + * naming the replacement (`test/package-publish-taxonomy.test.ts`). + */ +const REFUSED = ['category'] as const; /** Scaffold-time extras: read by `create-objectstack` or docs, never a publish key. */ const SCAFFOLD_ONLY = ['name', 'specVersion', 'namespace', 'skills', 'scaffold'] as const; @@ -290,10 +296,10 @@ describe('os package publish — manifest keys', () => { }); }); -describe('every TemplateManifestSchema key is sent, warned about, or scaffold-only', () => { +describe('every TemplateManifestSchema key is sent, warned about, refused, or scaffold-only', () => { it('partitions the schema keys exactly', () => { const schemaKeys = Object.keys(TemplateManifestSchema.shape).sort(); - const classified = [...SENT, ...UNCARRIED_MANIFEST_KEYS, ...SCAFFOLD_ONLY].sort(); + const classified = [...SENT, ...UNCARRIED_MANIFEST_KEYS, ...REFUSED, ...SCAFFOLD_ONLY].sort(); expect(classified).toEqual(schemaKeys); // No key sits in two buckets. expect(new Set(classified).size).toBe(classified.length); diff --git a/packages/create-objectstack/src/rewrite-identity.test.ts b/packages/create-objectstack/src/rewrite-identity.test.ts index a272c6cb864..f7823072c91 100644 --- a/packages/create-objectstack/src/rewrite-identity.test.ts +++ b/packages/create-objectstack/src/rewrite-identity.test.ts @@ -47,7 +47,7 @@ describe('readTemplateNamespace', () => { $schema: 'https://schemas.objectstack.dev/template-manifest.json', name: 'todo', displayName: 'Todo', - category: 'productivity', + businessFunctions: ['collaboration'], skills: ['objectstack-platform'], }), ); diff --git a/packages/create-objectstack/src/rewrite-identity.ts b/packages/create-objectstack/src/rewrite-identity.ts index 8fb4bd565dd..61ba4d27363 100644 --- a/packages/create-objectstack/src/rewrite-identity.ts +++ b/packages/create-objectstack/src/rewrite-identity.ts @@ -24,7 +24,7 @@ // // - the BUNDLED `blank` template's manifest is app-shaped and HAS `namespace`; // - a REMOTE template's manifest is the template-REGISTRY document -// (`$schema: .../template-manifest.json` — name/displayName/category/skills/ +// (`$schema: .../template-manifest.json` — name/displayName/businessFunctions/skills/ // translations) and has NO `namespace` at all. The real namespace lives in // `objectstack.config.ts`. // diff --git a/packages/create-objectstack/src/templates/blank/objectstack.manifest.json b/packages/create-objectstack/src/templates/blank/objectstack.manifest.json index 0956082cfd8..40eda6ae5bb 100644 --- a/packages/create-objectstack/src/templates/blank/objectstack.manifest.json +++ b/packages/create-objectstack/src/templates/blank/objectstack.manifest.json @@ -5,7 +5,6 @@ "specVersion": "^18.0.0", "displayName": "Blank Starter", "description": "Minimal ObjectStack environment with a single object — a clean slate for building.", - "category": "starter", "isStarter": true, "skills": ["objectstack-platform", "objectstack-data"], "readmePath": "README.md", diff --git a/packages/qa/scenarios/src/export/exporter.ts b/packages/qa/scenarios/src/export/exporter.ts index f8118c2f6ba..ec12309c01c 100644 --- a/packages/qa/scenarios/src/export/exporter.ts +++ b/packages/qa/scenarios/src/export/exporter.ts @@ -129,7 +129,7 @@ function appReadme(scenario: LoadedScenario, entry: GalleryApp, judgement: PassJ '', `Generated by the ObjectStack scenario pipeline — do not edit by hand. Fix the card, the platform or the authoring skills instead; the next release rebuild corrects this directory.`, '', - `- Scenario: \`${c.id}\` · function \`${c.category}\` · namespace \`${entry.namespace}\``, + `- Scenario: \`${c.id}\` · business functions ${c.businessFunctions.map((f) => `\`${f}\``).join(', ')} · namespace \`${entry.namespace}\``, `- Framework: @objectstack/spec ${entry.framework.version} (runner checkout ${entry.framework.runner}) · run ${entry.run} on ${entry.runDate}`, `- 问数: ${entry.qa.questions} questions, ${entry.qa.turns} persona turns · ${entry.operations} write operations`, '', @@ -299,7 +299,7 @@ async function exportOne( code: card.code, namespace: scenarioNamespace(card), path: `apps/${id}`, - function: card.category, + function: card.businessFunctions[0]!, title: { en: card.title, zh: null }, summary: { ...(card.prompt.short.en ? { en: card.prompt.short.en } : {}), ...(card.prompt.short.zh ? { zh: card.prompt.short.zh } : {}) }, capabilities: [...card.capabilities].sort(), diff --git a/packages/qa/scenarios/src/export/manifest.ts b/packages/qa/scenarios/src/export/manifest.ts index dd332d1a58c..cb885adf278 100644 --- a/packages/qa/scenarios/src/export/manifest.ts +++ b/packages/qa/scenarios/src/export/manifest.ts @@ -14,7 +14,9 @@ import { z } from 'zod'; -import { CategorySchema, MachineNameSchema } from '../schema.js'; +import { MarketplaceBusinessFunctionSchema } from '@objectstack/spec/marketplace'; + +import { MachineNameSchema } from '../schema.js'; export const GALLERY_INDEX_SCHEMA_VERSION = 1; @@ -40,8 +42,12 @@ export const GalleryAppSchema = z.strictObject({ namespace: z.string().regex(/^demo[a-z]{2,8}$/), /** `apps/`. */ path: z.string().regex(/^apps\/[a-z][a-z0-9_]*$/), - /** The business function: the card's `category` (the marketplace taxonomy). */ - function: CategorySchema, + /** + * The business function: the card's PRIMARY one (`businessFunctions[0]`), + * from the marketplace taxonomy's business-function facet in + * `@objectstack/spec/marketplace` — the one a listing card shows. + */ + function: MarketplaceBusinessFunctionSchema, /** `en` is the card's `title`; the card schema has no Chinese title, so `zh` is `null` until it does. */ title: z.strictObject({ en: z.string().min(1), zh: z.string().min(1).nullable() }), /** The card's one-sentence requirement (`prompt.short`), in the languages it has. */ diff --git a/packages/qa/scenarios/src/schema.ts b/packages/qa/scenarios/src/schema.ts index 33af23206db..579cbe202e7 100644 --- a/packages/qa/scenarios/src/schema.ts +++ b/packages/qa/scenarios/src/schema.ts @@ -18,6 +18,7 @@ */ import { ErrorCode } from '@objectstack/spec/api'; +import { MarketplaceBusinessFunctionsSchema } from '@objectstack/spec/marketplace'; import { z } from 'zod'; /** A machine name: snake_case, the convention for data values in this repo. */ @@ -71,7 +72,7 @@ export const ExpectSchema = z.strictObject({ }); export type ScenarioExpect = z.infer; -// ── Layer 3: capabilities + category ──────────────────────────────────────── +// ── Layer 3: capabilities + business functions ──────────────────────────────────────── /** * `.`. The AREA half is a closed set read from the tree @@ -92,24 +93,15 @@ export const CapabilityTagSchema = z */ export const ROW_SCOPE_TAG = 'access-security.row-scope'; -/** The cloud marketplace taxonomy — a closed enum. */ -export const SCENARIO_CATEGORIES = [ - 'app', - 'crm', - 'sales', - 'marketing', - 'finance', - 'hr', - 'operations', - 'project', - 'productivity', - 'data', - 'integrations', - 'devtools', - 'utility', - 'other', -] as const; -export const CategorySchema = z.enum(SCENARIO_CATEGORIES); +/** + * A card's business functions — the marketplace taxonomy's business-function + * facet, read from `@objectstack/spec/marketplace` and never restated here: + * 1-3 distinct values, the primary one first. A card graduates into a + * marketplace template, so it is classified the way the template will be + * listed. (This used to be a hand copy of the cloud catalog's single-category + * list; the taxonomy now lives once, in the spec.) + */ +export const BusinessFunctionsSchema = MarketplaceBusinessFunctionsSchema; // ── Layer 4: run.seed / run.questions / run.personas ──────────────────────── @@ -460,7 +452,7 @@ export const ScenarioCardSchema = z.strictObject({ /** The fixed "today" relative windows are computed against (`YYYY-MM-DD`). */ asOf: z.iso.date().optional(), capabilities: z.array(CapabilityTagSchema).min(1), - category: CategorySchema, + businessFunctions: BusinessFunctionsSchema, run: z.strictObject({ seed: z.record(MachineNameSchema, z.array(SeedRowSchema).min(1)), questions: z.array(QuestionSchema).min(5), diff --git a/packages/qa/scenarios/test/build/narrow-score-graph.test.ts b/packages/qa/scenarios/test/build/narrow-score-graph.test.ts index db7646a07c0..62ba2601c7d 100644 --- a/packages/qa/scenarios/test/build/narrow-score-graph.test.ts +++ b/packages/qa/scenarios/test/build/narrow-score-graph.test.ts @@ -55,6 +55,7 @@ describe('the ./score entry import graph', () => { '@objectstack/core', '@objectstack/spec/api', '@objectstack/spec/data', + '@objectstack/spec/marketplace', '@objectstack/spec/meta-spelling', ]); }); diff --git a/packages/qa/scenarios/test/export/exporter.test.ts b/packages/qa/scenarios/test/export/exporter.test.ts index fa9747ac7c8..ade95ecf741 100644 --- a/packages/qa/scenarios/test/export/exporter.test.ts +++ b/packages/qa/scenarios/test/export/exporter.test.ts @@ -169,7 +169,7 @@ describe('exportGallery — one passing, generic app', () => { code: 'crm', namespace: 'democrm', path: `apps/${CARD_ID}`, - function: 'crm', + function: 'sales', title: { en: fx.scenario.card.title, zh: null }, summary: { ...(fx.scenario.card.prompt.short.en ? { en: fx.scenario.card.prompt.short.en } : {}), ...(fx.scenario.card.prompt.short.zh ? { zh: fx.scenario.card.prompt.short.zh } : {}) }, capabilities: [...fx.scenario.card.capabilities].sort(), @@ -441,7 +441,7 @@ describe('index.json — the schema', () => { code: 'crm', namespace: 'democrm', path: `apps/${id}`, - function: 'crm', + function: 'sales', title: { en: 'CRM', zh: null }, summary: { en: 'Build a CRM.' }, capabilities: [], diff --git a/packages/qa/scenarios/test/refusals.test.ts b/packages/qa/scenarios/test/refusals.test.ts index f6c4f2c5cc8..80a0751fb95 100644 --- a/packages/qa/scenarios/test/refusals.test.ts +++ b/packages/qa/scenarios/test/refusals.test.ts @@ -48,7 +48,10 @@ const op = (c: RawCard, id: string) => c.run.operations.find((x: { id: string }) const CASES: Array<[string, (c: RawCard) => void, string, RegExp]> = [ // ── the three classes the card names ── ['an unknown capability area, naming the closed set', (c) => (c.capabilities[0] = 'billing.invoices'), 'capabilities[0]', /unknown capability area `billing`.*records-forms/], - ['a category outside the closed enum', (c) => (c.category = 'retail'), 'category', /expected one of/], + ['a business function outside the spec facet', (c) => (c.businessFunctions = ['operations']), 'businessFunctions[0]', /expected one of/], + ['more business functions than the spec allows', (c) => (c.businessFunctions = ['sales', 'finance', 'hr', 'legal']), 'businessFunctions', /at most 3 business functions/], + ['a business function listed twice', (c) => (c.businessFunctions = ['sales', 'sales']), 'businessFunctions', /more than once/], + ['the retired single category', (c) => (c.category = 'crm'), 'category', /unknown key `category`/], ['a truth kind outside the enum', (c) => (c.run.questions[0].truth.kind = 'median'), 'run.questions[0].truth.kind', /discriminator/], // ── shape ── ['an unknown key, locating the key itself', (c) => (c.run.questions[0].truth.limit = 5), 'run.questions[0].truth.limit', /unknown key `limit`/], @@ -186,12 +189,12 @@ describe('scenario card refusals', () => { const first = join(dir, `${base.id}.json`); const second = join(dir, 'crm_twin.json'); writeFileSync(first, JSON.stringify(base)); - writeFileSync(second, JSON.stringify({ ...base, id: 'crm_twin', category: 'retail' })); + writeFileSync(second, JSON.stringify({ ...base, id: 'crm_twin', businessFunctions: ['retail'] })); const r = loadScenarios(dir, { areas }); expect(r.cards).toEqual([]); expect(r.refusals.map((x) => [x.file, x.path])).toEqual([ [first, 'code'], - [second, 'category'], + [second, 'businessFunctions[0]'], [second, 'code'], ]); }); diff --git a/packages/spec/api-surface/marketplace.json b/packages/spec/api-surface/marketplace.json index 1f6f5842223..9eede0d2492 100644 --- a/packages/spec/api-surface/marketplace.json +++ b/packages/spec/api-surface/marketplace.json @@ -11,10 +11,21 @@ "CreatePackageRequestSchema (const)", "CreatePackageVersionRequest (type)", "CreatePackageVersionRequestSchema (const)", + "DEFAULT_SOLUTION_TYPE (const)", + "LEGACY_CATEGORY_MAPPING (const)", + "LegacyCategoryMapping (type)", "ListingStatus (type)", "ListingStatusSchema (const)", - "MarketplaceCategory (type)", - "MarketplaceCategorySchema (const)", + "MAX_BUSINESS_FUNCTIONS (const)", + "MAX_INDUSTRIES (const)", + "MarketplaceBusinessFunction (type)", + "MarketplaceBusinessFunctionSchema (const)", + "MarketplaceBusinessFunctions (type)", + "MarketplaceBusinessFunctionsSchema (const)", + "MarketplaceIndustries (type)", + "MarketplaceIndustriesSchema (const)", + "MarketplaceIndustry (type)", + "MarketplaceIndustrySchema (const)", "MarketplaceInstallRequest (type)", "MarketplaceInstallRequestParsed (type)", "MarketplaceInstallRequestSchema (const)", @@ -29,9 +40,9 @@ "MarketplaceSearchResponse (type)", "MarketplaceSearchResponseParsed (type)", "MarketplaceSearchResponseSchema (const)", + "MarketplaceSolutionType (type)", + "MarketplaceSolutionTypeSchema (const)", "Package (type)", - "PackageCategory (type)", - "PackageCategorySchema (const)", "PackageDependency (type)", "PackageDependencyParsed (type)", "PackageDependencySchema (const)", @@ -41,6 +52,8 @@ "PackageManifest (type)", "PackageManifestParsed (type)", "PackageManifestSchema (const)", + "PackageMaturity (type)", + "PackageMaturitySchema (const)", "PackageParsed (type)", "PackagePublisher (type)", "PackagePublisherSchema (const)", @@ -69,14 +82,17 @@ "PublisherVerification (type)", "PublisherVerificationSchema (const)", "ResolvePackageL10nOptions (interface)", + "SolutionTypeEvidence (interface)", "TemplateManifest (type)", "TemplateManifestSchema (const)", "UpdatePackageRequest (type)", "UpdatePackageRequestSchema (const)", "UpdatePackageVersionRequest (type)", "UpdatePackageVersionRequestSchema (const)", + "explainSolutionTypeContradiction (function)", "resolvePackageL10n (function)", "resolvePackageL10nField (function)", - "resolveScreenshotCaptions (function)" + "resolveScreenshotCaptions (function)", + "retiredCategoryGuidance (function)" ] } diff --git a/packages/spec/authorable-defaults/marketplace.json b/packages/spec/authorable-defaults/marketplace.json index 1597c49e87d..231facdd472 100644 --- a/packages/spec/authorable-defaults/marketplace.json +++ b/packages/spec/authorable-defaults/marketplace.json @@ -3,15 +3,19 @@ "category": "marketplace", "defaults": [ "marketplace/ArtifactReference:format = \"tgz\"", + "marketplace/MarketplaceListing:maturity = \"stable\"", "marketplace/MarketplaceListing:packageType = \"app\"", "marketplace/MarketplaceListing:pricing = \"free\"", + "marketplace/MarketplaceListing:solutionType = \"app\"", "marketplace/MarketplaceListing:status = \"draft\"", "marketplace/MarketplaceSearchRequest:page = 1", "marketplace/MarketplaceSearchRequest:pageSize = 20", "marketplace/MarketplaceSearchRequest:sortBy = \"relevance\"", "marketplace/MarketplaceSearchRequest:sortDirection = \"desc\"", "marketplace/Package:isStarter = false", + "marketplace/Package:maturity = \"stable\"", "marketplace/Package:publisher = \"private\"", + "marketplace/Package:solutionType = \"app\"", "marketplace/Package:visibility = \"org\"", "marketplace/PackageManifest:dependencies = []", "marketplace/PackageManifest:metadataTypes = []", diff --git a/packages/spec/authorable-surface/marketplace.json b/packages/spec/authorable-surface/marketplace.json index 73f865f11b6..2e38837db86 100644 --- a/packages/spec/authorable-surface/marketplace.json +++ b/packages/spec/authorable-surface/marketplace.json @@ -12,18 +12,22 @@ "marketplace/ArtifactReference:size", "marketplace/ArtifactReference:uploadedAt", "marketplace/ArtifactReference:url", - "marketplace/CreatePackageRequest:category", + "marketplace/CreatePackageRequest:businessFunctions", + "marketplace/CreatePackageRequest:category [RETIRED]", "marketplace/CreatePackageRequest:createdBy", "marketplace/CreatePackageRequest:description", "marketplace/CreatePackageRequest:displayName", "marketplace/CreatePackageRequest:homepageUrl", "marketplace/CreatePackageRequest:iconUrl", + "marketplace/CreatePackageRequest:industries", "marketplace/CreatePackageRequest:isStarter", "marketplace/CreatePackageRequest:license", "marketplace/CreatePackageRequest:manifestId", + "marketplace/CreatePackageRequest:maturity", "marketplace/CreatePackageRequest:namespace", "marketplace/CreatePackageRequest:ownerOrgId", "marketplace/CreatePackageRequest:publisher", + "marketplace/CreatePackageRequest:solutionType", "marketplace/CreatePackageRequest:tags", "marketplace/CreatePackageRequest:translations", "marketplace/CreatePackageRequest:visibility", @@ -44,12 +48,15 @@ "marketplace/MarketplaceInstallResponse:packageId", "marketplace/MarketplaceInstallResponse:success", "marketplace/MarketplaceInstallResponse:version", - "marketplace/MarketplaceListing:category", + "marketplace/MarketplaceListing:businessFunctions", + "marketplace/MarketplaceListing:category [RETIRED]", "marketplace/MarketplaceListing:description", "marketplace/MarketplaceListing:documentationUrl", "marketplace/MarketplaceListing:iconUrl", "marketplace/MarketplaceListing:id", + "marketplace/MarketplaceListing:industries", "marketplace/MarketplaceListing:latestVersion", + "marketplace/MarketplaceListing:maturity", "marketplace/MarketplaceListing:minPlatformVersion", "marketplace/MarketplaceListing:name", "marketplace/MarketplaceListing:packageId", @@ -60,6 +67,7 @@ "marketplace/MarketplaceListing:publisherId", "marketplace/MarketplaceListing:repositoryUrl", "marketplace/MarketplaceListing:screenshots", + "marketplace/MarketplaceListing:solutionType", "marketplace/MarketplaceListing:stats", "marketplace/MarketplaceListing:status", "marketplace/MarketplaceListing:supportUrl", @@ -68,13 +76,17 @@ "marketplace/MarketplaceListing:translations", "marketplace/MarketplaceListing:updatedAt", "marketplace/MarketplaceListing:versions", - "marketplace/MarketplaceSearchRequest:category", + "marketplace/MarketplaceSearchRequest:businessFunctions", + "marketplace/MarketplaceSearchRequest:category [RETIRED]", + "marketplace/MarketplaceSearchRequest:industries", + "marketplace/MarketplaceSearchRequest:maturity", "marketplace/MarketplaceSearchRequest:page", "marketplace/MarketplaceSearchRequest:pageSize", "marketplace/MarketplaceSearchRequest:platformVersion", "marketplace/MarketplaceSearchRequest:pricing", "marketplace/MarketplaceSearchRequest:publisherVerification", "marketplace/MarketplaceSearchRequest:query", + "marketplace/MarketplaceSearchRequest:solutionTypes", "marketplace/MarketplaceSearchRequest:sortBy", "marketplace/MarketplaceSearchRequest:sortDirection", "marketplace/MarketplaceSearchRequest:tags", @@ -83,7 +95,8 @@ "marketplace/MarketplaceSearchResponse:page", "marketplace/MarketplaceSearchResponse:pageSize", "marketplace/MarketplaceSearchResponse:total", - "marketplace/Package:category", + "marketplace/Package:businessFunctions", + "marketplace/Package:category [RETIRED]", "marketplace/Package:createdAt", "marketplace/Package:createdBy", "marketplace/Package:description", @@ -91,13 +104,16 @@ "marketplace/Package:homepageUrl", "marketplace/Package:iconUrl", "marketplace/Package:id", + "marketplace/Package:industries", "marketplace/Package:isStarter", "marketplace/Package:license", "marketplace/Package:manifestId", + "marketplace/Package:maturity", "marketplace/Package:namespace", "marketplace/Package:ownerOrgId", "marketplace/Package:publisher", "marketplace/Package:readme", + "marketplace/Package:solutionType", "marketplace/Package:tags", "marketplace/Package:translations", "marketplace/Package:updatedAt", @@ -157,14 +173,17 @@ "marketplace/Publisher:type", "marketplace/Publisher:verification", "marketplace/Publisher:website", - "marketplace/TemplateManifest:category", + "marketplace/TemplateManifest:businessFunctions", + "marketplace/TemplateManifest:category [RETIRED]", "marketplace/TemplateManifest:description", "marketplace/TemplateManifest:displayName", "marketplace/TemplateManifest:homepageUrl", "marketplace/TemplateManifest:iconUrl", + "marketplace/TemplateManifest:industries", "marketplace/TemplateManifest:isStarter", "marketplace/TemplateManifest:license", "marketplace/TemplateManifest:manifestId", + "marketplace/TemplateManifest:maturity", "marketplace/TemplateManifest:name", "marketplace/TemplateManifest:namespace", "marketplace/TemplateManifest:preview", @@ -172,19 +191,24 @@ "marketplace/TemplateManifest:readmePath", "marketplace/TemplateManifest:scaffold", "marketplace/TemplateManifest:skills", + "marketplace/TemplateManifest:solutionType", "marketplace/TemplateManifest:specVersion", "marketplace/TemplateManifest:tags", "marketplace/TemplateManifest:translations", "marketplace/TemplateManifest:visibility", - "marketplace/UpdatePackageRequest:category", + "marketplace/UpdatePackageRequest:businessFunctions", + "marketplace/UpdatePackageRequest:category [RETIRED]", "marketplace/UpdatePackageRequest:description", "marketplace/UpdatePackageRequest:displayName", "marketplace/UpdatePackageRequest:homepageUrl", "marketplace/UpdatePackageRequest:iconUrl", + "marketplace/UpdatePackageRequest:industries", "marketplace/UpdatePackageRequest:isStarter", "marketplace/UpdatePackageRequest:license", + "marketplace/UpdatePackageRequest:maturity", "marketplace/UpdatePackageRequest:publisher", "marketplace/UpdatePackageRequest:readme", + "marketplace/UpdatePackageRequest:solutionType", "marketplace/UpdatePackageRequest:tags", "marketplace/UpdatePackageRequest:translations", "marketplace/UpdatePackageRequest:visibility", diff --git a/packages/spec/declaration-map/marketplace.json b/packages/spec/declaration-map/marketplace.json index d4fbf617fe5..9aeccf3089d 100644 --- a/packages/spec/declaration-map/marketplace.json +++ b/packages/spec/declaration-map/marketplace.json @@ -12,8 +12,14 @@ "CreatePackageVersionRequestSchema": "marketplace/CreatePackageVersionRequest", "ListingStatus": "marketplace/ListingStatus", "ListingStatusSchema": "marketplace/ListingStatus", - "MarketplaceCategory": "marketplace/MarketplaceCategory", - "MarketplaceCategorySchema": "marketplace/MarketplaceCategory", + "MarketplaceBusinessFunction": "marketplace/MarketplaceBusinessFunction", + "MarketplaceBusinessFunctionSchema": "marketplace/MarketplaceBusinessFunction", + "MarketplaceBusinessFunctions": "marketplace/MarketplaceBusinessFunctions", + "MarketplaceBusinessFunctionsSchema": "marketplace/MarketplaceBusinessFunctions", + "MarketplaceIndustries": "marketplace/MarketplaceIndustries", + "MarketplaceIndustriesSchema": "marketplace/MarketplaceIndustries", + "MarketplaceIndustry": "marketplace/MarketplaceIndustry", + "MarketplaceIndustrySchema": "marketplace/MarketplaceIndustry", "MarketplaceInstallRequest": "marketplace/MarketplaceInstallRequest", "MarketplaceInstallRequestSchema": "marketplace/MarketplaceInstallRequest", "MarketplaceInstallResponse": "marketplace/MarketplaceInstallResponse", @@ -24,15 +30,17 @@ "MarketplaceSearchRequestSchema": "marketplace/MarketplaceSearchRequest", "MarketplaceSearchResponse": "marketplace/MarketplaceSearchResponse", "MarketplaceSearchResponseSchema": "marketplace/MarketplaceSearchResponse", + "MarketplaceSolutionType": "marketplace/MarketplaceSolutionType", + "MarketplaceSolutionTypeSchema": "marketplace/MarketplaceSolutionType", "Package": "marketplace/Package", - "PackageCategory": "marketplace/PackageCategory", - "PackageCategorySchema": "marketplace/PackageCategory", "PackageDependency": "marketplace/PackageDependency", "PackageDependencySchema": "marketplace/PackageDependency", "PackageLocale": "marketplace/PackageLocale", "PackageLocaleSchema": "marketplace/PackageLocale", "PackageManifest": "marketplace/PackageManifest", "PackageManifestSchema": "marketplace/PackageManifest", + "PackageMaturity": "marketplace/PackageMaturity", + "PackageMaturitySchema": "marketplace/PackageMaturity", "PackagePublisher": "marketplace/PackagePublisher", "PackagePublisherSchema": "marketplace/PackagePublisher", "PackageSchema": "marketplace/Package", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 27a14925df8..5b6edf5ef2a 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 252, - "droppedRefinementSites": 1023, + "publishedSchemasWithDroppedRefinements": 260, + "droppedRefinementSites": 1037, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -1274,6 +1274,52 @@ "" ] }, + "marketplace/CreatePackageRequest": { + "sites": [ + "businessFunctions", + "industries" + ] + }, + "marketplace/MarketplaceBusinessFunctions": { + "sites": [ + "" + ] + }, + "marketplace/MarketplaceIndustries": { + "sites": [ + "" + ] + }, + "marketplace/MarketplaceListing": { + "sites": [ + "businessFunctions", + "industries" + ] + }, + "marketplace/MarketplaceSearchResponse": { + "sites": [ + "items.element.businessFunctions", + "items.element.industries" + ] + }, + "marketplace/Package": { + "sites": [ + "businessFunctions", + "industries" + ] + }, + "marketplace/TemplateManifest": { + "sites": [ + "businessFunctions", + "industries" + ] + }, + "marketplace/UpdatePackageRequest": { + "sites": [ + "businessFunctions", + "industries" + ] + }, "security/CriteriaSharingRule": { "sites": [ "sharedWith" diff --git a/packages/spec/export-origins/marketplace.json b/packages/spec/export-origins/marketplace.json index f57afc78086..75d23f82373 100644 --- a/packages/spec/export-origins/marketplace.json +++ b/packages/spec/export-origins/marketplace.json @@ -11,10 +11,21 @@ "CreatePackageRequestSchema": "src/marketplace/package.zod.ts#CreatePackageRequestSchema (const)", "CreatePackageVersionRequest": "src/marketplace/package-version.zod.ts#CreatePackageVersionRequest (type)", "CreatePackageVersionRequestSchema": "src/marketplace/package-version.zod.ts#CreatePackageVersionRequestSchema (const)", + "DEFAULT_SOLUTION_TYPE": "src/marketplace/taxonomy.zod.ts#DEFAULT_SOLUTION_TYPE (const)", + "LEGACY_CATEGORY_MAPPING": "src/marketplace/taxonomy.zod.ts#LEGACY_CATEGORY_MAPPING (const)", + "LegacyCategoryMapping": "src/marketplace/taxonomy.zod.ts#LegacyCategoryMapping (type)", "ListingStatus": "src/marketplace/marketplace.zod.ts#ListingStatus (type)", "ListingStatusSchema": "src/marketplace/marketplace.zod.ts#ListingStatusSchema (const)", - "MarketplaceCategory": "src/marketplace/marketplace.zod.ts#MarketplaceCategory (type)", - "MarketplaceCategorySchema": "src/marketplace/marketplace.zod.ts#MarketplaceCategorySchema (const)", + "MAX_BUSINESS_FUNCTIONS": "src/marketplace/taxonomy.zod.ts#MAX_BUSINESS_FUNCTIONS (const)", + "MAX_INDUSTRIES": "src/marketplace/taxonomy.zod.ts#MAX_INDUSTRIES (const)", + "MarketplaceBusinessFunction": "src/marketplace/taxonomy.zod.ts#MarketplaceBusinessFunction (type)", + "MarketplaceBusinessFunctionSchema": "src/marketplace/taxonomy.zod.ts#MarketplaceBusinessFunctionSchema (const)", + "MarketplaceBusinessFunctions": "src/marketplace/taxonomy.zod.ts#MarketplaceBusinessFunctions (type)", + "MarketplaceBusinessFunctionsSchema": "src/marketplace/taxonomy.zod.ts#MarketplaceBusinessFunctionsSchema (const)", + "MarketplaceIndustries": "src/marketplace/taxonomy.zod.ts#MarketplaceIndustries (type)", + "MarketplaceIndustriesSchema": "src/marketplace/taxonomy.zod.ts#MarketplaceIndustriesSchema (const)", + "MarketplaceIndustry": "src/marketplace/taxonomy.zod.ts#MarketplaceIndustry (type)", + "MarketplaceIndustrySchema": "src/marketplace/taxonomy.zod.ts#MarketplaceIndustrySchema (const)", "MarketplaceInstallRequest": "src/marketplace/marketplace.zod.ts#MarketplaceInstallRequest (type)", "MarketplaceInstallRequestParsed": "src/marketplace/marketplace.zod.ts#MarketplaceInstallRequestParsed (type)", "MarketplaceInstallRequestSchema": "src/marketplace/marketplace.zod.ts#MarketplaceInstallRequestSchema (const)", @@ -29,9 +40,9 @@ "MarketplaceSearchResponse": "src/marketplace/marketplace.zod.ts#MarketplaceSearchResponse (type)", "MarketplaceSearchResponseParsed": "src/marketplace/marketplace.zod.ts#MarketplaceSearchResponseParsed (type)", "MarketplaceSearchResponseSchema": "src/marketplace/marketplace.zod.ts#MarketplaceSearchResponseSchema (const)", + "MarketplaceSolutionType": "src/marketplace/taxonomy.zod.ts#MarketplaceSolutionType (type)", + "MarketplaceSolutionTypeSchema": "src/marketplace/taxonomy.zod.ts#MarketplaceSolutionTypeSchema (const)", "Package": "src/marketplace/package.zod.ts#Package (type)", - "PackageCategory": "src/marketplace/package.zod.ts#PackageCategory (type)", - "PackageCategorySchema": "src/marketplace/package.zod.ts#PackageCategorySchema (const)", "PackageDependency": "src/marketplace/package-version.zod.ts#PackageDependency (type)", "PackageDependencyParsed": "src/marketplace/package-version.zod.ts#PackageDependencyParsed (type)", "PackageDependencySchema": "src/marketplace/package-version.zod.ts#PackageDependencySchema (const)", @@ -41,6 +52,8 @@ "PackageManifest": "src/marketplace/package-version.zod.ts#PackageManifest (type)", "PackageManifestParsed": "src/marketplace/package-version.zod.ts#PackageManifestParsed (type)", "PackageManifestSchema": "src/marketplace/package-version.zod.ts#PackageManifestSchema (const)", + "PackageMaturity": "src/marketplace/taxonomy.zod.ts#PackageMaturity (type)", + "PackageMaturitySchema": "src/marketplace/taxonomy.zod.ts#PackageMaturitySchema (const)", "PackageParsed": "src/marketplace/package.zod.ts#PackageParsed (type)", "PackagePublisher": "src/marketplace/package.zod.ts#PackagePublisher (type)", "PackagePublisherSchema": "src/marketplace/package.zod.ts#PackagePublisherSchema (const)", @@ -69,14 +82,17 @@ "PublisherVerification": "src/marketplace/marketplace.zod.ts#PublisherVerification (type)", "PublisherVerificationSchema": "src/marketplace/marketplace.zod.ts#PublisherVerificationSchema (const)", "ResolvePackageL10nOptions": "src/marketplace/package-l10n.ts#ResolvePackageL10nOptions (interface)", + "SolutionTypeEvidence": "src/marketplace/taxonomy.zod.ts#SolutionTypeEvidence (interface)", "TemplateManifest": "src/marketplace/template-manifest.zod.ts#TemplateManifest (type)", "TemplateManifestSchema": "src/marketplace/template-manifest.zod.ts#TemplateManifestSchema (const)", "UpdatePackageRequest": "src/marketplace/package.zod.ts#UpdatePackageRequest (type)", "UpdatePackageRequestSchema": "src/marketplace/package.zod.ts#UpdatePackageRequestSchema (const)", "UpdatePackageVersionRequest": "src/marketplace/package-version.zod.ts#UpdatePackageVersionRequest (type)", "UpdatePackageVersionRequestSchema": "src/marketplace/package-version.zod.ts#UpdatePackageVersionRequestSchema (const)", + "explainSolutionTypeContradiction": "src/marketplace/taxonomy.zod.ts#explainSolutionTypeContradiction (function)", "resolvePackageL10n": "src/marketplace/package-l10n.ts#resolvePackageL10n (function)", "resolvePackageL10nField": "src/marketplace/package-l10n.ts#resolvePackageL10nField (function)", - "resolveScreenshotCaptions": "src/marketplace/package-l10n.ts#resolveScreenshotCaptions (function)" + "resolveScreenshotCaptions": "src/marketplace/package-l10n.ts#resolveScreenshotCaptions (function)", + "retiredCategoryGuidance": "src/marketplace/taxonomy.zod.ts#retiredCategoryGuidance (function)" } } diff --git a/packages/spec/json-schema.manifest/marketplace.json b/packages/spec/json-schema.manifest/marketplace.json index 73c1c27f0be..5221c8e79a1 100644 --- a/packages/spec/json-schema.manifest/marketplace.json +++ b/packages/spec/json-schema.manifest/marketplace.json @@ -7,17 +7,21 @@ "marketplace/CreatePackageRequest", "marketplace/CreatePackageVersionRequest", "marketplace/ListingStatus", - "marketplace/MarketplaceCategory", + "marketplace/MarketplaceBusinessFunction", + "marketplace/MarketplaceBusinessFunctions", + "marketplace/MarketplaceIndustries", + "marketplace/MarketplaceIndustry", "marketplace/MarketplaceInstallRequest", "marketplace/MarketplaceInstallResponse", "marketplace/MarketplaceListing", "marketplace/MarketplaceSearchRequest", "marketplace/MarketplaceSearchResponse", + "marketplace/MarketplaceSolutionType", "marketplace/Package", - "marketplace/PackageCategory", "marketplace/PackageDependency", "marketplace/PackageLocale", "marketplace/PackageManifest", + "marketplace/PackageMaturity", "marketplace/PackagePublisher", "marketplace/PackageSubmission", "marketplace/PackageTranslation", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index c4796cd7692..c21d56aa663 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -893,7 +893,7 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type RecordDetailView had been gating the History tab on it the whole time (#2707). 4. Add the type to `GOVERNED`; confirm the gate is green. -## Current state — 43 governed types (complete registry coverage) +## Current state — 44 governed types (complete registry coverage) > **This heading is now checked** (#7257). `check:liveness` reconciles the table > against `GOVERNED` in both directions — a governed type with no row fails, a row @@ -1026,6 +1026,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by commit 2306a765c and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That kept `dimensions.granularities` (read only by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead) `dead` until **#20282**'s second stage (2026-09-29) read both on the query doors off whichever cube answers the name: `analytics-service#withDeclaredMeasureFormats` describes each measure column's `fields[].format`, and `#withDeclaredGranularityDefaults` buckets a grouped time dimension at the default `dataset-executor#declaredDefaultGranularity` reads — the one reading (a single-entry list) the dataset path's `granularityOf` now shares. The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 3 `dead` are the `refreshKey` tombstone and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity — RETIRED by #20300 (ADR-0049 enforce-or-remove) as `retiredKey()` tombstones on the member `strictObject`s, so those two rows STAY `dead` (the tombstone keeps the key in the walked shape) and the count does not move. It was 6 until **#20282**'s third stage (2026-09-29) published the three `description`s on discovery: `analytics-service#getMeta` copies each onto its `CubeMeta` entry, the read point the `title`/`label` rows already cite (display-shaped, the #7131 split). It was 7 until #20637 RETIRED `refreshKey` whole (ADR-0049 enforce-or-remove, maintainer letter C): the caching block's `every` and `sql` were two drilled `dead` rows — no refresh scheduler, pre-aggregation or analytics result cache exists anywhere, re-measured 2026-09-29 — and the `retiredKey()` tombstone that replaced the block is ONE leaf row, because the gate refuses `children` on a property that is no longer a container (the connector `health` precedent). **#20282** flips the tenth, the visibility flag `public`, `dead` → `live` 2026-09-27: seeded as a knob that was never wired (three internal mints wrote `false`, nothing read it), it is now read by `service-analytics`' `cube-visibility.ts#isCubePublic` — `getMeta` omits a hidden cube and `query()` / `generateSql()` refuse it — in the same change that moved its default from `false` to the Cube.dev `true`, since enforcing the old default would have hidden every authored cube. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **The end-to-end measurement is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | | queue | seeded 2026-10-26 with the type itself (#25769, ADR-0142 phase 2): registered governed, never a `PENDING_GOVERNANCE` debt. A declared ownership queue is never copied into a row (ADR-0131 D6, regime C): every consumer reads the registry item through the one reader they share, plugin-sharing's `OwnershipQueueService` (`resolveQueue`, `expandMembers`, `isMember`, `queuesOf`), and each row's producer is `registerMetadataCollections` loading the stack's `queues`. All rows `live`: `name` is the one reference the `queue` column, an assignment target, a queue approver and a sharing recipient carry, and the namespace an authored `sys_queue` row may not take; `members.type` is a one-member literal (`position`) that `expandSpec` still dispatches on beside the authored member kinds; `label` is served as the resolved queue's label; `label`/`description` are shown in the Studio metadata list's default columns (the `webhook` precedent). An organization's authored queues are `sys_queue`/`sys_queue_member` rows, governed by their object definitions, not here. | | assignment_rule | seeded 2026-10-26 with the type itself (#25769, ADR-0142 D5): registered governed. Declared only — organization-authored rules are deferred (ADR-0142 merge question 11) — and read on every insert by the engine's `AssignmentRuleEvaluator`, after every `beforeInsert` hook, for a row whose `owner_id` and `queue` are both still empty. All rows `live`, and `entries.assignTo` is drilled (`type`, `value`) rather than inherited. `name` and `entries.name` key the round-robin cursor (`sys_assignment_cursor`, per rule, entry and organization), so a rename restarts a rotation; `object` gates evaluation to objects carrying the platform `queue` column; `entries.load` is the `least_loaded` count predicate, required with that strategy and refused otherwise. `kernel:ready` refuses a stack that declares rules or queues with no `ownership-queue` service registered, so no row is inert by a missing service. | +| template_manifest | seeded 2026-11-06 with the marketplace taxonomy, through `SPEC_ONLY_SCHEMAS` (the sixth category: a package-listing file surface — `objectstack.manifest.json`, read by `os package publish` and create-objectstack, never by a boot). The facet keys `businessFunctions` / `industries` / `maturity` are `planned`: the CLI judges and forwards them, and their consumers are the cloud catalog (objectstack-ai/cloud#3661, which drops them until it lands) and the console marketplace card. `solutionType` is `live` through the publish cross-check. `category` is the tombstone. The other `dead` rows are what entering the denominator measured (`name`, `specVersion`, `skills`, `scaffold`, and `visibility` / `publisher` / `isStarter`, which publish names in a warning and does not send) — recorded, not retired. | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); a `dead` row warns its authors at compile time by its verdict alone, with no `authorWarn` diff --git a/packages/spec/liveness/state-counts/template_manifest.md b/packages/spec/liveness/state-counts/template_manifest.md new file mode 100644 index 00000000000..2649b084ecb --- /dev/null +++ b/packages/spec/liveness/state-counts/template_manifest.md @@ -0,0 +1,15 @@ + + + +# `template_manifest` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `template_manifest` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `template_manifest` | 15 | 0 | 0 | 9 | 5 | 29 | diff --git a/packages/spec/liveness/template_manifest.json b/packages/spec/liveness/template_manifest.json new file mode 100644 index 00000000000..42f77716b48 --- /dev/null +++ b/packages/spec/liveness/template_manifest.json @@ -0,0 +1,213 @@ +{ + "type": "template_manifest", + "_note": "TemplateManifestSchema (packages/spec/src/marketplace/template-manifest.zod.ts) — `objectstack.manifest.json`, the package-listing file an author writes beside objectstack.config.ts. Read by `os package publish` (packages/cli/src/commands/package/publish.ts) and by create-objectstack at scaffold; parsed for every shipped file by `pnpm --filter @objectstack/spec check:template-manifests`; never loaded by a runtime boot. GOVERNED VIA THE GATE'S SPEC-ONLY OVERRIDE (SPEC_ONLY_SCHEMAS), seeded 2026-11-06 with the marketplace taxonomy, whose facet keys (`businessFunctions`, `industries`, `solutionType`, `maturity`) have their consumers in the cloud catalog (objectstack-ai/cloud#3661) and the console — a row is where those consumers are named. MEASUREMENT: every row below was measured for this seeding against objectstack origin/main 7d9682ffd7 plus the taxonomy change, and the cloud leg against cloud origin/main 8f574b9 (its publish route reads `tags` and the category it retires; it drops every facet key today). The `dead` rows other than `category` are what entering the denominator found, not part of the taxonomy: they are recorded here, not retired.", + "props": { + "manifestId": { + "status": "live", + "evidence": "packages/cli/src/commands/package/publish.ts#PackagePublish (`--manifest-id ?? m.manifestId ?? deriveManifestId(...)` — the package id the publish request is addressed by)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "Optional on this file by design (the derived `local.` covers a tree that declares none); a declared value wins over the derived one." + }, + "displayName": { + "status": "live", + "evidence": "packages/cli/src/commands/package/publish.ts#PackagePublish (`flags['display-name'] ?? m.displayName ?? deriveDisplayName(...)` → `display_name` on the package request)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo" + }, + "description": { + "status": "live", + "evidence": "packages/cli/src/commands/package/publish.ts#PackagePublish (`flags.description ?? m.description` → `description` on the package request)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "create-objectstack drops the template's own description at scaffold, so a scaffolded project carries none until its author writes one." + }, + "visibility": { + "status": "dead", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "Read only to be named in a warning: `uncarriedManifestWarnings` (packages/cli/src/commands/package/publish.ts) tells the author that `os package publish` does not read `visibility` from this file and that `--visibility` carries it. The value an author writes here changes nothing. Recorded on the taxonomy enrolment (the first time this file was in the denominator); not retired here." + }, + "category": { + "status": "dead", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "REMOVED 2026-11-06 — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error), refused by name by `os package publish` (`retiredCategoryRefusal`) and by `check:template-manifests` for every shipped file. No D2 conversion: this file is not a stack collection member (D3 `marketplace-category-retired`). The entry stays because retiredKey keeps the key in the walked shape; declare `businessFunctions`, `industries` and `solutionType` instead." + }, + "businessFunctions": { + "status": "planned", + "evidence": "packages/cli/src/commands/package/publish.ts#readPublishTaxonomy (validated against `CreatePackageRequestSchema.shape.businessFunctions` and sent as `business_functions`)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "1-3 business functions, the primary first; required to submit to the marketplace (the cloud submit policy, cloud#3661). CONSUMERS (named per the marketplace-taxonomy ruling): the cloud catalog stores and filters it — objectstack-ai/cloud#3661, not landed at cloud origin/main 8f574b9, whose publish route picks named body keys and drops this one today — and the console marketplace (`ui/`), whose facet filters, badges and labels are the console card the repo:cloud#3 seat files from the taxonomy PR. IN THIS REPO: `os package publish` judges the value with the spec's own schema and forwards it under its wire name (`readPublishTaxonomy`), so a malformed value is refused before any network call — a gate, not a consumer. Flip to `live` with a cloud-attributed pointer when cloud#3661 lands." + }, + "industries": { + "status": "planned", + "evidence": "packages/cli/src/commands/package/publish.ts#readPublishTaxonomy (validated and sent as `industries`)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "Up to 3; absent means cross-industry, and the catalog hides this facet while no listing carries it. CONSUMERS (named per the marketplace-taxonomy ruling): the cloud catalog stores and filters it — objectstack-ai/cloud#3661, not landed at cloud origin/main 8f574b9, whose publish route picks named body keys and drops this one today — and the console marketplace (`ui/`), whose facet filters, badges and labels are the console card the repo:cloud#3 seat files from the taxonomy PR. IN THIS REPO: `os package publish` judges the value with the spec's own schema and forwards it under its wire name (`readPublishTaxonomy`), so a malformed value is refused before any network call — a gate, not a consumer. Flip to `live` with a cloud-attributed pointer when cloud#3661 lands." + }, + "maturity": { + "status": "planned", + "evidence": "packages/cli/src/commands/package/publish.ts#readPublishTaxonomy (validated and sent as `maturity`)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "`beta` | `stable`, a flag beside the facets; the console shows a Beta badge on the card and the detail page. CONSUMERS (named per the marketplace-taxonomy ruling): the cloud catalog stores it and serves it as a search filter — objectstack-ai/cloud#3661, not landed at cloud origin/main 8f574b9, whose publish route picks named body keys and drops this one today — and the console marketplace (`ui/`), whose facet filters, badges and labels are the console card the repo:cloud#3 seat files from the taxonomy PR. IN THIS REPO: `os package publish` judges the value with the spec's own schema and forwards it under its wire name (`readPublishTaxonomy`), so a malformed value is refused before any network call — a gate, not a consumer. Flip to `live` with a cloud-attributed pointer when cloud#3661 lands." + }, + "solutionType": { + "status": "live", + "evidence": "packages/cli/src/commands/package/publish.ts#readPublishTaxonomy (a declared `template` or `connector` is checked against the built artifact by `explainSolutionTypeContradiction` — a `template` whose artifact permits no `unmanaged` install, or a `connector` that declares no connector, is refused before any network call — then sent as `solution_type`)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "Live in this repo through the publish cross-check: authoring the key changes what publish accepts. Its catalog half — storing it, filtering by it, the `my_apps` view — is objectstack-ai/cloud#3661 (not landed at cloud 8f574b9, which drops the key today), and its console half (a type tab or segment) is the console card the repo:cloud#3 seat files." + }, + "tags": { + "status": "live", + "evidence": "packages/cli/src/commands/package/publish.ts#PackagePublish (`if (m.tags !== undefined) pkgBody.tags = m.tags` — sent as written; the hosted route owns the bounds)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "Search only: tags generate no filter. The cloud catalog validates and stores them (cloud @8f574b9: packages/service-cloud/src/routes/package-publish.ts#readPublishTags)." + }, + "iconUrl": { + "status": "live", + "evidence": "packages/cli/src/commands/package/publish.ts#PackagePublish (`flags['icon-url'] ?? m.iconUrl` → `icon_url`)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo" + }, + "homepageUrl": { + "status": "live", + "evidence": "packages/cli/src/commands/package/publish.ts#PackagePublish (`flags['homepage-url'] ?? m.homepageUrl` → `homepage_url`)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo" + }, + "license": { + "status": "live", + "evidence": "packages/cli/src/commands/package/publish.ts#PackagePublish (`flags.license ?? m.license` → `license`)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo" + }, + "publisher": { + "status": "dead", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "Decided by the platform: `os package publish` names it in a warning (`uncarriedManifestWarnings`, PLATFORM_DECIDED_MANIFEST_KEYS) and never sends it — the hosted control plane assigns the trust tier from the caller's credentials. An author's value has no effect. Recorded on the taxonomy enrolment; not retired here." + }, + "isStarter": { + "status": "dead", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "Decided by the platform: `os package publish` names it in a warning and never sends it; the hosted control plane honours a starter flag only on a first-party service-mode seed (cloud @8f574b9 package-publish.ts, `isFirstPartySeed`). The bundled blank template still ships `isStarter: true`, which is why the warning does not fail the publish. Recorded on the taxonomy enrolment; not retired here." + }, + "translations": { + "status": "live", + "evidence": "packages/cli/src/commands/package/publish.ts#PackagePublish (the per-locale `displayName` / `description` / `tagline` / `readme` entries, a `readme` path resolved against the manifest directory, sent as `translations`)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "children": { + "displayName": { + "status": "live", + "evidence": "packages/cli/src/commands/package/publish.ts#PackagePublish (the per-locale loop copies `displayName` into the `translations` it sends)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo" + }, + "description": { + "status": "live", + "evidence": "packages/cli/src/commands/package/publish.ts#PackagePublish (the per-locale loop copies `description` into the `translations` it sends)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo" + }, + "readme": { + "status": "live", + "evidence": "packages/cli/src/commands/package/publish.ts#PackagePublish (the per-locale loop copies `readme` into the `translations` it sends; a value naming a file is read relative to the manifest)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo" + }, + "tagline": { + "status": "live", + "evidence": "packages/cli/src/commands/package/publish.ts#PackagePublish (the per-locale loop copies `tagline` into the `translations` it sends)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo" + }, + "screenshotCaptions": { + "status": "live", + "evidence": "packages/cli/src/commands/package/publish.ts#PackagePublish (the per-locale loop copies `screenshotCaptions` into the `translations` it sends)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo" + } + } + }, + "name": { + "status": "dead", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "Written, never read: create-objectstack rewrites it to the project slug at scaffold (packages/create-objectstack/src/index.ts), and neither `os package publish` (which derives the package id from `manifestId` or the artifact) nor any other reader consults the file's value. Measured on the taxonomy enrolment; not retired here." + }, + "specVersion": { + "status": "dead", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "No reader of the file's value. The protocol-compatibility advisory reads the artifact manifest's `engines.protocol` (packages/cli/src/utils/protocol-version-gap.ts), not this key. Measured on the taxonomy enrolment; not retired here." + }, + "namespace": { + "status": "live", + "evidence": "packages/create-objectstack/src/rewrite-identity.ts#readTemplateNamespace (the fallback source for the template's original namespace when the tree carries no objectstack.config.ts to read it from)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "Scaffold-only; the publish namespace is read off the compiled artifact (ADR-0048 addendum §A.2)." + }, + "skills": { + "status": "dead", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "No reader: described as 'for docs / picker', and neither create-objectstack's template picker (template-registry.ts) nor any docs build reads it. Measured on the taxonomy enrolment; not retired here." + }, + "preview": { + "status": "planned", + "evidence": "packages/cli/src/commands/package/publish.ts#uncarriedManifestWarnings (named as a key the hosted catalog does not carry yet, with the tracking issue)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "Screenshots and a demo URL; publish has no upload path for them yet (https://github.com/objectstack-ai/objectstack/issues/27348).", + "children": { + "screenshots": { + "status": "planned", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "Not carried by the hosted catalog yet; `os package publish` names the whole `preview` block in a warning (see the parent row)." + }, + "demoUrl": { + "status": "planned", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "Not carried by the hosted catalog yet; `os package publish` names the whole `preview` block in a warning (see the parent row)." + } + } + }, + "scaffold": { + "status": "dead", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "No reader: create-objectstack prompts for nothing from `scaffold.variables` and runs no `scaffold.postInstall` (packages/create-objectstack/src/index.ts names neither). Measured on the taxonomy enrolment; not retired here.", + "children": { + "variables": { + "status": "dead", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "No reader; see the parent row." + }, + "postInstall": { + "status": "dead", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "note": "No reader; see the parent row." + } + } + }, + "readmePath": { + "status": "live", + "evidence": "packages/cli/src/commands/package/publish.ts#PackagePublish (read relative to the manifest when neither --readme nor --readme-file is given, sent as `readme`)", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo" + } + } +} diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index 0929f06ab30..b899738c8d9 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (219 schemas) +## 3. Schema Inventory by Domain (220 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 @@ -94,7 +94,7 @@ here is verifiable from the installed package. | shared | 16 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Conditional Formatting, Value Domain, Epoch Instant (EpochMs), Duration (DurationMs / DurationSeconds) | | ai | 12 | Agent, Build Progress, Conversation, Knowledge Source/Document, Model Registry, MCP, Skill, Tool | | identity | 5 | Identity, Organization, Position, SCIM, Eval User | -| marketplace | 4 | Marketplace, Package, Package Version, Template Manifest | +| marketplace | 5 | Marketplace, Package, Package Version, Template Manifest, Taxonomy | | security | 5 | Permission, RLS, Sharing, Queue, Explain | | studio | 3 | Flow Builder, Object Designer, Studio Plugin | | integration | 1 | Connector | diff --git a/packages/spec/scripts/lib/renamed-defs.ts b/packages/spec/scripts/lib/renamed-defs.ts index 8d0dc27c218..0ce52a68b96 100644 --- a/packages/spec/scripts/lib/renamed-defs.ts +++ b/packages/spec/scripts/lib/renamed-defs.ts @@ -199,14 +199,17 @@ export const RENAMED_DEFS: Readonly> = { 'cloud/CreatePackageRequest': 'marketplace/CreatePackageRequest', 'cloud/CreatePackageVersionRequest': 'marketplace/CreatePackageVersionRequest', 'cloud/ListingStatus': 'marketplace/ListingStatus', - 'cloud/MarketplaceCategory': 'marketplace/MarketplaceCategory', + // ─── RETIRED, do not re-add: `cloud/MarketplaceCategory` → `marketplace/MarketplaceCategory` + // and `cloud/PackageCategory` → `marketplace/PackageCategory` (0 keys carried — + // an enum def and a string def). Both targets left whole with the marketplace + // `category` keys (the D3 `marketplace-category-retired`, `RETIRED_DEFS_BY_MAJOR[18]`, + // the deliberate manifest deletion); the taxonomy facets replace them. 'cloud/MarketplaceInstallRequest': 'marketplace/MarketplaceInstallRequest', 'cloud/MarketplaceInstallResponse': 'marketplace/MarketplaceInstallResponse', 'cloud/MarketplaceListing': 'marketplace/MarketplaceListing', 'cloud/MarketplaceSearchRequest': 'marketplace/MarketplaceSearchRequest', 'cloud/MarketplaceSearchResponse': 'marketplace/MarketplaceSearchResponse', 'cloud/Package': 'marketplace/Package', - 'cloud/PackageCategory': 'marketplace/PackageCategory', 'cloud/PackageDependency': 'marketplace/PackageDependency', 'cloud/PackageLocale': 'marketplace/PackageLocale', 'cloud/PackageManifest': 'marketplace/PackageManifest', diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index c337d54d9d9..c582fd7f1bf 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -189,6 +189,7 @@ import { RouteGenerationConfigSchema, } from '../../src/api/rest-server.zod'; import { SubscriptionSchema } from '../../src/api/realtime.zod'; +import { TemplateManifestSchema } from '../../src/marketplace/template-manifest.zod'; import { BOUND_PROOF_PATHS, HIGH_RISK_CLASSES, @@ -278,7 +279,7 @@ const ledgerRoot = ledgerRootArg // Governed metadata types, rolled out highest-frequency / highest-risk first. // (`query` is not a metadata type — see SPEC_ONLY_SCHEMAS below.) -const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource', 'app', 'book', 'doc', 'email_template', 'job', 'mapping', 'picklist', 'seed', 'translation', 'validation', 'api', 'capability', 'qa', 'manifest', 'crud_endpoints', 'metadata_endpoints', 'batch_endpoints', 'route_generation', 'rest_api', 'realtime_subscription', 'sharing_rule', 'connector', 'analytics_cube', 'queue', 'assignment_rule']; +const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource', 'app', 'book', 'doc', 'email_template', 'job', 'mapping', 'picklist', 'seed', 'translation', 'validation', 'api', 'capability', 'qa', 'manifest', 'crud_endpoints', 'metadata_endpoints', 'batch_endpoints', 'route_generation', 'rest_api', 'realtime_subscription', 'sharing_rule', 'connector', 'analytics_cube', 'queue', 'assignment_rule', 'template_manifest']; // Authorable metadata types that are NOT yet governed — the coverage ratchet. // @@ -478,6 +479,17 @@ const PENDING_GOVERNANCE: Record = {}; // and this override records what has been measured. There is no registry to // fold this one back onto either — like `query`, `qa` and `manifest`, the // override IS its governance. +// +// `template_manifest` is the SIXTH category: a PACKAGE-LISTING file surface. +// `TemplateManifestSchema` (src/marketplace/template-manifest.zod.ts) is the +// schema of `objectstack.manifest.json`, the file a package author writes to +// describe how the package is listed — read by `os package publish` and by +// `create-objectstack`, parsed for every shipped file by +// `check:template-manifests`, and never loaded by a runtime boot. It entered +// with the marketplace taxonomy: the facet keys it gained have their consumers +// outside this repository (the cloud catalog) and in the console, and a row is +// where those consumers are named. Like `qa` and `manifest`, there is no +// registry to fold it back onto — the override IS its governance. const SPEC_ONLY_SCHEMAS: Record = { webhook: WebhookSchema, query: QuerySchema, @@ -490,6 +502,7 @@ const SPEC_ONLY_SCHEMAS: Record = { route_generation: RouteGenerationConfigSchema, rest_api: RestApiConfigSchema, realtime_subscription: SubscriptionSchema, + template_manifest: TemplateManifestSchema, }; // ADR-0010 provenance/lock overlay fields — system-stamped, on every type; auto-live. diff --git a/packages/spec/scripts/liveness/tombstoned-row-status.test.ts b/packages/spec/scripts/liveness/tombstoned-row-status.test.ts index 51d9b0eb83c..dce82eeb7c9 100644 --- a/packages/spec/scripts/liveness/tombstoned-row-status.test.ts +++ b/packages/spec/scripts/liveness/tombstoned-row-status.test.ts @@ -63,6 +63,7 @@ import { RouteGenerationConfigSchema, } from '../../src/api/rest-server.zod'; import { SubscriptionSchema } from '../../src/api/realtime.zod'; +import { TemplateManifestSchema } from '../../src/marketplace/template-manifest.zod'; const here = dirname(fileURLToPath(import.meta.url)); const specRoot = resolve(here, '../..'); @@ -81,6 +82,7 @@ const SPEC_ONLY_SCHEMAS: Record = { route_generation: RouteGenerationConfigSchema, rest_api: RestApiConfigSchema, realtime_subscription: SubscriptionSchema, + template_manifest: TemplateManifestSchema, }; /** The prefix `retiredKey()` writes onto a tombstone's description. */ diff --git a/packages/spec/src/marketplace/index.ts b/packages/spec/src/marketplace/index.ts index 5fbfb0f389f..32a796d0bec 100644 --- a/packages/spec/src/marketplace/index.ts +++ b/packages/spec/src/marketplace/index.ts @@ -10,6 +10,8 @@ * - Listing, submission, review, search and install request/response shapes (`marketplace.zod`) * - The listing-localisation resolver shared by every surface (`package-l10n`) * - The on-disk `objectstack.manifest.json` template descriptor (`template-manifest.zod`) + * - The marketplace taxonomy — business function, industry, solution type — + * and the maturity flag, defined once for every surface (`taxonomy.zod`) * * Until #16325 these five modules lived under `./cloud` beside the cloud * control plane's own row contracts (environments, tenants, the developer @@ -26,3 +28,4 @@ export * from './package.zod'; export * from './package-l10n'; export * from './package-version.zod'; export * from './template-manifest.zod'; +export * from './taxonomy.zod'; diff --git a/packages/spec/src/marketplace/marketplace.test.ts b/packages/spec/src/marketplace/marketplace.test.ts index 7eb2e141b6b..0ff7b75f1af 100644 --- a/packages/spec/src/marketplace/marketplace.test.ts +++ b/packages/spec/src/marketplace/marketplace.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'; import { PublisherVerificationSchema, PublisherSchema, - MarketplaceCategorySchema, ListingStatusSchema, PricingModelSchema, MarketplaceListingSchema, @@ -56,19 +55,6 @@ describe('PublisherSchema', () => { }); }); -describe('MarketplaceCategorySchema', () => { - it('should accept all valid categories', () => { - const categories = [ - 'crm', 'erp', 'hr', 'finance', 'project', 'collaboration', - 'analytics', 'integration', 'automation', 'ai', 'security', - 'developer-tools', 'ui-theme', 'storage', 'other', - ]; - categories.forEach(cat => { - expect(() => MarketplaceCategorySchema.parse(cat)).not.toThrow(); - }); - }); -}); - describe('ListingStatusSchema', () => { it('should accept all listing statuses', () => { const statuses = [ @@ -97,7 +83,7 @@ describe('MarketplaceListingSchema', () => { packageId: 'com.acme.crm', publisherId: 'pub-001', name: 'Acme CRM', - category: 'crm' as const, + businessFunctions: ['sales' as const], latestVersion: '1.0.0', }; const parsed = MarketplaceListingSchema.parse(listing); @@ -111,7 +97,7 @@ describe('MarketplaceListingSchema', () => { packageId: 'com.acme.crm', publisherId: 'pub-001', name: 'Acme CRM', - category: 'crm' as const, + businessFunctions: ['sales' as const], latestVersion: '1.0.0', }; expect(MarketplaceListingSchema.parse(listing).packageType).toBe('app'); @@ -123,7 +109,7 @@ describe('MarketplaceListingSchema', () => { packageId: 'com.acme.pg-driver', publisherId: 'pub-001', name: 'Postgres Driver', - category: 'crm' as const, + businessFunctions: ['sales' as const], latestVersion: '1.0.0', packageType: 'driver', }; @@ -139,7 +125,7 @@ describe('MarketplaceListingSchema', () => { name: 'Acme CRM', tagline: 'Complete customer relationship management for ObjectStack', description: '# Acme CRM\n\nFull-featured CRM with sales pipeline...', - category: 'crm' as const, + businessFunctions: ['sales' as const], tags: ['sales', 'pipeline', 'contacts'], iconUrl: 'https://acme.com/crm-icon.png', screenshots: [ @@ -189,7 +175,7 @@ describe('MarketplaceListingSchema', () => { publisherId: 'pub-001', name: 'Test', tagline: 'x'.repeat(121), - category: 'other' as const, + businessFunctions: ['it' as const], latestVersion: '1.0.0', }; expect(() => MarketplaceListingSchema.parse(listing)).toThrow(); @@ -262,7 +248,8 @@ describe('MarketplaceSearchRequestSchema', () => { it('should accept search with filters', () => { const request = { query: 'crm', - category: 'crm' as const, + businessFunctions: ['sales' as const, 'marketing' as const], + solutionTypes: ['template' as const], pricing: 'free' as const, publisherVerification: 'verified' as const, sortBy: 'popularity' as const, @@ -271,7 +258,8 @@ describe('MarketplaceSearchRequestSchema', () => { }; const parsed = MarketplaceSearchRequestSchema.parse(request); expect(parsed.query).toBe('crm'); - expect(parsed.category).toBe('crm'); + expect(parsed.businessFunctions).toEqual(['sales', 'marketing']); + expect(parsed.solutionTypes).toEqual(['template']); expect(parsed.page).toBe(2); }); @@ -300,16 +288,19 @@ describe('MarketplaceSearchResponseSchema', () => { packageId: 'com.acme.crm', publisherId: 'pub-001', name: 'Acme CRM', - category: 'crm' as const, + businessFunctions: ['sales' as const], latestVersion: '1.0.0', }], total: 1, page: 1, pageSize: 20, facets: { - categories: [ - { category: 'crm' as const, count: 12 }, - { category: 'erp' as const, count: 8 }, + businessFunctions: [ + { value: 'sales' as const, count: 12 }, + { value: 'finance' as const, count: 8 }, + ], + solutionTypes: [ + { value: 'template' as const, count: 3 }, ], pricing: [ { model: 'free' as const, count: 15 }, @@ -318,7 +309,8 @@ describe('MarketplaceSearchResponseSchema', () => { }, }; const parsed = MarketplaceSearchResponseSchema.parse(response); - expect(parsed.facets?.categories).toHaveLength(2); + expect(parsed.facets?.businessFunctions).toHaveLength(2); + expect(parsed.facets?.solutionTypes).toEqual([{ value: 'template', count: 3 }]); }); }); @@ -448,7 +440,7 @@ describe('MarketplaceListingSchema - Artifact References', () => { packageId: 'com.acme.crm', publisherId: 'pub-001', name: 'Acme CRM', - category: 'crm' as const, + businessFunctions: ['sales' as const], latestVersion: '2.0.0', versions: [ { diff --git a/packages/spec/src/marketplace/marketplace.zod.ts b/packages/spec/src/marketplace/marketplace.zod.ts index 06f44f83286..49752196bfa 100644 --- a/packages/spec/src/marketplace/marketplace.zod.ts +++ b/packages/spec/src/marketplace/marketplace.zod.ts @@ -56,7 +56,17 @@ import { CONSUMER_INSTALLABLE_TYPES } from '../kernel/plugin.zod'; * Publisher Verification Status */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; import { PackageTranslationsSchema } from './package.zod'; +import { + MarketplaceBusinessFunctionSchema, + MarketplaceBusinessFunctionsSchema, + MarketplaceIndustriesSchema, + MarketplaceIndustrySchema, + MarketplaceSolutionTypeSchema, + PackageMaturitySchema, + retiredCategoryGuidance, +} from './taxonomy.zod'; export const PublisherVerificationSchema = lazySchema(() => z.enum([ 'unverified', // Not yet verified 'pending', // Verification in progress @@ -161,26 +171,10 @@ export type ArtifactDownloadResponse = z.input z.enum([ - 'crm', // Customer Relationship Management - 'erp', // Enterprise Resource Planning - 'hr', // Human Resources - 'finance', // Finance & Accounting - 'project', // Project Management - 'collaboration', // Collaboration & Communication - 'analytics', // Analytics & Reporting - 'integration', // Integrations & Connectors - 'automation', // Automation & Workflows - 'ai', // AI & Machine Learning - 'security', // Security & Compliance - 'developer-tools', // Developer Tools - 'ui-theme', // UI Themes & Appearance - 'storage', // Storage & Drivers - 'other', // Other / Uncategorized -]).describe('Marketplace package category')); +// `MarketplaceCategorySchema` — the listing's single closed category — was +// RETIRED with `PackageCategorySchema`. The marketplace taxonomy is the facets +// in `./taxonomy.zod.ts` (business function, industry, solution type) plus the +// separate `maturity` flag; `LEGACY_CATEGORY_MAPPING` there maps each old value. /** * Listing Status @@ -249,8 +243,26 @@ export const MarketplaceListingSchema = lazySchema(() => z.object({ /** Full description (supports Markdown) */ description: z.string().optional().describe('Full description (Markdown)'), - /** Category */ - category: MarketplaceCategorySchema.describe('Package category'), + /** + * REMOVED — the listing's single category, retired with + * `PackageSchema.category` (see that tombstone). The facets below replace it. + */ + category: retiredKey(retiredCategoryGuidance('MarketplaceListing.category')), + + /** + * Business functions the package serves — required on a listing: 1 to 3, + * the primary one first; a listing card shows the first. + */ + businessFunctions: MarketplaceBusinessFunctionsSchema, + + /** Industries the package is built for — up to 3; absent means cross-industry. */ + industries: MarketplaceIndustriesSchema.optional(), + + /** What the package is: `app`, `template` or `connector`. */ + solutionType: MarketplaceSolutionTypeSchema.default('app'), + + /** `beta` shows a badge on the card and the detail page; absent means stable. */ + maturity: PackageMaturitySchema.default('stable'), /** Additional tags for search discovery */ tags: z.array(z.string()).optional().describe('Search tags'), @@ -411,8 +423,23 @@ export const MarketplaceSearchRequestSchema = lazySchema(() => z.object({ /** Search query string */ query: z.string().optional().describe('Full-text search query'), - /** Filter by category */ - category: MarketplaceCategorySchema.optional().describe('Filter by category'), + /** REMOVED — the category filter, retired with the listing's `category`. */ + category: retiredKey(retiredCategoryGuidance('MarketplaceSearchRequest.category')), + + /** + * Facet filters. Values inside one facet are OR-ed, facets are AND-ed: + * `{ businessFunctions: ['sales', 'marketing'], solutionTypes: ['template'] }` + * finds sales OR marketing templates. + */ + businessFunctions: z.array(MarketplaceBusinessFunctionSchema).optional() + .describe('Filter: any of these business functions'), + industries: z.array(MarketplaceIndustrySchema).optional() + .describe('Filter: any of these industries'), + solutionTypes: z.array(MarketplaceSolutionTypeSchema).optional() + .describe('Filter: any of these solution types'), + + /** Filter by maturity (e.g. stable only). Not a facet: it has no facet count. */ + maturity: PackageMaturitySchema.optional().describe('Filter by maturity'), /** Filter by tags */ tags: z.array(z.string()).optional().describe('Filter by tags'), @@ -466,8 +493,19 @@ export const MarketplaceSearchResponseSchema = lazySchema(() => z.object({ /** Facets for filtering */ facets: z.object({ - categories: z.array(z.object({ - category: MarketplaceCategorySchema, + // `categories` was retired with the listing's `category`; one count list + // per taxonomy facet replaces it. + categories: retiredKey(retiredCategoryGuidance('MarketplaceSearchResponse.facets.categories')), + businessFunctions: z.array(z.object({ + value: MarketplaceBusinessFunctionSchema, + count: z.number().int().min(0), + })).optional(), + industries: z.array(z.object({ + value: MarketplaceIndustrySchema, + count: z.number().int().min(0), + })).optional(), + solutionTypes: z.array(z.object({ + value: MarketplaceSolutionTypeSchema, count: z.number().int().min(0), })).optional(), pricing: z.array(z.object({ @@ -579,7 +617,6 @@ export type PublisherVerification = z.input; export type Publisher = z.input; /** Post-parse shape of {@link Publisher} — defaults applied, transforms run (ADR-0122). */ export type PublisherParsed = z.infer; -export type MarketplaceCategory = z.input; export type ListingStatus = z.input; export type PricingModel = z.input; export type MarketplaceListing = z.input; diff --git a/packages/spec/src/marketplace/package.zod.ts b/packages/spec/src/marketplace/package.zod.ts index 9d71be7c8f3..7ae876d1b3d 100644 --- a/packages/spec/src/marketplace/package.zod.ts +++ b/packages/spec/src/marketplace/package.zod.ts @@ -33,6 +33,14 @@ import { lazySchema } from '../shared/lazy-schema'; // authoring key name the same identity; two independent copies of the pattern // are what let them drift apart in the first place. import { MANIFEST_ID_PATTERN, manifestIdRefusal } from '../kernel/manifest.zod'; +import { retiredKey } from '../shared/retired-key'; +import { + MarketplaceBusinessFunctionsSchema, + MarketplaceIndustriesSchema, + MarketplaceSolutionTypeSchema, + PackageMaturitySchema, + retiredCategoryGuidance, +} from './taxonomy.zod'; export const PackageVisibilitySchema = lazySchema(() => z .enum(['private', 'org', 'marketplace']) .describe( @@ -41,16 +49,10 @@ export const PackageVisibilitySchema = lazySchema(() => z export type PackageVisibility = z.input; -/** - * Category hint for marketplace discovery and filtering. - * Kept open-ended (z.string()) so third-party packages can declare custom categories. - */ -export const PackageCategorySchema = lazySchema(() => z - .string() - .min(1) - .describe('Package category for marketplace discovery (e.g. "crm", "hr", "finance", "devtools")')); - -export type PackageCategory = z.input; +// `PackageCategorySchema` (an open `category` string) was RETIRED with the +// listing's closed `MarketplaceCategorySchema`: the marketplace taxonomy is the +// facets in `./taxonomy.zod.ts`, and every `category` key below is a +// `retiredKey()` tombstone whose refusal names them (ADR-0087). /** * Provenance tier of a package publisher. @@ -232,8 +234,38 @@ export const PackageSchema = lazySchema(() => z.object({ */ visibility: PackageVisibilitySchema.default('org'), - /** Primary category for marketplace filtering. */ - category: PackageCategorySchema.optional(), + /** + * REMOVED — the single marketplace category. It had to say both who the + * package is for and what it is, and the platform and the cloud catalog + * kept lists that disagreed. Replaced by {@link businessFunctions}, + * {@link industries} and {@link solutionType} (the maintainer's taxonomy + * ruling, recorded on the card that retired it). Tombstoned rather than + * deleted: this object is not strict, so a bare deletion would strip the + * key silently. No D2 conversion — a package row is not a stack collection + * member; the cloud catalog migrates its stored rows once, reading + * `LEGACY_CATEGORY_MAPPING`. The D3 record is `marketplace-category-retired`. + */ + category: retiredKey(retiredCategoryGuidance('Package.category')), + + /** + * Business functions the package serves — 1 to 3, the primary one first + * (see `./taxonomy.zod.ts`). Optional here; the marketplace requires it at + * submission. + */ + businessFunctions: MarketplaceBusinessFunctionsSchema.optional(), + + /** Industries the package is built for — up to 3; absent means cross-industry. */ + industries: MarketplaceIndustriesSchema.optional(), + + /** + * What the package is: `app` (managed), `template` (unmanaged, the + * customer's to change) or `connector`. Cross-checked at publish against + * the built package (`explainSolutionTypeContradiction`). + */ + solutionType: MarketplaceSolutionTypeSchema.default('app'), + + /** `beta` shows a badge on the listing; a separate flag, never a facet. */ + maturity: PackageMaturitySchema.default('stable'), /** Additional tags for search and filtering (e.g. ["salesforce", "sync", "crm"]). */ tags: z.array(z.string()).optional().describe('Search and filter tags'), @@ -306,7 +338,16 @@ export const CreatePackageRequestSchema = lazySchema(() => z.object({ displayName: PackageSchema.shape.displayName, description: PackageSchema.shape.description, visibility: PackageVisibilitySchema.optional(), - category: PackageCategorySchema.optional(), + // Retired with `PackageSchema.category` — see that tombstone. + category: retiredKey(retiredCategoryGuidance('CreatePackageRequest.category')), + // The taxonomy facets and the maturity flag. No `.default()` on a request: + // an absent key is "not stated", and the control plane applies the row's + // default to a new package (the `visibility` lesson — a request default + // rewrites an existing package on every re-publish). + businessFunctions: MarketplaceBusinessFunctionsSchema.optional(), + industries: MarketplaceIndustriesSchema.optional(), + solutionType: MarketplaceSolutionTypeSchema.optional(), + maturity: PackageMaturitySchema.optional(), tags: z.array(z.string()).optional(), iconUrl: z.string().url().optional(), homepageUrl: z.string().url().optional(), @@ -328,7 +369,12 @@ export const UpdatePackageRequestSchema = lazySchema(() => z.object({ description: PackageSchema.shape.description, readme: PackageSchema.shape.readme, visibility: PackageVisibilitySchema.optional(), - category: PackageCategorySchema.optional(), + // Retired with `PackageSchema.category` — see that tombstone. + category: retiredKey(retiredCategoryGuidance('UpdatePackageRequest.category')), + businessFunctions: MarketplaceBusinessFunctionsSchema.optional(), + industries: MarketplaceIndustriesSchema.optional(), + solutionType: MarketplaceSolutionTypeSchema.optional(), + maturity: PackageMaturitySchema.optional(), tags: z.array(z.string()).optional(), iconUrl: z.string().url().optional(), homepageUrl: z.string().url().optional(), diff --git a/packages/spec/src/marketplace/taxonomy.test.ts b/packages/spec/src/marketplace/taxonomy.test.ts new file mode 100644 index 00000000000..282431324e7 --- /dev/null +++ b/packages/spec/src/marketplace/taxonomy.test.ts @@ -0,0 +1,321 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The marketplace taxonomy (`./taxonomy.zod.ts`) and the retirement of the + * single `category` it replaces — the maintainer's ruling D1–D3. + * + * One `describe` per rule, so an ablation of a rule turns exactly its block + * red: + * + * F1 `businessFunctions` — 1 to 3 distinct values, ordered. + * F2 `industries` — 0 to 3 distinct values; no catch-all value. + * F3 `solutionType` — `app` | `template` | `connector`, no `theme`; + * defaulted on the row shapes, never on a request. + * D3 `maturity` — `beta` | `stable`, a flag beside the facets. + * × the publish cross-check: `template` needs `unmanaged`, `connector` + * needs a declared connector. + * D2 `category` refused by name on every schema that carried it, with the + * prescription; both category defs gone; ADR-0087 registration. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues + * carry `code` and `path` but no ADR-0112 `status` — none of these shapes has + * an HTTP door of its own here. Asserted: refusal, the issue `code`, the + * `path` naming the key, and the prescription's opening. + */ + +import { describe, expect, it } from 'vitest'; + +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { MIGRATIONS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import * as Marketplace from './index'; +import { + MarketplaceListingSchema, + MarketplaceSearchRequestSchema, + MarketplaceSearchResponseSchema, +} from './marketplace.zod'; +import { + CreatePackageRequestSchema, + PackageSchema, + UpdatePackageRequestSchema, + type CreatePackageRequest, +} from './package.zod'; +import { TemplateManifestSchema } from './template-manifest.zod'; +import { + explainSolutionTypeContradiction, + LEGACY_CATEGORY_MAPPING, + MarketplaceBusinessFunctionSchema, + MarketplaceBusinessFunctionsSchema, + MarketplaceIndustriesSchema, + MarketplaceIndustrySchema, + MarketplaceSolutionTypeSchema, + PackageMaturitySchema, +} from './taxonomy.zod'; + +const D3_ID = 'marketplace-category-retired'; + +const PACKAGE_ROW = { + id: '6a0f8b9e-1d2c-4e5f-8a7b-9c0d1e2f3a4b', + manifestId: 'com.acme.crm', + ownerOrgId: 'org_1', + displayName: 'Acme CRM', + createdAt: '2026-11-06T00:00:00Z', + updatedAt: '2026-11-06T00:00:00Z', + createdBy: 'usr_1', +}; +const CREATE_REQUEST = { manifestId: 'com.acme.crm', ownerOrgId: 'org_1', displayName: 'Acme CRM', createdBy: 'usr_1' }; +const TEMPLATE_MANIFEST = { name: 'acme-crm', specVersion: '^18.0.0', displayName: 'Acme CRM' }; +const LISTING = { + id: 'listing-001', + packageId: 'com.acme.crm', + publisherId: 'pub-001', + name: 'Acme CRM', + latestVersion: '1.0.0', + businessFunctions: ['sales'], +}; + +/** The issues of a refused parse. */ +function issuesOf(result: { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[]; message: string }> } }) { + expect(result.success).toBe(false); + return result.error!.issues; +} + +describe('F1 businessFunctions — 1 to 3 distinct business functions, the first is primary', () => { + it('accepts one to three values and keeps their order', () => { + expect(MarketplaceBusinessFunctionsSchema.parse(['sales'])).toEqual(['sales']); + expect(MarketplaceBusinessFunctionsSchema.parse(['marketing', 'sales', 'customer-service'])) + .toEqual(['marketing', 'sales', 'customer-service']); + }); + + it('refuses an empty list', () => { + const [issue] = issuesOf(MarketplaceBusinessFunctionsSchema.safeParse([])); + expect(issue!.code).toBe('too_small'); + expect(issue!.message).toMatch(/at least one business function/); + }); + + it('refuses a fourth value', () => { + const [issue] = issuesOf(MarketplaceBusinessFunctionsSchema.safeParse(['sales', 'finance', 'hr', 'legal'])); + expect(issue!.code).toBe('too_big'); + expect(issue!.message).toMatch(/at most 3 business functions/); + }); + + it('refuses a value listed twice', () => { + const [issue] = issuesOf(MarketplaceBusinessFunctionsSchema.safeParse(['sales', 'sales'])); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toMatch(/`businessFunctions` lists a value more than once/); + }); + + it('refuses a value outside the facet, and has no catch-all value', () => { + const [issue] = issuesOf(MarketplaceBusinessFunctionsSchema.safeParse(['operations'])); + expect(issue!.code).toBe('invalid_value'); + expect(issue!.path).toEqual([0]); + expect(MarketplaceBusinessFunctionSchema.options).not.toContain('other'); + }); + + it('is optional on a package and a publish request, and required on a listing', () => { + expect(PackageSchema.safeParse(PACKAGE_ROW).success).toBe(true); + expect(CreatePackageRequestSchema.safeParse(CREATE_REQUEST).success).toBe(true); + const { businessFunctions: _omitted, ...unclassified } = LISTING; + const [issue] = issuesOf(MarketplaceListingSchema.safeParse(unclassified)); + expect(issue!.path).toEqual(['businessFunctions']); + }); +}); + +describe('F2 industries — up to 3 distinct industries; absent means cross-industry', () => { + it('accepts zero to three values', () => { + expect(MarketplaceIndustriesSchema.parse([])).toEqual([]); + expect(MarketplaceIndustriesSchema.parse(['manufacturing', 'trade-distribution', 'retail'])) + .toEqual(['manufacturing', 'trade-distribution', 'retail']); + }); + + it('refuses a fourth value', () => { + const [issue] = issuesOf(MarketplaceIndustriesSchema.safeParse(['manufacturing', 'retail', 'logistics', 'education'])); + expect(issue!.code).toBe('too_big'); + expect(issue!.message).toMatch(/at most 3 industries/); + }); + + it('refuses a value listed twice', () => { + const [issue] = issuesOf(MarketplaceIndustriesSchema.safeParse(['retail', 'retail'])); + expect(issue!.message).toMatch(/`industries` lists a value more than once/); + }); + + it('has no general / cross-industry value: absence says that', () => { + for (const catchAll of ['general', 'cross-industry', 'other']) { + expect(MarketplaceIndustrySchema.safeParse(catchAll).success, catchAll).toBe(false); + } + expect(PackageSchema.parse(PACKAGE_ROW).industries).toBeUndefined(); + }); +}); + +describe('F3 solutionType — app, template or connector', () => { + it('has exactly the three ruled values, and no theme (ADR-0019)', () => { + expect(MarketplaceSolutionTypeSchema.options).toEqual(['app', 'template', 'connector']); + expect(MarketplaceSolutionTypeSchema.safeParse('theme').success).toBe(false); + }); + + it('defaults to `app` on the package row and the listing', () => { + expect(PackageSchema.parse(PACKAGE_ROW).solutionType).toBe('app'); + expect(MarketplaceListingSchema.parse(LISTING).solutionType).toBe('app'); + }); + + it('carries no default on a request or a manifest: absent stays absent, so a re-publish never rewrites it', () => { + expect(CreatePackageRequestSchema.parse(CREATE_REQUEST)).not.toHaveProperty('solutionType'); + expect(UpdatePackageRequestSchema.parse({})).not.toHaveProperty('solutionType'); + expect(TemplateManifestSchema.parse(TEMPLATE_MANIFEST)).not.toHaveProperty('solutionType'); + }); +}); + +describe('D3 maturity — beta or stable, a flag beside the facets', () => { + it('accepts beta and stable only', () => { + expect(PackageMaturitySchema.options).toEqual(['beta', 'stable']); + expect(PackageMaturitySchema.safeParse('preview').success).toBe(false); + }); + + it('means stable when absent on the row shapes, and stays absent on a request', () => { + expect(PackageSchema.parse(PACKAGE_ROW).maturity).toBe('stable'); + expect(MarketplaceListingSchema.parse(LISTING).maturity).toBe('stable'); + expect(CreatePackageRequestSchema.parse(CREATE_REQUEST)).not.toHaveProperty('maturity'); + }); + + it('is a search filter but not a facet count', () => { + expect(MarketplaceSearchRequestSchema.parse({ maturity: 'stable' }).maturity).toBe('stable'); + const facets = MarketplaceSearchResponseSchema.parse({ + items: [], total: 0, page: 1, pageSize: 20, + facets: { maturity: [{ value: 'beta', count: 1 }] }, + }).facets as Record; + expect(facets).not.toHaveProperty('maturity'); + }); +}); + +describe('the publish cross-check of solutionType', () => { + it('refuses a template whose package permits no unmanaged install, naming the fix', () => { + const refusal = explainSolutionTypeContradiction('template', { installModes: ['managed'], connectorCount: 0 }); + expect(refusal).toMatch(/^`solutionType: 'template'`.*permits only \['managed'\].*Add 'unmanaged' to `manifest\.installModes`/s); + }); + + it('accepts a template that permits an unmanaged install', () => { + expect(explainSolutionTypeContradiction('template', { installModes: ['managed', 'unmanaged'], connectorCount: 0 })).toBeUndefined(); + }); + + it('refuses a connector that declares no connector, naming the fix', () => { + const refusal = explainSolutionTypeContradiction('connector', { installModes: ['managed'], connectorCount: 0 }); + expect(refusal).toMatch(/^`solutionType: 'connector'`.*declares no connector.*under `connectors`/s); + }); + + it('accepts a connector that declares one', () => { + expect(explainSolutionTypeContradiction('connector', { installModes: ['managed'], connectorCount: 1 })).toBeUndefined(); + }); + + it('asks nothing of an app, or of a package that declares no solution type', () => { + expect(explainSolutionTypeContradiction('app', { installModes: ['managed'], connectorCount: 0 })).toBeUndefined(); + expect(explainSolutionTypeContradiction(undefined, { installModes: ['managed'], connectorCount: 0 })).toBeUndefined(); + }); +}); + +describe('D2 category — refused by name on every schema that carried it', () => { + const CASES: Array<[string, { safeParse: (v: unknown) => any }, Record, string[]]> = [ + ['Package.category', PackageSchema, { ...PACKAGE_ROW, category: 'crm' }, ['category']], + ['CreatePackageRequest.category', CreatePackageRequestSchema, { ...CREATE_REQUEST, category: 'crm' }, ['category']], + ['UpdatePackageRequest.category', UpdatePackageRequestSchema, { category: 'crm' }, ['category']], + ['TemplateManifest.category', TemplateManifestSchema, { ...TEMPLATE_MANIFEST, category: 'starter' }, ['category']], + ['MarketplaceListing.category', MarketplaceListingSchema, { ...LISTING, category: 'crm' }, ['category']], + ['MarketplaceSearchRequest.category', MarketplaceSearchRequestSchema, { category: 'crm' }, ['category']], + ['MarketplaceSearchResponse.facets.categories', MarketplaceSearchResponseSchema, + { items: [], total: 0, page: 1, pageSize: 20, facets: { categories: [{ category: 'crm', count: 1 }] } }, + ['facets', 'categories']], + ]; + + it.each(CASES)('%s carries the prescription', (key, schema, input, path) => { + const [issue] = issuesOf(schema.safeParse(input)); + expect(issue!.path).toEqual(path); + expect(issue!.message.startsWith(`\`${key}\` was removed in @objectstack/spec 18 (ADR-0087)`)).toBe(true); + expect(issue!.message).toMatch(/Delete the key\. Declare `businessFunctions`.*`industries`.*`solutionType`.*LEGACY_CATEGORY_MAPPING/s); + }); + + it('types the key as the retired mark for a TypeScript author', () => { + const request: CreatePackageRequest = { + ...CREATE_REQUEST, + // @ts-expect-error — `category` is a retiredKey() tombstone: no value is assignable. + category: 'crm', + }; + expect(CreatePackageRequestSchema.safeParse(request).success).toBe(false); + }); + + it('leaves no category def exported from @objectstack/spec/marketplace', () => { + for (const gone of ['MarketplaceCategorySchema', 'PackageCategorySchema']) { + expect(Object.keys(Marketplace), gone).not.toContain(gone); + } + }); +}); + +describe('D2 category — ADR-0087 registration', () => { + const KEYS = ['Package', 'CreatePackageRequest', 'UpdatePackageRequest', 'TemplateManifest', 'MarketplaceListing', 'MarketplaceSearchRequest'] + .map((def) => `marketplace/${def}:category`); + + it('declares every tombstoned key and both defs under major 18', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toEqual(expect.arrayContaining(KEYS)); + expect(RETIRED_DEFS_BY_MAJOR[18]).toEqual(expect.arrayContaining(['marketplace/MarketplaceCategory', 'marketplace/PackageCategory'])); + }); + + it('carries no D2 conversion — none of these shapes is a stack collection member', () => { + const touching = ALL_CONVERSIONS.filter((c) => /category/i.test(c.surface) && /Package|Listing|Marketplace|TemplateManifest/.test(c.surface)); + expect(touching.map((c) => c.id)).toEqual([]); + }); + + it('carries ONE D3 entry for the family, naming the facets', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries).toHaveLength(1); + expect(entries[0]!.replacement).toContain('`businessFunctions`'); + expect(entries[0]!.replacement).toContain('`LEGACY_CATEGORY_MAPPING`'); + }); +}); + +describe('LEGACY_CATEGORY_MAPPING — what every old category value becomes', () => { + // The two retired lists, as they stood when they were retired — a record of + // the old values this table must answer for, not a live taxonomy. + const RETIRED_SPEC_VALUES = [ + 'crm', 'erp', 'hr', 'finance', 'project', 'collaboration', 'analytics', 'integration', + 'automation', 'ai', 'security', 'developer-tools', 'ui-theme', 'storage', 'other', + ]; + const RETIRED_CLOUD_VALUES = [ + 'app', 'crm', 'sales', 'marketing', 'finance', 'hr', 'operations', 'project', 'productivity', + 'data', 'integrations', 'devtools', 'utility', 'other', 'starter', + ]; + + it('answers for every value either retired list held', () => { + for (const value of [...RETIRED_SPEC_VALUES, ...RETIRED_CLOUD_VALUES]) { + expect(LEGACY_CATEGORY_MAPPING, value).toHaveProperty(value); + } + }); + + it('maps only to values the facets accept, and keys are lowercase and trimmed', () => { + for (const [value, mapping] of Object.entries(LEGACY_CATEGORY_MAPPING)) { + expect(value).toBe(value.trim().toLowerCase()); + if (mapping.kind === 'unlistable') continue; + expect(MarketplaceSolutionTypeSchema.safeParse(mapping.solutionType).success, value).toBe(true); + if (mapping.kind === 'mapped') { + expect(MarketplaceBusinessFunctionsSchema.safeParse(mapping.businessFunctions).success, value).toBe(true); + } + } + }); + + it('folds nothing into a catch-all: an uncertain value stays manual', () => { + expect(LEGACY_CATEGORY_MAPPING.other).toEqual({ kind: 'manual', solutionType: 'app' }); + expect(LEGACY_CATEGORY_MAPPING.analytics!.kind).toBe('manual'); + }); + + it('gives a connector its connector type and leaves its business function to the author', () => { + for (const value of ['integration', 'integrations', 'connector', 'connectors']) { + expect(LEGACY_CATEGORY_MAPPING[value], value).toEqual({ kind: 'manual', solutionType: 'connector' }); + } + }); + + it('maps the old starter marker to the template type, and the two values ADR-0019 never lists to nothing', () => { + expect(LEGACY_CATEGORY_MAPPING.starter).toEqual({ kind: 'manual', solutionType: 'template' }); + expect(LEGACY_CATEGORY_MAPPING['ui-theme']!.kind).toBe('unlistable'); + expect(LEGACY_CATEGORY_MAPPING.storage!.kind).toBe('unlistable'); + }); + + it('flags operations for review: one value held three functions', () => { + expect(LEGACY_CATEGORY_MAPPING.operations).toMatchObject({ kind: 'mapped', businessFunctions: ['administration'], review: true }); + }); +}); diff --git a/packages/spec/src/marketplace/taxonomy.zod.ts b/packages/spec/src/marketplace/taxonomy.zod.ts new file mode 100644 index 00000000000..a74a3f8eaba --- /dev/null +++ b/packages/spec/src/marketplace/taxonomy.zod.ts @@ -0,0 +1,333 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; +import type { InstallMode } from '../kernel/manifest.zod'; + +/** + * Marketplace taxonomy — the ONE classification of a listed package. + * + * A package used to carry a single `category`, and that one value had to say + * two different things at once: who the package is FOR (a business function) + * and what it IS (a full app, a template, a connector). The platform declared + * it as a closed enum on the listing (`MarketplaceCategorySchema`) and as an + * open string on the package (`PackageCategorySchema`), the cloud catalog kept + * a third list, and the scenario library copied the third — four lists that + * agreed on five values. A connector filed under `integrations` lost the + * business function it served; one `operations` value held three functions. + * + * The maintainer's ruling replaced it with three facets and one separate flag, + * defined here once and read by every surface — the CLI publish path, the cloud + * catalog and the console marketplace — so there is no second list to drift: + * + * | Facet | Key | Arity | Required | + * |:--|:--|:--|:--| + * | Business function | `businessFunctions` | 1–3, ordered, the first is primary | to submit to the marketplace | + * | Industry | `industries` | 0–3 | never — absent means cross-industry | + * | Solution type | `solutionType` | one | no — absent means `app` | + * | Maturity (not a facet) | `maturity` | one | no — absent means `stable` | + * + * Filtering semantics the catalog applies: OR inside one facet, AND across + * facets. Free-text `tags` stay for search only and generate no filter. + * + * ## Growing a facet + * + * Values are append-only. Renaming or removing one is an ADR-0087 retirement + * with a data migration. Adding one needs a real listed package, or a ruled + * scenario card, that needs it, and the console's labels in the same change. + * Keep each facet near a dozen values; merge before you grow. + * + * ## What is deliberately absent + * + * - No `general` / `cross-industry` industry: absence already means that, and a + * catch-all value is the one every author (an AI author first) would tick. + * - No `theme` solution type: ADR-0019 lists only apps, and a theme ships + * inside one. + * - No company-size facet: nothing in the catalog or the scenario library uses + * one. + * - No `other` value anywhere: an unclassifiable package is a question for its + * author, never a bucket. + */ + +// --------------------------------------------------------------------------- +// F1 — business function +// --------------------------------------------------------------------------- + +/** + * The business function a package serves — the primary way a buyer looks for a + * template. `businessFunctions` (not `functions`) so it never reads as code. + */ +export const MarketplaceBusinessFunctionSchema = lazySchema(() => z.enum([ + 'sales', // Sales + 'marketing', // Marketing + 'customer-service', // Customer service and support + 'finance', // Finance and accounting + 'hr', // Human resources + 'supply-chain', // Procurement and supply chain + 'administration', // Administration and assets + 'project', // Project management + 'legal', // Legal and compliance + 'it', // IT and development + 'collaboration', // Collaboration and office work +]).describe('Business function a marketplace package serves')); + +export type MarketplaceBusinessFunction = z.input; + +/** The most business functions one package may claim. */ +export const MAX_BUSINESS_FUNCTIONS = 3; + +// --------------------------------------------------------------------------- +// F2 — industry +// --------------------------------------------------------------------------- + +/** + * The industry a package is built for. Optional on every package: a package + * that declares none is cross-industry, and the catalog hides this facet's + * filter while no listing carries a value. + */ +export const MarketplaceIndustrySchema = lazySchema(() => z.enum([ + 'manufacturing', // Manufacturing (chemicals included) + 'trade-distribution', // Trade and distribution (export included) + 'retail', // Retail and consumer goods + 'logistics', // Logistics and transport + 'professional-services', // Professional services + 'financial-services', // Financial services + 'healthcare', // Healthcare + 'education', // Education + 'public-sector', // Government and public sector + 'real-estate-construction', // Real estate and construction +]).describe('Industry a marketplace package is built for')); + +export type MarketplaceIndustry = z.input; + +/** The most industries one package may claim. */ +export const MAX_INDUSTRIES = 3; + +// --------------------------------------------------------------------------- +// F3 — solution type +// --------------------------------------------------------------------------- + +/** + * What the package IS, declared by its author and cross-checked at publish + * against what the package actually ships ({@link explainSolutionTypeContradiction}): + * + * - `app` — a complete application its publisher keeps upgrading (managed). + * - `template` — installed into the customer's hands to change at will; needs + * the `unmanaged` install mode (ADR-0131 D6). + * - `connector` — reaches one external system's API through a declared + * connector. + * + * Not `kind` or `type`, so it never collides with `packageType` (ADR-0019, + * where the only listable package type is `app`) or a page's `kind`. + */ +export const MarketplaceSolutionTypeSchema = lazySchema(() => z.enum([ + 'app', + 'template', + 'connector', +]).describe('What a marketplace package is: a managed app, an unmanaged template, or a connector')); + +export type MarketplaceSolutionType = z.input; + +/** The solution type of a package that declares none. */ +export const DEFAULT_SOLUTION_TYPE: MarketplaceSolutionType = 'app'; + +// --------------------------------------------------------------------------- +// Maturity — a flag beside the facets, not a facet +// --------------------------------------------------------------------------- + +/** + * How far along a package is. A facet says what a package is about and stays + * fixed; maturity changes when the package graduates, so it is a separate flag + * and a beta package keeps its real facets. Absent means `stable`. An enum + * rather than a boolean so a later `preview` is one more value. + */ +export const PackageMaturitySchema = lazySchema(() => z.enum([ + 'beta', + 'stable', +]).describe('Package maturity: beta shows a badge on the listing; absent means stable')); + +export type PackageMaturity = z.input; + +// --------------------------------------------------------------------------- +// The keys, as every package and listing schema declares them +// --------------------------------------------------------------------------- + +/** A list with no repeated value — a facet value counts once. */ +function distinct(item: T, key: string) { + return z.array(item).refine( + (values) => new Set(values).size === values.length, + { message: `\`${key}\` lists a value more than once; list each value once.` }, + ); +} + +/** + * `businessFunctions` — 1 to {@link MAX_BUSINESS_FUNCTIONS} distinct business + * functions, ordered: the first is the primary one, the one a listing card + * shows. The key itself is optional on a package (a private package needs no + * classification); submitting to the marketplace requires it. + */ +export const MarketplaceBusinessFunctionsSchema = lazySchema(() => + distinct(MarketplaceBusinessFunctionSchema, 'businessFunctions') + .min(1, '`businessFunctions` lists at least one business function — omit the key on a package that is not listed.') + .max(MAX_BUSINESS_FUNCTIONS, `\`businessFunctions\` lists at most ${MAX_BUSINESS_FUNCTIONS} business functions; keep the ones the package is really for, the primary one first.`) + .describe(`Business functions the package serves: 1-${MAX_BUSINESS_FUNCTIONS} distinct values, the first is primary`), +); + +export type MarketplaceBusinessFunctions = z.input; + +/** + * `industries` — up to {@link MAX_INDUSTRIES} distinct industries. Absent, and + * equally an empty list, means cross-industry. + */ +export const MarketplaceIndustriesSchema = lazySchema(() => + distinct(MarketplaceIndustrySchema, 'industries') + .max(MAX_INDUSTRIES, `\`industries\` lists at most ${MAX_INDUSTRIES} industries; omit the key for a cross-industry package.`) + .describe(`Industries the package is built for: up to ${MAX_INDUSTRIES} distinct values; absent means cross-industry`), +); + +export type MarketplaceIndustries = z.input; + +// --------------------------------------------------------------------------- +// The publish-time cross-check of `solutionType` +// --------------------------------------------------------------------------- + +/** + * The facts about a built package that its declared {@link MarketplaceSolutionType} + * must agree with — read off the compiled artifact, never off the listing. + */ +export interface SolutionTypeEvidence { + /** The artifact manifest's install modes, resolved (`resolveInstallModes`). */ + readonly installModes: readonly InstallMode[]; + /** How many connectors the artifact declares (its `connectors` collection). */ + readonly connectorCount: number; +} + +/** + * The refusal for a declared solution type the package contradicts, or + * `undefined` when they agree. A declaration rather than an inference, so a + * mismatch is a loud refusal instead of a silent guess: + * + * - `template` needs `unmanaged` among the install modes — a template is + * handed to the customer to change, which is what `unmanaged` means. + * - `connector` needs at least one declared connector. + * - `app`, or no declaration, needs nothing. + * + * The CLI publish path asks this before any network call, and the cloud + * publish gate asks it again, so both doors answer with one rule and one + * sentence. + */ +export function explainSolutionTypeContradiction( + solutionType: MarketplaceSolutionType | undefined, + evidence: SolutionTypeEvidence, +): string | undefined { + if (solutionType === 'template' && !evidence.installModes.includes('unmanaged')) { + const modes = evidence.installModes.map((mode) => `'${mode}'`).join(', '); + return '`solutionType: \'template\'` means the installed package is the customer\'s to change, which ' + + `needs the 'unmanaged' install mode, but the package permits only [${modes}]. Add 'unmanaged' to ` + + '`manifest.installModes` in objectstack.config.ts (ADR-0131 D6), or declare `solutionType: \'app\'`.'; + } + if (solutionType === 'connector' && evidence.connectorCount === 0) { + return '`solutionType: \'connector\'` means the package reaches an external system through a declared ' + + 'connector, but the package declares no connector. Declare one under `connectors` in ' + + 'objectstack.config.ts, or declare `solutionType: \'app\'`.'; + } + return undefined; +} + +// --------------------------------------------------------------------------- +// The retired `category`, and what each old value becomes +// --------------------------------------------------------------------------- + +/** + * What one retired `category` value becomes. + * + * - `mapped` — a certain mapping. `review: true` marks one an operator should + * still look at (`operations` held three different functions). + * - `manual` — no certain business function: the solution type is known, the + * functions are the author's call, and a migration lists the package for a + * person to complete. Never folded into a catch-all. + * - `unlistable` — the value named something ADR-0019 never lists; there is + * nothing to map it to. + * + * `tag`, when present, is appended to the package's free-text `tags` so search + * still finds what the old value said. + */ +export type LegacyCategoryMapping = + | { + readonly kind: 'mapped'; + readonly businessFunctions: readonly MarketplaceBusinessFunction[]; + readonly solutionType: MarketplaceSolutionType; + readonly tag?: string; + readonly review?: true; + } + | { + readonly kind: 'manual'; + readonly solutionType: MarketplaceSolutionType; + readonly tag?: string; + } + | { + readonly kind: 'unlistable'; + readonly reason: string; + }; + +/** + * Every value the retired `category` was written with — the spec listing's + * closed enum, the cloud catalog's list and the aliases its earlier migration + * folded — mapped to the facets. Keys are lowercase and trimmed. The cloud + * catalog's one-time migration and the CLI's refusal both read this table, so + * the mapping is stated once. + */ +export const LEGACY_CATEGORY_MAPPING: Readonly> = Object.freeze({ + // The spec listing's enum. + crm: { kind: 'mapped', businessFunctions: ['sales'], solutionType: 'app', tag: 'crm' }, + erp: { kind: 'mapped', businessFunctions: ['finance', 'supply-chain'], solutionType: 'app', tag: 'erp' }, + hr: { kind: 'mapped', businessFunctions: ['hr'], solutionType: 'app' }, + finance: { kind: 'mapped', businessFunctions: ['finance'], solutionType: 'app' }, + project: { kind: 'mapped', businessFunctions: ['project'], solutionType: 'app' }, + collaboration: { kind: 'mapped', businessFunctions: ['collaboration'], solutionType: 'app' }, + analytics: { kind: 'manual', solutionType: 'app', tag: 'analytics' }, + integration: { kind: 'manual', solutionType: 'connector' }, + automation: { kind: 'manual', solutionType: 'app', tag: 'automation' }, + ai: { kind: 'manual', solutionType: 'app', tag: 'ai' }, + security: { kind: 'mapped', businessFunctions: ['it'], solutionType: 'app', tag: 'security' }, + 'developer-tools': { kind: 'mapped', businessFunctions: ['it'], solutionType: 'app', tag: 'devtools' }, + 'ui-theme': { kind: 'unlistable', reason: 'a theme ships inside an app and is never listed on its own (ADR-0019)' }, + storage: { kind: 'unlistable', reason: 'a storage driver is operator-provisioned and never listed (ADR-0019)' }, + other: { kind: 'manual', solutionType: 'app' }, + // The cloud catalog's list (values the spec enum did not already carry). + app: { kind: 'manual', solutionType: 'app' }, + sales: { kind: 'mapped', businessFunctions: ['sales'], solutionType: 'app' }, + marketing: { kind: 'mapped', businessFunctions: ['marketing'], solutionType: 'app' }, + operations: { kind: 'mapped', businessFunctions: ['administration'], solutionType: 'app', tag: 'operations', review: true }, + productivity: { kind: 'mapped', businessFunctions: ['collaboration'], solutionType: 'app' }, + data: { kind: 'manual', solutionType: 'app', tag: 'analytics' }, + integrations: { kind: 'manual', solutionType: 'connector' }, + devtools: { kind: 'mapped', businessFunctions: ['it'], solutionType: 'app', tag: 'devtools' }, + utility: { kind: 'manual', solutionType: 'app', tag: 'utility' }, + // Aliases the cloud catalog's earlier migration folded into its list. + application: { kind: 'manual', solutionType: 'app' }, + apps: { kind: 'manual', solutionType: 'app' }, + business: { kind: 'mapped', businessFunctions: ['administration'], solutionType: 'app', tag: 'operations', review: true }, + ops: { kind: 'mapped', businessFunctions: ['administration'], solutionType: 'app', tag: 'operations', review: true }, + connector: { kind: 'manual', solutionType: 'connector' }, + connectors: { kind: 'manual', solutionType: 'connector' }, + tools: { kind: 'mapped', businessFunctions: ['it'], solutionType: 'app', tag: 'devtools' }, + developer: { kind: 'mapped', businessFunctions: ['it'], solutionType: 'app', tag: 'devtools' }, + travel: { kind: 'manual', solutionType: 'app', tag: 'travel' }, + // The old template marker. `isStarter` is a separate platform decision and + // is not touched by this mapping. + starter: { kind: 'manual', solutionType: 'template' }, +} satisfies Record); + +/** + * The prescription a `retiredKey()` tombstone carries for a retired `category` + * key, opened by the fully qualified key it sits on. + */ +export function retiredCategoryGuidance(qualifiedKey: string): string { + return `\`${qualifiedKey}\` was removed in @objectstack/spec 18 (ADR-0087) — one category had to say both who a ` + + 'package is for and what it is, and the platform and the cloud catalog spelled it from lists that disagreed. ' + + 'Delete the key. Declare `businessFunctions` (1-3 business functions, the primary one first), optionally ' + + '`industries` (up to 3; absent means cross-industry) and `solutionType` (`app`, `template` or `connector`; ' + + 'absent means `app`). `LEGACY_CATEGORY_MAPPING` in @objectstack/spec/marketplace gives the facets for every ' + + 'old category value.'; +} diff --git a/packages/spec/src/marketplace/template-manifest.zod.ts b/packages/spec/src/marketplace/template-manifest.zod.ts index ce1baf3617e..8b57ddb73a0 100644 --- a/packages/spec/src/marketplace/template-manifest.zod.ts +++ b/packages/spec/src/marketplace/template-manifest.zod.ts @@ -16,7 +16,9 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; import { CreatePackageRequestSchema } from './package.zod'; +import { retiredCategoryGuidance } from './taxonomy.zod'; export const TemplateManifestSchema = lazySchema(() => CreatePackageRequestSchema @@ -106,6 +108,15 @@ export const TemplateManifestSchema = lazySchema(() => namespace: CreatePackageRequestSchema.shape.namespace.describe( 'Scaffold-only: the template’s own metadata namespace, rewritten by create-objectstack at scaffold time and read back as the fallback source for the template’s original namespace. NOT the publish namespace — publish reads that off the compiled artifact’s manifest.namespace (ADR-0048 addendum §A.2)' ), + /** + * REMOVED — re-declared only so the refusal names the file an author + * writes: the tombstone it inherits from `CreatePackageRequestSchema` + * names the publish request. Declare `businessFunctions`, `industries` + * and `solutionType` instead (inherited from the create request). + * `pnpm --filter @objectstack/spec check:template-manifests` parses every + * shipped manifest against this schema, so none can still carry it. + */ + category: retiredKey(retiredCategoryGuidance('TemplateManifest.category')), skills: z.array(z.string()).optional() .describe('Skill ids exercised by this template (for docs / picker)'), preview: z.object({ diff --git a/packages/spec/src/migrations/entries/retired-defs/18.marketplace__MarketplaceCategory.ts b/packages/spec/src/migrations/entries/retired-defs/18.marketplace__MarketplaceCategory.ts new file mode 100644 index 00000000000..d0ce0c4ba6a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.marketplace__MarketplaceCategory.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// `marketplace/MarketplaceCategory` — the listing's closed single-category enum +// — left with the `category` keys it typed (D3 `marketplace-category-retired`). +// Its old values are the keys of `LEGACY_CATEGORY_MAPPING` +// (`marketplace/taxonomy.zod.ts`), which says what each one becomes. +export const entry = 'marketplace/MarketplaceCategory'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.marketplace__PackageCategory.ts b/packages/spec/src/migrations/entries/retired-defs/18.marketplace__PackageCategory.ts new file mode 100644 index 00000000000..c58067d17e3 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.marketplace__PackageCategory.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// `marketplace/PackageCategory` — the package's open category string — left +// with the `category` keys it typed (D3 `marketplace-category-retired`). An open +// string was the reason the platform and the cloud catalog could disagree. +export const entry = 'marketplace/PackageCategory'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.marketplace__CreatePackageRequest__category.ts b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__CreatePackageRequest__category.ts new file mode 100644 index 00000000000..24cf4590ae1 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__CreatePackageRequest__category.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// The single marketplace `category`, retired by the maintainer's taxonomy ruling +// (D2) for the facets in `marketplace/taxonomy.zod.ts` — business functions, +// industries and a solution type. `retiredKey()` carries the prescription and +// the `tsc` channel. No D2 conversion: a package row, a listing and an +// `objectstack.manifest.json` are not stack collection members (the +// `marketplace/PackageDependency:optional` precedent). The D3 record is +// `marketplace-category-retired`. +export const entry = 'marketplace/CreatePackageRequest:category'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.marketplace__MarketplaceListing__category.ts b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__MarketplaceListing__category.ts new file mode 100644 index 00000000000..67f9a014d10 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__MarketplaceListing__category.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// The single marketplace `category`, retired by the maintainer's taxonomy ruling +// (D2) for the facets in `marketplace/taxonomy.zod.ts` — business functions, +// industries and a solution type. `retiredKey()` carries the prescription and +// the `tsc` channel. No D2 conversion: a package row, a listing and an +// `objectstack.manifest.json` are not stack collection members (the +// `marketplace/PackageDependency:optional` precedent). The D3 record is +// `marketplace-category-retired`. +export const entry = 'marketplace/MarketplaceListing:category'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.marketplace__MarketplaceSearchRequest__category.ts b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__MarketplaceSearchRequest__category.ts new file mode 100644 index 00000000000..e2c7a883ee6 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__MarketplaceSearchRequest__category.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// The single marketplace `category`, retired by the maintainer's taxonomy ruling +// (D2) for the facets in `marketplace/taxonomy.zod.ts` — business functions, +// industries and a solution type. `retiredKey()` carries the prescription and +// the `tsc` channel. No D2 conversion: a package row, a listing and an +// `objectstack.manifest.json` are not stack collection members (the +// `marketplace/PackageDependency:optional` precedent). The D3 record is +// `marketplace-category-retired`. +export const entry = 'marketplace/MarketplaceSearchRequest:category'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.marketplace__Package__category.ts b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__Package__category.ts new file mode 100644 index 00000000000..c9c7506851c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__Package__category.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// The single marketplace `category`, retired by the maintainer's taxonomy ruling +// (D2) for the facets in `marketplace/taxonomy.zod.ts` — business functions, +// industries and a solution type. `retiredKey()` carries the prescription and +// the `tsc` channel. No D2 conversion: a package row, a listing and an +// `objectstack.manifest.json` are not stack collection members (the +// `marketplace/PackageDependency:optional` precedent). The D3 record is +// `marketplace-category-retired`. +export const entry = 'marketplace/Package:category'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__category.ts b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__category.ts new file mode 100644 index 00000000000..a36640d6c6e --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__TemplateManifest__category.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// The single marketplace `category`, retired by the maintainer's taxonomy ruling +// (D2) for the facets in `marketplace/taxonomy.zod.ts` — business functions, +// industries and a solution type. `retiredKey()` carries the prescription and +// the `tsc` channel. No D2 conversion: a package row, a listing and an +// `objectstack.manifest.json` are not stack collection members (the +// `marketplace/PackageDependency:optional` precedent). The D3 record is +// `marketplace-category-retired`. +export const entry = 'marketplace/TemplateManifest:category'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.marketplace__UpdatePackageRequest__category.ts b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__UpdatePackageRequest__category.ts new file mode 100644 index 00000000000..672610c0b0b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.marketplace__UpdatePackageRequest__category.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// The single marketplace `category`, retired by the maintainer's taxonomy ruling +// (D2) for the facets in `marketplace/taxonomy.zod.ts` — business functions, +// industries and a solution type. `retiredKey()` carries the prescription and +// the `tsc` channel. No D2 conversion: a package row, a listing and an +// `objectstack.manifest.json` are not stack collection members (the +// `marketplace/PackageDependency:optional` precedent). The D3 record is +// `marketplace-category-retired`. +export const entry = 'marketplace/UpdatePackageRequest:category'; diff --git a/packages/spec/src/migrations/entries/semantic/18.marketplace-category-retired.ts b/packages/spec/src/migrations/entries/semantic/18.marketplace-category-retired.ts new file mode 100644 index 00000000000..a0c490eb56e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.marketplace-category-retired.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The maintainer's marketplace-taxonomy ruling (D1–D3): one family, one entry. +// Six `category` keys are in `RETIRED_KEYS_BY_MAJOR[18]` and both category defs +// in `RETIRED_DEFS_BY_MAJOR[18]`. +export const entry: SemanticMigration = { + id: 'marketplace-category-retired', + surface: + 'Package.category / CreatePackageRequest.category / UpdatePackageRequest.category / ' + + 'TemplateManifest.category (objectstack.manifest.json) / MarketplaceListing.category / ' + + 'MarketplaceSearchRequest.category / MarketplaceSearchResponse.facets.categories', + replacement: + 'delete `category`. Declare `businessFunctions` (1-3 business functions, the primary one first; ' + + 'required to submit to the marketplace), optionally `industries` (up to 3; absent means ' + + 'cross-industry) and `solutionType` (`app`, `template` or `connector`; absent means `app`), all ' + + 'from `marketplace/taxonomy.zod.ts`. `LEGACY_CATEGORY_MAPPING` there gives the facets for every old ' + + 'category value; a value it marks `manual` needs the author to choose the business functions, and ' + + 'one it marks `unlistable` named something ADR-0019 never lists. A search filters by ' + + '`businessFunctions` / `industries` / `solutionTypes` and reads one facet count list per facet. ' + + '`maturity` (`beta` | `stable`) is a separate new flag, not a facet', + reason: + 'One `category` had to say both who a package is for and what it is: a connector filed under the ' + + 'integrations value lost the business function it served, and one operations value held three ' + + 'functions. The platform declared it twice — a closed enum on the listing and an open string on the ' + + 'package — and the cloud catalog and the scenario library kept two more lists that agreed with the ' + + 'listing on five values. The ruling defines the taxonomy once, here, for the CLI, the cloud catalog ' + + 'and the console. Why D3 semantic and not a D2 conversion: none of these shapes is a stack ' + + 'collection member, so a conversion would be a transform with no seam that ever runs; the stored ' + + 'catalog rows are migrated once by the cloud catalog, reading `LEGACY_CATEGORY_MAPPING`. Choosing ' + + 'business functions for a `manual` value is a judgement no mechanical edit can make.', + acceptanceCriteria: + 'No package source, publish request or listing writes `category`: a TypeScript author fails `tsc` ' + + '(each key is typed as the retired mark), a parse of PackageSchema, CreatePackageRequestSchema, ' + + 'UpdatePackageRequestSchema, TemplateManifestSchema, MarketplaceListingSchema or ' + + 'MarketplaceSearchRequestSchema refuses it with its prescription, and `os package publish` refuses a ' + + 'manifest or a flag carrying it before any network call, naming the facets for the value it held. ' + + 'Every shipped objectstack.manifest.json declares `businessFunctions` and `solutionType` instead and ' + + 'passes `check:template-manifests`. A `template` declares the unmanaged install mode and a ' + + '`connector` declares a connector, or publish refuses it.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 5600bdd93ca..9231642cf6c 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -2338,6 +2338,21 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'mechanical conversion strips the four keys from stored sources (pure lossless ' + 'deletes — none ever had an effect to lose).', }, + { + id: 'marketplace-category-retired', + order: 119, + text: + 'It also retires the single marketplace `category` on the package, the publish requests, ' + + '`objectstack.manifest.json`, the listing and the search request, with both of its defs ' + + '(`MarketplaceCategorySchema`, `PackageCategorySchema`): one value had to say both who a package ' + + 'is for and what it is, and the platform and the cloud catalog kept lists that disagreed. The ' + + 'marketplace taxonomy is now defined once, in `marketplace/taxonomy.zod.ts` — `businessFunctions` ' + + '(1-3, the primary one first), `industries` (up to 3, optional) and `solutionType` (`app`, ' + + '`template` or `connector`), with `maturity` (`beta` | `stable`) as a separate flag — and ' + + '`LEGACY_CATEGORY_MAPPING` says what every old value becomes. Each key is a retiredKey tombstone ' + + 'with no D2 conversion, because none of these shapes is a stack collection member; the D3 record ' + + 'is the semantic entry `marketplace-category-retired`.', + }, { id: 'memory-persistence-placeholder-refused', order: 1, diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index c720f01ad65..f8ba1e19a61 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -293,9 +293,11 @@ import type * as M199 from './api/push-relay.zod.js'; import type * as M200 from './automation/api-trigger-signature.zod.js'; // [ADR-0148] The recycle bin's spec half: the entry, its snapshot and the retention values -- new module, next free index. import type * as M201 from './data/recycle-bin.zod.js'; +// The marketplace taxonomy (business function, industry, solution type) and the maturity flag -- new module, next free index. +import type * as M202 from './marketplace/taxonomy.zod.js'; // --------------------------------------------------------------------------- -// 837 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 841 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1143,7 +1145,6 @@ export type Iso_kernel_startupOrchestrator__PluginStartupResultSchema = Assert, z.infer< typeof M50.ArtifactDownloadResponseSchema > >>; export type Iso_marketplace_marketplace__ListingStatusSchema = Assert, z.infer< typeof M50.ListingStatusSchema > >>; -export type Iso_marketplace_marketplace__MarketplaceCategorySchema = Assert, z.infer< typeof M50.MarketplaceCategorySchema > >>; export type Iso_marketplace_marketplace__MarketplaceInstallResponseSchema = Assert, z.infer< typeof M50.MarketplaceInstallResponseSchema > >>; export type Iso_marketplace_marketplace__PricingModelSchema = Assert, z.infer< typeof M50.PricingModelSchema > >>; export type Iso_marketplace_marketplace__PublisherVerificationSchema = Assert, z.infer< typeof M50.PublisherVerificationSchema > >>; @@ -1156,7 +1157,6 @@ export type Iso_marketplace_packageVersion__UpdatePackageVersionRequestSchema = // marketplace/package.zod.ts export type Iso_marketplace_package__CreatePackageRequestSchema = Assert, z.infer< typeof M52.CreatePackageRequestSchema > >>; -export type Iso_marketplace_package__PackageCategorySchema = Assert, z.infer< typeof M52.PackageCategorySchema > >>; export type Iso_marketplace_package__PackageLocaleSchema = Assert, z.infer< typeof M52.PackageLocaleSchema > >>; export type Iso_marketplace_package__PackagePublisherSchema = Assert, z.infer< typeof M52.PackagePublisherSchema > >>; export type Iso_marketplace_package__PackageTranslationSchema = Assert, z.infer< typeof M52.PackageTranslationSchema > >>; @@ -1164,6 +1164,14 @@ export type Iso_marketplace_package__PackageTranslationsSchema = Assert, z.infer< typeof M52.PackageVisibilitySchema > >>; export type Iso_marketplace_package__UpdatePackageRequestSchema = Assert, z.infer< typeof M52.UpdatePackageRequestSchema > >>; +// marketplace/taxonomy.zod.ts +export type Iso_marketplace_taxonomy__MarketplaceBusinessFunctionSchema = Assert, z.infer< typeof M202.MarketplaceBusinessFunctionSchema > >>; +export type Iso_marketplace_taxonomy__MarketplaceBusinessFunctionsSchema = Assert, z.infer< typeof M202.MarketplaceBusinessFunctionsSchema > >>; +export type Iso_marketplace_taxonomy__MarketplaceIndustriesSchema = Assert, z.infer< typeof M202.MarketplaceIndustriesSchema > >>; +export type Iso_marketplace_taxonomy__MarketplaceIndustrySchema = Assert, z.infer< typeof M202.MarketplaceIndustrySchema > >>; +export type Iso_marketplace_taxonomy__MarketplaceSolutionTypeSchema = Assert, z.infer< typeof M202.MarketplaceSolutionTypeSchema > >>; +export type Iso_marketplace_taxonomy__PackageMaturitySchema = Assert, z.infer< typeof M202.PackageMaturitySchema > >>; + // marketplace/template-manifest.zod.ts export type Iso_marketplace_templateManifest__TemplateManifestSchema = Assert, z.infer< typeof M53.TemplateManifestSchema > >>; @@ -1793,7 +1801,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 837 isomorphic pins', () => { + it('still declares all 841 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2818,7 +2826,15 @@ describe('ADR-0122 type-alias convention', () => { // the entry, its snapshot, a snapshot row, a cleared reference and the two // retention value schemas — plain objects, a number and a regex string, // no default and no transform (Iso_data_recycleBin__*). +6 added. - expect(pins).toHaveLength(837); + // 837 -> 841 is the marketplace taxonomy (`marketplace/taxonomy.zod.ts`): + // the business-function, industry, solution-type and maturity enums and the + // two facet lists — enums and arrays of enums under a refinement, which + // changes no type, no default and no transform + // (Iso_marketplace_taxonomy__*), +6 added; and the two category defs it + // retired, `MarketplaceCategorySchema` and `PackageCategorySchema`, left + // their modules whole (RETIRED_DEFS_BY_MAJOR[18]) and took their pins with + // them, -2 removed. + expect(pins).toHaveLength(841); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either