diff --git a/.changeset/18576-api-assembled-entry-split.md b/.changeset/18576-api-assembled-entry-split.md new file mode 100644 index 00000000000..78c5d6ed935 --- /dev/null +++ b/.changeset/18576-api-assembled-entry-split.md @@ -0,0 +1,40 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: split the assembled-stage package API declarations off `@objectstack/spec/api` into the new `@objectstack/spec/api-assembled` entry (#18576) + +**BREAKING** — five Package API declarations, with their types, are no longer exported from `@objectstack/spec/api`. They are exported, unchanged, from the new entry `@objectstack/spec/api-assembled`. + +A `major`-class change — an existing import path stops resolving for these names — recorded as `minor` under the launch-window convention. Maintainer ruling on #18576, batch #145 item 1, letter B, 「同意,其他也同意」. + +**Why.** These five declarations embed the ASSEMBLED package body, which reaches the whole metadata vocabulary and, behind it, the datasource declaration and the driver-config validators. While they were declared inside `@objectstack/spec/api`, that tree was part of every bundle of the entry, and the entry ships as one self-contained bundle that a consumer's tree-shaking can recover little of. A browser module that imports two string constants from `@objectstack/spec/api` paid for all of it. Measured on the splitting PR (esbuild 0.28.2, `platform: browser`, conditions `browser` + `import`, minified, gzip -9), for objectui's `@object-ui/core` `column-sortability.ts`, which imports only those two constants: **311,124 → 166,529 bytes gzipped (−46.5%)**. The `./api` entry bundle itself goes from 612,813 to 469,795 bytes gzipped, and its module graph no longer reaches `stack.zod`, the datasource declaration or any driver-config module. `./api` also no longer needs a `browser` export condition, since nothing in its graph links the server-only pg URL grammar any more; the condition moves to `./api-assembled`. + +### FROM → TO + +| removed from `@objectstack/spec/api` | import instead from | +| --- | --- | +| `AssembledInstalledPackageSchema`, `AssembledInstalledPackage`, `AssembledInstalledPackageParsed` | `@objectstack/spec/api-assembled` | +| `InstalledPackageAtEitherStageSchema`, `InstalledPackageAtEitherStage`, `InstalledPackageAtEitherStageParsed` | `@objectstack/spec/api-assembled` | +| `ListInstalledPackagesResponseSchema`, `ListInstalledPackagesResponse`, `ListInstalledPackagesResponseParsed` | `@objectstack/spec/api-assembled` | +| `GetInstalledPackageResponseSchema`, `GetInstalledPackageResponse`, `GetInstalledPackageResponseParsed` | `@objectstack/spec/api-assembled` | +| `PackageApiContracts` | `@objectstack/spec/api-assembled` | + +**The one-line fix: change the import path.** + +```ts +// before +import { ListInstalledPackagesResponseSchema } from '@objectstack/spec/api'; +// after +import { ListInstalledPackagesResponseSchema } from '@objectstack/spec/api-assembled'; +``` + +The compiler finds every site: `TS2305` ("Module '"@objectstack/spec/api"' has no exported member …"), or `TS2724` with a did-you-mean when a similarly named export exists — measured on the splitting PR, `ListInstalledPackagesResponseSchema` from `/api` answers `TS2724 … Did you mean 'InstallPackageResponseSchema'?`, which is NOT the name you want. Nothing else changes: every schema parses and refuses exactly what it did, `PackageApiContracts` keeps its four entries, and the JSON Schema ids are the same (`json-schema/api/AssembledInstalledPackage.json` and its three siblings are still published under `api/`, and still documented in the API reference). Every other Package API declaration — the two read doors' request schemas, the install / uninstall / upgrade / rollback shapes and `PackageApiErrorCode` — stays on `@objectstack/spec/api`. If you only use those, or any other `/api` contract, you need to do nothing. + +⚠️ **Out-of-repo consumers are NOT MEASURED beyond objectui.** Inside this repository the moved names had four importers (the client's type import, one runtime conformance test, the client's return-type pins and the spec's own unit test), all moved in the same PR. objectui at the pinned `.objectui-sha` imports none of the moved names from anywhere; its six browser-shipped files that import `@objectstack/spec/api` keep resolving every name they use, from `/api` itself. `@object-ui/types` re-exports `@objectstack/spec/api` as a type-only `API` namespace, which loses the moved names with this release; objectui itself references none of them through it. The `cloud` repository was not measured. + +The ADR-0087 D3 semantic entry `api-assembled-entry-split` carries the judgement: an import path is TypeScript source, not metadata, so there is no source a D2 conversion could rewrite. + +Clause-②: yes (narrowing) + + diff --git a/.changeset/18576-client-api-assembled-type-import.md b/.changeset/18576-client-api-assembled-type-import.md new file mode 100644 index 00000000000..77c5417dadd --- /dev/null +++ b/.changeset/18576-client-api-assembled-type-import.md @@ -0,0 +1,7 @@ +--- +'@objectstack/client': patch +--- + +fix(client): take `InstalledPackageAtEitherStage` from `@objectstack/spec/api-assembled` (#18576) + +`@objectstack/spec` moved the declarations that embed the assembled package body — `InstalledPackageAtEitherStage` among them — off `@objectstack/spec/api` into the new `@objectstack/spec/api-assembled` entry. The client's `packages.list` / `packages.get` return types (and their scoped twins) name that type, so the published declarations now import it from the new entry. The return types are the same type as before; nothing a caller writes changes. It is a type-only import, erased from the client's bundle. diff --git a/content/docs/deployment/troubleshooting.mdx b/content/docs/deployment/troubleshooting.mdx index 2055cfb0753..7f846d3f3c6 100644 --- a/content/docs/deployment/troubleshooting.mdx +++ b/content/docs/deployment/troubleshooting.mdx @@ -344,7 +344,7 @@ import { FieldSchema } from '@objectstack/spec/data'; import { ErrorResponseSchema } from '@objectstack/spec/api'; ``` -Available subpaths (the `./*` entries of the package's `exports` map, in its order): `data`, `system`, `kernel`, `ai`, `automation`, `api`, `ui`, `contracts`, `integration`, `security`, `studio`, `marketplace`, `qa`, `identity`, `shared`, `meta-spelling`. +Available subpaths (the `./*` entries of the package's `exports` map, in its order): `data`, `system`, `kernel`, `ai`, `automation`, `api`, `api-assembled`, `ui`, `contracts`, `integration`, `security`, `studio`, `marketplace`, `qa`, `identity`, `shared`, `meta-spelling`. --- diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 301a077eedb..45e7a4cc539 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -126,7 +126,7 @@ AI/ML capabilities - agents, skills, tools, MCP exposure, RAG, and cost tracking | **[Usage](/docs/references/ai/usage)** | `usage.zod.ts` | AIUsageRecord, TokenUsage | AI usage and cost tracking | | **[Solution Blueprint](/docs/references/ai/solution-blueprint)** | `solution-blueprint.zod.ts` | BlueprintObject, BlueprintApp | Blueprint format for AI app generation | -## API Protocol (17 of 31 schemas) +## API Protocol (17 of 32 schemas) REST endpoints, real-time subscriptions, and discovery. diff --git a/content/docs/references/api/index.mdx b/content/docs/references/api/index.mdx index a5a30772f05..6b4699c8169 100644 --- a/content/docs/references/api/index.mdx +++ b/content/docs/references/api/index.mdx @@ -27,6 +27,7 @@ This section contains all protocol schemas for the api layer of ObjectStack. + diff --git a/content/docs/references/api/meta.json b/content/docs/references/api/meta.json index 69b8a4dd37b..3a31ec00c46 100644 --- a/content/docs/references/api/meta.json +++ b/content/docs/references/api/meta.json @@ -34,6 +34,7 @@ "---More---", "error-code-ledger", "misc", + "package-api-assembled", "package-lifecycle", "sortability" ] diff --git a/content/docs/references/api/package-api-assembled.mdx b/content/docs/references/api/package-api-assembled.mdx new file mode 100644 index 00000000000..e0e7f45edfb --- /dev/null +++ b/content/docs/references/api/package-api-assembled.mdx @@ -0,0 +1,445 @@ +--- +title: Package Api Assembled +description: Package Api Assembled protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +The Package API declarations that carry the ASSEMBLED package body. + +Published from `@objectstack/spec/api-assembled`, never from +`@objectstack/spec/api`. Everything here is part of the Package API +(`/api/v1/packages`, `./package-api.zod.ts`); what sets these five apart is +that each one embeds the assembled package body, `RecordStagePackageBodySchema` +from `../stack.zod` — or, for the route map, names a schema that does: + +- `AssembledInstalledPackageSchema` — the installed row at the assembled stage; +- `InstalledPackageAtEitherStageSchema` — the union the read doors serve; +- `ListInstalledPackagesResponseSchema` / `GetInstalledPackageResponseSchema` + — the two read responses, bound to that union; +- `PackageApiContracts` — the route map, which names both read responses. + +## Why they have their own entry + +The assembled body is the WHOLE metadata vocabulary: `../stack.zod` reaches +every collection schema, the datasource declaration and, behind it, the +driver-config validators and the server-only pg URL grammar. Declared inside +`@objectstack/spec/api` (#17517), that tree became part of every bundle of the +entry — and a browser module that imported two string constants from +`./sortability.zod` paid for all of it, roughly doubling its gzipped bundle, +because the entry ships as one self-contained bundle and little of that tree +can be dropped by a consumer's tree-shaking. The maintainer ruling on #18576 +(letter B) removed the cost rather than watching it: the browser-facing +`./api` no longer carries these declarations, and this entry does. + +⛔ Their MEANING did not change with the move — same schemas, same refusals, +same JSON Schema ids (`api/...`, still published under `json-schema/api/`, +because they are API-protocol declarations; only the import path moved). + +⛔ Only a declaration that genuinely needs the assembled body belongs here. +Everything else in the Package API stays in `./package-api.zod.ts`, which +`@objectstack/spec/api` publishes; `./api-entry-graph.pin.test.ts` pins that +`./api` reaches neither `../stack.zod` nor the datasource declaration. + + +**Source:** `packages/spec/src/api/package-api-assembled.zod.ts` + + +## TypeScript Usage + +```typescript +import { AssembledInstalledPackageSchema, GetInstalledPackageResponseSchema, InstalledPackageAtEitherStageSchema, ListInstalledPackagesResponseSchema } from '@objectstack/spec/api-assembled'; +import type { AssembledInstalledPackage, GetInstalledPackageResponse, InstalledPackageAtEitherStage, ListInstalledPackagesResponse } from '@objectstack/spec/api-assembled'; + +// Validate data +const result = AssembledInstalledPackageSchema.parse(data); +``` + +--- + +## AssembledInstalledPackage + +Installed package row whose manifest is the assembled package body + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | The ASSEMBLED package body this row carries, at the stage the registry records it | +| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | +| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | +| **installedAt** | `string` | optional | Installation timestamp | +| **updatedAt** | `string` | optional | Last update timestamp | +| **installedVersion** | `string` | optional | Currently installed version for quick access | +| **previousVersion** | `string` | optional | Version before the last upgrade | +| **statusChangedAt** | `string` | optional | Status change timestamp | +| **errorMessage** | `string` | optional | Error message when status is error | +| **settings** | `Record` | optional | User-provided configuration settings | +| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | +| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | + +### Nested Shape: `AssembledInstalledPackage.manifest` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | +| **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | +| **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | +| **version** | `string` | ✅ | Package version (semantic versioning) | +| **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | +| **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **name** | `string` | ✅ | Human-readable package name | +| **description** | `string` | optional | Package description | +| **permissions** | `{ name: string; label?: string; description?: string; packageId?: string; … }[]` | optional | Permission Sets — the ADR-0090 collection half of `permissions`; at the manifest/authoring stage the same key is the ADR-0025 capability grant instead (`ManifestSchema.permissions`) | +| **objects** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }[]` | optional | Business Objects definition (owned by this package) | +| **datasources** | `{ name: string; label?: string; driver: string; config: Record; … }[]` | optional | External Data Connections | +| **dependencies** | `Record` | optional | Package dependencies | +| **configuration** | `never` | optional | [REMOVED] `manifest.configuration` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: no settings UI rendered it and no loader resolved a setting from it, so authoring it configured nothing. Worse, `properties.*.secret` promised "value is encrypted/masked (e.g. API Keys)" while nothing encrypted, masked or even parsed the flag — a false assurance about credential handling. Delete the key. A plugin is configured by the host that composes it: pass options to its constructor in `defineStack({ plugins: [new MyPlugin({ … })] })`, which is the enforced channel. A declarative settings surface must be designed with an enforcing reader first, not revived here. | +| **contributes** | `{ kinds?: object[] }` | optional | Platform contributions | +| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Seed Data / Fixtures for bootstrapping | +| **capabilities** | `{ name: string; label?: string; description?: string; scope?: Enum<'platform' \| 'org'>; … }[]` | optional | [ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance) | +| **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | +| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | +| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | +| **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | +| **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | +| **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | +| **functions** | `Record }> \| { name: string; handler?: string; packageId?: string; effect?: Enum<'pure' \| 'writes'> }[]` | optional | Named handler functions, lowered to the refs a JSON document carries | +| **datasourceMapping** | `{ namespace?: string; package?: string; objectPattern?: string; default?: boolean; … }[]` | optional | Centralized datasource routing rules for packages/namespaces/objects | +| **translations** | `Record; apps?: Record; messages?: Record; globalActions?: Record; … }>[]` | optional | I18n Translation Bundles | +| **objectExtensions** | `{ extend: string; fields?: Record; label?: string; pluralLabel?: string; … }[]` | optional | Extensions to objects owned by other packages | +| **apps** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }[]` | optional | Applications | +| **views** | `{ name?: string; label?: string \| Record; object?: string; list?: object; … }[]` | optional | List Views | +| **viewItems** | `never` | optional | [MACHINE-ASSEMBLED] Non-container view artifacts of a runtime-assembled manifest (standalone ViewItems, flattened overlays) — written by package export and artifact factories, refused in authored stack sources. | +| **pages** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }[]` | optional | Custom Pages | +| **dashboards** | `{ name: string; label: string \| Record; description?: string \| Record; header?: object; … }[]` | optional | Dashboards | +| **reports** | `{ name: string; label: string \| Record; description?: string \| Record; type?: Enum<'tabular' \| 'summary' \| 'matrix' \| 'joined'>; … }[]` | optional | Analytics Reports | +| **datasets** | `{ name: string; label: string \| Record; description?: string \| Record; object: string; … }[]` | optional | Analytics semantic-layer datasets (ADR-0021) | +| **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Global and Object Actions. Unique per scope, not per stack: the runtime keys every action by its owning object's name (or 'global' when object-less), a colon, then the action name, and defineStack refuses two declarations that resolve to one key — both here, both on one object's actions, or one in each position, identical twins included (an embedded action is keyed by the object it is written on, not by its own objectName). One global and one object-bound action may share a name; on that object's route the object's own actions take precedence for by-name readers. composeStacks runs the same key rule across its input stacks (counting distinct stacks, not sites) and names both source stacks on a collision. | +| **flows** | `{ name: string; label: string; description?: string; successMessage?: string; … }[]` | optional | Screen Flows | +| **jobs** | `{ name: string; label?: string; description?: string; schedule: object \| object \| object; … }[]` | optional | Background / Scheduled Jobs (run by IJobService on cron/interval/once schedules) | +| **emailTemplates** | `{ name: string; label: string; category?: Enum<'auth' \| 'notification' \| 'workflow' \| 'marketing' \| 'custom'>; locale?: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | +| **docs** | `{ name: string; label?: string; description?: string; content: string; … }[]` | optional | Package documentation — flat Markdown items compiled from src/docs/*.md (ADR-0046) | +| **books** | `{ name: string; label?: string; description?: string; slug?: string; … }[]` | optional | Documentation navigation spines — ordered groups with derived membership (ADR-0046 §6) | +| **positions** | `{ name: string; label: string; description?: string; delegatable?: boolean; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | +| **sharingRules** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Record Sharing Rules | +| **apis** | `{ name: string; path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; summary?: string; … }[]` | optional | API Endpoints — declared endpoints are live from protocol 17; each is gated at publish (ADR-0121) | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Outbound Webhooks | +| **agents** | `{ name: string; label: string; avatar?: string; role: string; … }[]` | optional | AI Agents — platform-internal (ADR-0063 §2): the kernel ships exactly two (ask/build); third parties extend via skills, not agents | +| **tools** | `{ name: string; label: string; description: string; parameters: Record; … }[]` | optional | AI Tool metadata records — optional refinement layer, never required: the default path is skills referencing platform tools or materialised action_`` tools (ADR-0109) | +| **skills** | `{ name: string; label: string; description?: string; surface?: Enum<'ask' \| 'build' \| 'both'>; … }[]` | optional | AI Skills (reusable capability bundles — the third-party AI extension primitive, ADR-0063) | +| **hooks** | `{ name: string; label?: string; object: string \| string[]; events: Enum<'beforeFind' \| 'afterFind' \| 'beforeInsert' \| 'afterInsert' \| 'beforeUpdate' \| …>[]; … }[]` | optional | Object Lifecycle Hooks, as a JSON document carries them | +| **mappings** | `{ name: string; label?: string; sourceFormat?: Enum<'csv' \| 'json' \| 'xml' \| 'sql'>; targetObject: string; … }[]` | optional | Data Import/Export Mappings | +| **analyticsCubes** | `{ name: string; title?: string; description?: string; sql: string; … }[]` | optional | Analytics Semantic Layer Cubes | +| **connectors** | `{ name: string; label: string; type: Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>; description?: string; … }[]` | optional | External System Connectors. A provider-bound entry (has `provider`: openapi/mcp/rest) is materialized into a live, dispatchable connector at boot and referenced by flows via `connector_action`; credentials are `auth.credentialRef` references, never inline secrets. An entry with no `provider` is a catalog descriptor only (NOT dispatchable) — set `enabled: false` on deliberate descriptors. Unknown provider / unresolvable credentialRef / name conflict ⇒ hard boot error (ADR-0097). | +| **requires** | `string[]` | optional | Capability names this stack requires from the platform (canonical kebab-case tokens from PLATFORM_CAPABILITY_TOKENS; an unknown token is a defineStack error, declared-but-missing ⇒ fail-fast at startup) | +| **tiers** | `string[]` | optional | Plugin tier presets to enable; overrides --preset | + +### Nested Shape: `AssembledInstalledPackage.upgradeHistory[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **fromVersion** | `string` | ✅ | Version before upgrade | +| **toVersion** | `string` | ✅ | Version after upgrade | +| **upgradedAt** | `string` | ✅ | Upgrade timestamp | +| **status** | `Enum<'success' \| 'failed' \| 'rolled_back'>` | ✅ | Upgrade outcome | +| **migrationLog** | `string[]` | optional | Migration step logs | + + +--- + +## GetInstalledPackageResponse + +Get installed package response + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **success** | `boolean` | ✅ | Operation success status | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | +| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | +| **data** | `{ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … } \| … +1 more` | ✅ | Installed package details | + +### Nested Shape: `GetInstalledPackageResponse.error` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | +| **message** | `string` | ✅ | Readable error message | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | +| **refusal** | `true` | optional | Producer-declared: the 5xx this envelope carries is a deliberate refusal whose `message` is authored for the caller, so a boundary that reads the declaration keeps it verbatim (until the withhold arms read it, a declared refusal is still withheld). Absent (the default) on a declared fault, whose `message` is withheld from the body and logged for the operator; redundant on a 4xx. Presence is the declaration — `true` is the only value. | +| **category** | `string` | optional | Error category (e.g. validation, authorization) | +| **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | +| **details** | `any` | optional | Additional error context (e.g. field validation errors) | +| **requestId** | `string` | optional | Request ID for tracking | + +### Nested Shape: `GetInstalledPackageResponse.meta` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **timestamp** | `string` | ✅ | | +| **duration** | `integer` | optional | Server-side processing duration in milliseconds | +| **requestId** | `string` | optional | | +| **traceId** | `string` | optional | | + +### Nested Shape: `GetInstalledPackageResponse.data[option 1]` + +Installed package with runtime lifecycle state + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | +| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | +| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | +| **installedAt** | `string` | optional | Installation timestamp | +| **updatedAt** | `string` | optional | Last update timestamp | +| **installedVersion** | `string` | optional | Currently installed version for quick access | +| **previousVersion** | `string` | optional | Version before the last upgrade | +| **statusChangedAt** | `string` | optional | Status change timestamp | +| **errorMessage** | `string` | optional | Error message when status is error | +| **settings** | `Record` | optional | User-provided configuration settings | +| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | +| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | + +### Nested Shape: `GetInstalledPackageResponse.data[option 2]` + +Installed package row whose manifest is the assembled package body + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | The ASSEMBLED package body this row carries, at the stage the registry records it | +| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | +| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | +| **installedAt** | `string` | optional | Installation timestamp | +| **updatedAt** | `string` | optional | Last update timestamp | +| **installedVersion** | `string` | optional | Currently installed version for quick access | +| **previousVersion** | `string` | optional | Version before the last upgrade | +| **statusChangedAt** | `string` | optional | Status change timestamp | +| **errorMessage** | `string` | optional | Error message when status is error | +| **settings** | `Record` | optional | User-provided configuration settings | +| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | +| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | + + +--- + +## InstalledPackageAtEitherStage + +Installed package row at whichever manifest stage it was installed at + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +Installed package with runtime lifecycle state + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | +| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | +| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | +| **installedAt** | `string` | optional | Installation timestamp | +| **updatedAt** | `string` | optional | Last update timestamp | +| **installedVersion** | `string` | optional | Currently installed version for quick access | +| **previousVersion** | `string` | optional | Version before the last upgrade | +| **statusChangedAt** | `string` | optional | Status change timestamp | +| **errorMessage** | `string` | optional | Error message when status is error | +| **settings** | `Record` | optional | User-provided configuration settings | +| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | +| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | + +### Nested Shape: `InstalledPackageAtEitherStage[option 1].manifest` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | +| **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | +| **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | +| **version** | `string` | ✅ | Package version (semantic versioning) | +| **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | +| **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **name** | `string` | ✅ | Human-readable package name | +| **description** | `string` | optional | Package description | +| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | +| **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files | +| **datasources** | `string[]` | optional | Glob patterns for Datasource definitions | +| **dependencies** | `Record` | optional | Package dependencies | +| **configuration** | `never` | optional | [REMOVED] `manifest.configuration` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: no settings UI rendered it and no loader resolved a setting from it, so authoring it configured nothing. Worse, `properties.*.secret` promised "value is encrypted/masked (e.g. API Keys)" while nothing encrypted, masked or even parsed the flag — a false assurance about credential handling. Delete the key. A plugin is configured by the host that composes it: pass options to its constructor in `defineStack({ plugins: [new MyPlugin({ … })] })`, which is the enforced channel. A declarative settings surface must be designed with an enforcing reader first, not revived here. | +| **contributes** | `{ kinds?: object[] }` | optional | Platform contributions | +| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Initial seed data (prefer top-level data field) | +| **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | +| **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | +| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | +| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | +| **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | +| **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | +| **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | + +### Nested Shape: `InstalledPackageAtEitherStage[option 1].upgradeHistory[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **fromVersion** | `string` | ✅ | Version before upgrade | +| **toVersion** | `string` | ✅ | Version after upgrade | +| **upgradedAt** | `string` | ✅ | Upgrade timestamp | +| **status** | `Enum<'success' \| 'failed' \| 'rolled_back'>` | ✅ | Upgrade outcome | +| **migrationLog** | `string[]` | optional | Migration step logs | + +--- + +#### Option 2 + +Installed package row whose manifest is the assembled package body + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | The ASSEMBLED package body this row carries, at the stage the registry records it | +| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | +| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | +| **installedAt** | `string` | optional | Installation timestamp | +| **updatedAt** | `string` | optional | Last update timestamp | +| **installedVersion** | `string` | optional | Currently installed version for quick access | +| **previousVersion** | `string` | optional | Version before the last upgrade | +| **statusChangedAt** | `string` | optional | Status change timestamp | +| **errorMessage** | `string` | optional | Error message when status is error | +| **settings** | `Record` | optional | User-provided configuration settings | +| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | +| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | + +### Nested Shape: `InstalledPackageAtEitherStage[option 2].manifest` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | +| **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | +| **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | +| **version** | `string` | ✅ | Package version (semantic versioning) | +| **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | +| **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **name** | `string` | ✅ | Human-readable package name | +| **description** | `string` | optional | Package description | +| **permissions** | `{ name: string; label?: string; description?: string; packageId?: string; … }[]` | optional | Permission Sets — the ADR-0090 collection half of `permissions`; at the manifest/authoring stage the same key is the ADR-0025 capability grant instead (`ManifestSchema.permissions`) | +| **objects** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }[]` | optional | Business Objects definition (owned by this package) | +| **datasources** | `{ name: string; label?: string; driver: string; config: Record; … }[]` | optional | External Data Connections | +| **dependencies** | `Record` | optional | Package dependencies | +| **configuration** | `never` | optional | [REMOVED] `manifest.configuration` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: no settings UI rendered it and no loader resolved a setting from it, so authoring it configured nothing. Worse, `properties.*.secret` promised "value is encrypted/masked (e.g. API Keys)" while nothing encrypted, masked or even parsed the flag — a false assurance about credential handling. Delete the key. A plugin is configured by the host that composes it: pass options to its constructor in `defineStack({ plugins: [new MyPlugin({ … })] })`, which is the enforced channel. A declarative settings surface must be designed with an enforcing reader first, not revived here. | +| **contributes** | `{ kinds?: object[] }` | optional | Platform contributions | +| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Seed Data / Fixtures for bootstrapping | +| **capabilities** | `{ name: string; label?: string; description?: string; scope?: Enum<'platform' \| 'org'>; … }[]` | optional | [ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance) | +| **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | +| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | +| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | +| **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | +| **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | +| **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | +| **functions** | `Record }> \| { name: string; handler?: string; packageId?: string; effect?: Enum<'pure' \| 'writes'> }[]` | optional | Named handler functions, lowered to the refs a JSON document carries | +| **datasourceMapping** | `{ namespace?: string; package?: string; objectPattern?: string; default?: boolean; … }[]` | optional | Centralized datasource routing rules for packages/namespaces/objects | +| **translations** | `Record; apps?: Record; messages?: Record; globalActions?: Record; … }>[]` | optional | I18n Translation Bundles | +| **objectExtensions** | `{ extend: string; fields?: Record; label?: string; pluralLabel?: string; … }[]` | optional | Extensions to objects owned by other packages | +| **apps** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }[]` | optional | Applications | +| **views** | `{ name?: string; label?: string \| Record; object?: string; list?: object; … }[]` | optional | List Views | +| **viewItems** | `never` | optional | [MACHINE-ASSEMBLED] Non-container view artifacts of a runtime-assembled manifest (standalone ViewItems, flattened overlays) — written by package export and artifact factories, refused in authored stack sources. | +| **pages** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }[]` | optional | Custom Pages | +| **dashboards** | `{ name: string; label: string \| Record; description?: string \| Record; header?: object; … }[]` | optional | Dashboards | +| **reports** | `{ name: string; label: string \| Record; description?: string \| Record; type?: Enum<'tabular' \| 'summary' \| 'matrix' \| 'joined'>; … }[]` | optional | Analytics Reports | +| **datasets** | `{ name: string; label: string \| Record; description?: string \| Record; object: string; … }[]` | optional | Analytics semantic-layer datasets (ADR-0021) | +| **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Global and Object Actions. Unique per scope, not per stack: the runtime keys every action by its owning object's name (or 'global' when object-less), a colon, then the action name, and defineStack refuses two declarations that resolve to one key — both here, both on one object's actions, or one in each position, identical twins included (an embedded action is keyed by the object it is written on, not by its own objectName). One global and one object-bound action may share a name; on that object's route the object's own actions take precedence for by-name readers. composeStacks runs the same key rule across its input stacks (counting distinct stacks, not sites) and names both source stacks on a collision. | +| **flows** | `{ name: string; label: string; description?: string; successMessage?: string; … }[]` | optional | Screen Flows | +| **jobs** | `{ name: string; label?: string; description?: string; schedule: object \| object \| object; … }[]` | optional | Background / Scheduled Jobs (run by IJobService on cron/interval/once schedules) | +| **emailTemplates** | `{ name: string; label: string; category?: Enum<'auth' \| 'notification' \| 'workflow' \| 'marketing' \| 'custom'>; locale?: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | +| **docs** | `{ name: string; label?: string; description?: string; content: string; … }[]` | optional | Package documentation — flat Markdown items compiled from src/docs/*.md (ADR-0046) | +| **books** | `{ name: string; label?: string; description?: string; slug?: string; … }[]` | optional | Documentation navigation spines — ordered groups with derived membership (ADR-0046 §6) | +| **positions** | `{ name: string; label: string; description?: string; delegatable?: boolean; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | +| **sharingRules** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Record Sharing Rules | +| **apis** | `{ name: string; path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; summary?: string; … }[]` | optional | API Endpoints — declared endpoints are live from protocol 17; each is gated at publish (ADR-0121) | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Outbound Webhooks | +| **agents** | `{ name: string; label: string; avatar?: string; role: string; … }[]` | optional | AI Agents — platform-internal (ADR-0063 §2): the kernel ships exactly two (ask/build); third parties extend via skills, not agents | +| **tools** | `{ name: string; label: string; description: string; parameters: Record; … }[]` | optional | AI Tool metadata records — optional refinement layer, never required: the default path is skills referencing platform tools or materialised action_`` tools (ADR-0109) | +| **skills** | `{ name: string; label: string; description?: string; surface?: Enum<'ask' \| 'build' \| 'both'>; … }[]` | optional | AI Skills (reusable capability bundles — the third-party AI extension primitive, ADR-0063) | +| **hooks** | `{ name: string; label?: string; object: string \| string[]; events: Enum<'beforeFind' \| 'afterFind' \| 'beforeInsert' \| 'afterInsert' \| 'beforeUpdate' \| …>[]; … }[]` | optional | Object Lifecycle Hooks, as a JSON document carries them | +| **mappings** | `{ name: string; label?: string; sourceFormat?: Enum<'csv' \| 'json' \| 'xml' \| 'sql'>; targetObject: string; … }[]` | optional | Data Import/Export Mappings | +| **analyticsCubes** | `{ name: string; title?: string; description?: string; sql: string; … }[]` | optional | Analytics Semantic Layer Cubes | +| **connectors** | `{ name: string; label: string; type: Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>; description?: string; … }[]` | optional | External System Connectors. A provider-bound entry (has `provider`: openapi/mcp/rest) is materialized into a live, dispatchable connector at boot and referenced by flows via `connector_action`; credentials are `auth.credentialRef` references, never inline secrets. An entry with no `provider` is a catalog descriptor only (NOT dispatchable) — set `enabled: false` on deliberate descriptors. Unknown provider / unresolvable credentialRef / name conflict ⇒ hard boot error (ADR-0097). | +| **requires** | `string[]` | optional | Capability names this stack requires from the platform (canonical kebab-case tokens from PLATFORM_CAPABILITY_TOKENS; an unknown token is a defineStack error, declared-but-missing ⇒ fail-fast at startup) | +| **tiers** | `string[]` | optional | Plugin tier presets to enable; overrides --preset | + +### Nested Shape: `InstalledPackageAtEitherStage[option 2].upgradeHistory[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **fromVersion** | `string` | ✅ | Version before upgrade | +| **toVersion** | `string` | ✅ | Version after upgrade | +| **upgradedAt** | `string` | ✅ | Upgrade timestamp | +| **status** | `Enum<'success' \| 'failed' \| 'rolled_back'>` | ✅ | Upgrade outcome | +| **migrationLog** | `string[]` | optional | Migration step logs | + +--- + + +--- + +## ListInstalledPackagesResponse + +List installed packages response + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **success** | `boolean` | ✅ | Operation success status | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | +| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | +| **data** | `{ packages: (object \| object)[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | + +### Nested Shape: `ListInstalledPackagesResponse.error` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | +| **message** | `string` | ✅ | Readable error message | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | +| **refusal** | `true` | optional | Producer-declared: the 5xx this envelope carries is a deliberate refusal whose `message` is authored for the caller, so a boundary that reads the declaration keeps it verbatim (until the withhold arms read it, a declared refusal is still withheld). Absent (the default) on a declared fault, whose `message` is withheld from the body and logged for the operator; redundant on a 4xx. Presence is the declaration — `true` is the only value. | +| **category** | `string` | optional | Error category (e.g. validation, authorization) | +| **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | +| **details** | `any` | optional | Additional error context (e.g. field validation errors) | +| **requestId** | `string` | optional | Request ID for tracking | + +### Nested Shape: `ListInstalledPackagesResponse.meta` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **timestamp** | `string` | ✅ | | +| **duration** | `integer` | optional | Server-side processing duration in milliseconds | +| **requestId** | `string` | optional | | +| **traceId** | `string` | optional | | + +### Nested Shape: `ListInstalledPackagesResponse.data` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **packages** | `({ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … } \| … +1 more)[]` | ✅ | Installed packages | +| **total** | `integer` | optional | Total matching packages | +| **nextCursor** | `string` | optional | Cursor for the next page | +| **hasMore** | `boolean` | ✅ | Whether more packages are available — this door serves one page, so always `false` | + + +--- + diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index 8b0b36df91a..704849141a0 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -20,6 +20,30 @@ POST /api/v1/packages/:packageId/rollback — Rollback a package DELETE /api/v1/packages/:packageId — Uninstall a package ``` +### Five declarations of this API live one file over, on purpose + +The two READ responses (`ListInstalledPackagesResponseSchema`, +`GetInstalledPackageResponseSchema`), the installed-row stages they are bound +to (`AssembledInstalledPackageSchema`, `InstalledPackageAtEitherStageSchema`) +and the `PackageApiContracts` map that names both responses are declared in +`./package-api-assembled.zod.ts` and published from +`@objectstack/spec/api-assembled`, not from `@objectstack/spec/api`. + +The reason is weight, not meaning. Four of them carry the ASSEMBLED package +body (the fifth, the route map, names two of those four), which is the whole +metadata vocabulary (`../stack.zod`) plus the datasource and driver-config +validators behind it. While they sat in this +file, every `@objectstack/spec/api` bundle linked that tree, and a browser +consumer that imported two string constants from `./sortability.zod` paid +for all of it: measured at about twice the gzipped bundle of the same import +before the stage declarations arrived. The maintainer ruling on #18576 +(letter B) split the entry so the browser-facing half does not carry them. + +⛔ Nothing in this file may import `../stack.zod` or anything that reaches +`../data/datasource.zod`: that edge is exactly what the split removed from +`@objectstack/spec/api`, and `./api-entry-graph.pin.test.ts` refuses it. +A declaration that needs the assembled body goes in the sibling file. + **Source:** `packages/spec/src/api/package-api.zod.ts` @@ -27,107 +51,13 @@ DELETE /api/v1/packages/:packageId — Uninstall a package ## TypeScript Usage ```typescript -import { AssembledInstalledPackageSchema, GetInstalledPackageRequestSchema, GetInstalledPackageResponseSchema, InstalledPackageAtEitherStageSchema, ListInstalledPackagesRequestSchema, ListInstalledPackagesResponseSchema, PackageApiErrorCode, PackageInstallBodySchema, PackageInstallRequestSchema, PackageInstallResponseSchema, PackagePathParamsSchema, PackageRollbackRequestSchema, PackageUpgradeRequestSchema, PackageUpgradeResponseSchema, ResolveDependenciesRequestSchema, ResolveDependenciesResponseSchema, UninstallPackageApiRequestSchema, UninstallPackageApiResponseSchema, UploadArtifactRequestSchema, UploadArtifactResponseSchema } from '@objectstack/spec/api'; -import type { AssembledInstalledPackage, GetInstalledPackageRequest, GetInstalledPackageResponse, InstalledPackageAtEitherStage, ListInstalledPackagesRequest, ListInstalledPackagesResponse, PackageApiErrorCode, PackageInstallBody, PackageInstallRequest, PackageInstallResponse, PackagePathParams, PackageRollbackRequest, PackageUpgradeRequest, PackageUpgradeResponse, ResolveDependenciesRequest, ResolveDependenciesResponse, UninstallPackageApiRequest, UninstallPackageApiResponse, UploadArtifactRequest, UploadArtifactResponse } from '@objectstack/spec/api'; +import { GetInstalledPackageRequestSchema, ListInstalledPackagesRequestSchema, PackageApiErrorCode, PackageInstallBodySchema, PackageInstallRequestSchema, PackageInstallResponseSchema, PackagePathParamsSchema, PackageRollbackRequestSchema, PackageUpgradeRequestSchema, PackageUpgradeResponseSchema, ResolveDependenciesRequestSchema, ResolveDependenciesResponseSchema, UninstallPackageApiRequestSchema, UninstallPackageApiResponseSchema, UploadArtifactRequestSchema, UploadArtifactResponseSchema } from '@objectstack/spec/api'; +import type { GetInstalledPackageRequest, ListInstalledPackagesRequest, PackageApiErrorCode, PackageInstallBody, PackageInstallRequest, PackageInstallResponse, PackagePathParams, PackageRollbackRequest, PackageUpgradeRequest, PackageUpgradeResponse, ResolveDependenciesRequest, ResolveDependenciesResponse, UninstallPackageApiRequest, UninstallPackageApiResponse, UploadArtifactRequest, UploadArtifactResponse } from '@objectstack/spec/api'; // Validate data -const result = AssembledInstalledPackageSchema.parse(data); +const result = GetInstalledPackageRequestSchema.parse(data); ``` ---- - -## AssembledInstalledPackage - -Installed package row whose manifest is the assembled package body - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | The ASSEMBLED package body this row carries, at the stage the registry records it | -| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | -| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | -| **installedAt** | `string` | optional | Installation timestamp | -| **updatedAt** | `string` | optional | Last update timestamp | -| **installedVersion** | `string` | optional | Currently installed version for quick access | -| **previousVersion** | `string` | optional | Version before the last upgrade | -| **statusChangedAt** | `string` | optional | Status change timestamp | -| **errorMessage** | `string` | optional | Error message when status is error | -| **settings** | `Record` | optional | User-provided configuration settings | -| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | -| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | - -### Nested Shape: `AssembledInstalledPackage.manifest` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | -| **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | -| **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | -| **version** | `string` | ✅ | Package version (semantic versioning) | -| **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | -| **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | -| **name** | `string` | ✅ | Human-readable package name | -| **description** | `string` | optional | Package description | -| **permissions** | `{ name: string; label?: string; description?: string; packageId?: string; … }[]` | optional | Permission Sets — the ADR-0090 collection half of `permissions`; at the manifest/authoring stage the same key is the ADR-0025 capability grant instead (`ManifestSchema.permissions`) | -| **objects** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }[]` | optional | Business Objects definition (owned by this package) | -| **datasources** | `{ name: string; label?: string; driver: string; config: Record; … }[]` | optional | External Data Connections | -| **dependencies** | `Record` | optional | Package dependencies | -| **configuration** | `never` | optional | [REMOVED] `manifest.configuration` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: no settings UI rendered it and no loader resolved a setting from it, so authoring it configured nothing. Worse, `properties.*.secret` promised "value is encrypted/masked (e.g. API Keys)" while nothing encrypted, masked or even parsed the flag — a false assurance about credential handling. Delete the key. A plugin is configured by the host that composes it: pass options to its constructor in `defineStack({ plugins: [new MyPlugin({ … })] })`, which is the enforced channel. A declarative settings surface must be designed with an enforcing reader first, not revived here. | -| **contributes** | `{ kinds?: object[] }` | optional | Platform contributions | -| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Seed Data / Fixtures for bootstrapping | -| **capabilities** | `{ name: string; label?: string; description?: string; scope?: Enum<'platform' \| 'org'>; … }[]` | optional | [ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance) | -| **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | -| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | -| **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | -| **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | -| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | -| **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | -| **functions** | `Record }> \| { name: string; handler?: string; packageId?: string; effect?: Enum<'pure' \| 'writes'> }[]` | optional | Named handler functions, lowered to the refs a JSON document carries | -| **datasourceMapping** | `{ namespace?: string; package?: string; objectPattern?: string; default?: boolean; … }[]` | optional | Centralized datasource routing rules for packages/namespaces/objects | -| **translations** | `Record; apps?: Record; messages?: Record; globalActions?: Record; … }>[]` | optional | I18n Translation Bundles | -| **objectExtensions** | `{ extend: string; fields?: Record; label?: string; pluralLabel?: string; … }[]` | optional | Extensions to objects owned by other packages | -| **apps** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }[]` | optional | Applications | -| **views** | `{ name?: string; label?: string \| Record; object?: string; list?: object; … }[]` | optional | List Views | -| **viewItems** | `never` | optional | [MACHINE-ASSEMBLED] Non-container view artifacts of a runtime-assembled manifest (standalone ViewItems, flattened overlays) — written by package export and artifact factories, refused in authored stack sources. | -| **pages** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }[]` | optional | Custom Pages | -| **dashboards** | `{ name: string; label: string \| Record; description?: string \| Record; header?: object; … }[]` | optional | Dashboards | -| **reports** | `{ name: string; label: string \| Record; description?: string \| Record; type?: Enum<'tabular' \| 'summary' \| 'matrix' \| 'joined'>; … }[]` | optional | Analytics Reports | -| **datasets** | `{ name: string; label: string \| Record; description?: string \| Record; object: string; … }[]` | optional | Analytics semantic-layer datasets (ADR-0021) | -| **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Global and Object Actions. Unique per scope, not per stack: the runtime keys every action by its owning object's name (or 'global' when object-less), a colon, then the action name, and defineStack refuses two declarations that resolve to one key — both here, both on one object's actions, or one in each position, identical twins included (an embedded action is keyed by the object it is written on, not by its own objectName). One global and one object-bound action may share a name; on that object's route the object's own actions take precedence for by-name readers. composeStacks runs the same key rule across its input stacks (counting distinct stacks, not sites) and names both source stacks on a collision. | -| **flows** | `{ name: string; label: string; description?: string; successMessage?: string; … }[]` | optional | Screen Flows | -| **jobs** | `{ name: string; label?: string; description?: string; schedule: object \| object \| object; … }[]` | optional | Background / Scheduled Jobs (run by IJobService on cron/interval/once schedules) | -| **emailTemplates** | `{ name: string; label: string; category?: Enum<'auth' \| 'notification' \| 'workflow' \| 'marketing' \| 'custom'>; locale?: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | -| **docs** | `{ name: string; label?: string; description?: string; content: string; … }[]` | optional | Package documentation — flat Markdown items compiled from src/docs/*.md (ADR-0046) | -| **books** | `{ name: string; label?: string; description?: string; slug?: string; … }[]` | optional | Documentation navigation spines — ordered groups with derived membership (ADR-0046 §6) | -| **positions** | `{ name: string; label: string; description?: string; delegatable?: boolean; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | -| **sharingRules** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Record Sharing Rules | -| **apis** | `{ name: string; path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; summary?: string; … }[]` | optional | API Endpoints — declared endpoints are live from protocol 17; each is gated at publish (ADR-0121) | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Outbound Webhooks | -| **agents** | `{ name: string; label: string; avatar?: string; role: string; … }[]` | optional | AI Agents — platform-internal (ADR-0063 §2): the kernel ships exactly two (ask/build); third parties extend via skills, not agents | -| **tools** | `{ name: string; label: string; description: string; parameters: Record; … }[]` | optional | AI Tool metadata records — optional refinement layer, never required: the default path is skills referencing platform tools or materialised action_`` tools (ADR-0109) | -| **skills** | `{ name: string; label: string; description?: string; surface?: Enum<'ask' \| 'build' \| 'both'>; … }[]` | optional | AI Skills (reusable capability bundles — the third-party AI extension primitive, ADR-0063) | -| **hooks** | `{ name: string; label?: string; object: string \| string[]; events: Enum<'beforeFind' \| 'afterFind' \| 'beforeInsert' \| 'afterInsert' \| 'beforeUpdate' \| …>[]; … }[]` | optional | Object Lifecycle Hooks, as a JSON document carries them | -| **mappings** | `{ name: string; label?: string; sourceFormat?: Enum<'csv' \| 'json' \| 'xml' \| 'sql'>; targetObject: string; … }[]` | optional | Data Import/Export Mappings | -| **analyticsCubes** | `{ name: string; title?: string; description?: string; sql: string; … }[]` | optional | Analytics Semantic Layer Cubes | -| **connectors** | `{ name: string; label: string; type: Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>; description?: string; … }[]` | optional | External System Connectors. A provider-bound entry (has `provider`: openapi/mcp/rest) is materialized into a live, dispatchable connector at boot and referenced by flows via `connector_action`; credentials are `auth.credentialRef` references, never inline secrets. An entry with no `provider` is a catalog descriptor only (NOT dispatchable) — set `enabled: false` on deliberate descriptors. Unknown provider / unresolvable credentialRef / name conflict ⇒ hard boot error (ADR-0097). | -| **requires** | `string[]` | optional | Capability names this stack requires from the platform (canonical kebab-case tokens from PLATFORM_CAPABILITY_TOKENS; an unknown token is a defineStack error, declared-but-missing ⇒ fail-fast at startup) | -| **tiers** | `string[]` | optional | Plugin tier presets to enable; overrides --preset | - -### Nested Shape: `AssembledInstalledPackage.upgradeHistory[number]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **fromVersion** | `string` | ✅ | Version before upgrade | -| **toVersion** | `string` | ✅ | Version after upgrade | -| **upgradedAt** | `string` | ✅ | Upgrade timestamp | -| **status** | `Enum<'success' \| 'failed' \| 'rolled_back'>` | ✅ | Upgrade outcome | -| **migrationLog** | `string[]` | optional | Migration step logs | - - --- ## GetInstalledPackageRequest @@ -142,250 +72,6 @@ Get installed package request | **version** | `string` | optional | Scope the read to this exact installed version; `latest` or omitted reads the installed row | ---- - -## GetInstalledPackageResponse - -Get installed package response - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … } \| … +1 more` | ✅ | Installed package details | - -### Nested Shape: `GetInstalledPackageResponse.error` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | -| **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | -| **refusal** | `true` | optional | Producer-declared: the 5xx this envelope carries is a deliberate refusal whose `message` is authored for the caller, so a boundary that reads the declaration keeps it verbatim (until the withhold arms read it, a declared refusal is still withheld). Absent (the default) on a declared fault, whose `message` is withheld from the body and logged for the operator; redundant on a 4xx. Presence is the declaration — `true` is the only value. | -| **category** | `string` | optional | Error category (e.g. validation, authorization) | -| **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | -| **details** | `any` | optional | Additional error context (e.g. field validation errors) | -| **requestId** | `string` | optional | Request ID for tracking | - -### Nested Shape: `GetInstalledPackageResponse.meta` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **timestamp** | `string` | ✅ | | -| **duration** | `integer` | optional | Server-side processing duration in milliseconds | -| **requestId** | `string` | optional | | -| **traceId** | `string` | optional | | - -### Nested Shape: `GetInstalledPackageResponse.data[option 1]` - -Installed package with runtime lifecycle state - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | -| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | -| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | -| **installedAt** | `string` | optional | Installation timestamp | -| **updatedAt** | `string` | optional | Last update timestamp | -| **installedVersion** | `string` | optional | Currently installed version for quick access | -| **previousVersion** | `string` | optional | Version before the last upgrade | -| **statusChangedAt** | `string` | optional | Status change timestamp | -| **errorMessage** | `string` | optional | Error message when status is error | -| **settings** | `Record` | optional | User-provided configuration settings | -| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | -| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | - -### Nested Shape: `GetInstalledPackageResponse.data[option 2]` - -Installed package row whose manifest is the assembled package body - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | The ASSEMBLED package body this row carries, at the stage the registry records it | -| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | -| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | -| **installedAt** | `string` | optional | Installation timestamp | -| **updatedAt** | `string` | optional | Last update timestamp | -| **installedVersion** | `string` | optional | Currently installed version for quick access | -| **previousVersion** | `string` | optional | Version before the last upgrade | -| **statusChangedAt** | `string` | optional | Status change timestamp | -| **errorMessage** | `string` | optional | Error message when status is error | -| **settings** | `Record` | optional | User-provided configuration settings | -| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | -| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | - - ---- - -## InstalledPackageAtEitherStage - -Installed package row at whichever manifest stage it was installed at - -### Union Options - -This schema accepts one of the following structures: - -#### Option 1 - -Installed package with runtime lifecycle state - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | -| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | -| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | -| **installedAt** | `string` | optional | Installation timestamp | -| **updatedAt** | `string` | optional | Last update timestamp | -| **installedVersion** | `string` | optional | Currently installed version for quick access | -| **previousVersion** | `string` | optional | Version before the last upgrade | -| **statusChangedAt** | `string` | optional | Status change timestamp | -| **errorMessage** | `string` | optional | Error message when status is error | -| **settings** | `Record` | optional | User-provided configuration settings | -| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | -| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | - -### Nested Shape: `InstalledPackageAtEitherStage[option 1].manifest` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | -| **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | -| **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | -| **version** | `string` | ✅ | Package version (semantic versioning) | -| **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | -| **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | -| **name** | `string` | ✅ | Human-readable package name | -| **description** | `string` | optional | Package description | -| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | -| **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files | -| **datasources** | `string[]` | optional | Glob patterns for Datasource definitions | -| **dependencies** | `Record` | optional | Package dependencies | -| **configuration** | `never` | optional | [REMOVED] `manifest.configuration` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: no settings UI rendered it and no loader resolved a setting from it, so authoring it configured nothing. Worse, `properties.*.secret` promised "value is encrypted/masked (e.g. API Keys)" while nothing encrypted, masked or even parsed the flag — a false assurance about credential handling. Delete the key. A plugin is configured by the host that composes it: pass options to its constructor in `defineStack({ plugins: [new MyPlugin({ … })] })`, which is the enforced channel. A declarative settings surface must be designed with an enforcing reader first, not revived here. | -| **contributes** | `{ kinds?: object[] }` | optional | Platform contributions | -| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Initial seed data (prefer top-level data field) | -| **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | -| **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | -| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | -| **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | -| **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | -| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | -| **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | - -### Nested Shape: `InstalledPackageAtEitherStage[option 1].upgradeHistory[number]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **fromVersion** | `string` | ✅ | Version before upgrade | -| **toVersion** | `string` | ✅ | Version after upgrade | -| **upgradedAt** | `string` | ✅ | Upgrade timestamp | -| **status** | `Enum<'success' \| 'failed' \| 'rolled_back'>` | ✅ | Upgrade outcome | -| **migrationLog** | `string[]` | optional | Migration step logs | - ---- - -#### Option 2 - -Installed package row whose manifest is the assembled package body - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | The ASSEMBLED package body this row carries, at the stage the registry records it | -| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | -| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | -| **installedAt** | `string` | optional | Installation timestamp | -| **updatedAt** | `string` | optional | Last update timestamp | -| **installedVersion** | `string` | optional | Currently installed version for quick access | -| **previousVersion** | `string` | optional | Version before the last upgrade | -| **statusChangedAt** | `string` | optional | Status change timestamp | -| **errorMessage** | `string` | optional | Error message when status is error | -| **settings** | `Record` | optional | User-provided configuration settings | -| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | -| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | - -### Nested Shape: `InstalledPackageAtEitherStage[option 2].manifest` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | -| **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | -| **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | -| **version** | `string` | ✅ | Package version (semantic versioning) | -| **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | -| **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | -| **name** | `string` | ✅ | Human-readable package name | -| **description** | `string` | optional | Package description | -| **permissions** | `{ name: string; label?: string; description?: string; packageId?: string; … }[]` | optional | Permission Sets — the ADR-0090 collection half of `permissions`; at the manifest/authoring stage the same key is the ADR-0025 capability grant instead (`ManifestSchema.permissions`) | -| **objects** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }[]` | optional | Business Objects definition (owned by this package) | -| **datasources** | `{ name: string; label?: string; driver: string; config: Record; … }[]` | optional | External Data Connections | -| **dependencies** | `Record` | optional | Package dependencies | -| **configuration** | `never` | optional | [REMOVED] `manifest.configuration` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: no settings UI rendered it and no loader resolved a setting from it, so authoring it configured nothing. Worse, `properties.*.secret` promised "value is encrypted/masked (e.g. API Keys)" while nothing encrypted, masked or even parsed the flag — a false assurance about credential handling. Delete the key. A plugin is configured by the host that composes it: pass options to its constructor in `defineStack({ plugins: [new MyPlugin({ … })] })`, which is the enforced channel. A declarative settings surface must be designed with an enforcing reader first, not revived here. | -| **contributes** | `{ kinds?: object[] }` | optional | Platform contributions | -| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Seed Data / Fixtures for bootstrapping | -| **capabilities** | `{ name: string; label?: string; description?: string; scope?: Enum<'platform' \| 'org'>; … }[]` | optional | [ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance) | -| **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | -| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | -| **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | -| **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | -| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | -| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | -| **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | -| **functions** | `Record }> \| { name: string; handler?: string; packageId?: string; effect?: Enum<'pure' \| 'writes'> }[]` | optional | Named handler functions, lowered to the refs a JSON document carries | -| **datasourceMapping** | `{ namespace?: string; package?: string; objectPattern?: string; default?: boolean; … }[]` | optional | Centralized datasource routing rules for packages/namespaces/objects | -| **translations** | `Record; apps?: Record; messages?: Record; globalActions?: Record; … }>[]` | optional | I18n Translation Bundles | -| **objectExtensions** | `{ extend: string; fields?: Record; label?: string; pluralLabel?: string; … }[]` | optional | Extensions to objects owned by other packages | -| **apps** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }[]` | optional | Applications | -| **views** | `{ name?: string; label?: string \| Record; object?: string; list?: object; … }[]` | optional | List Views | -| **viewItems** | `never` | optional | [MACHINE-ASSEMBLED] Non-container view artifacts of a runtime-assembled manifest (standalone ViewItems, flattened overlays) — written by package export and artifact factories, refused in authored stack sources. | -| **pages** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }[]` | optional | Custom Pages | -| **dashboards** | `{ name: string; label: string \| Record; description?: string \| Record; header?: object; … }[]` | optional | Dashboards | -| **reports** | `{ name: string; label: string \| Record; description?: string \| Record; type?: Enum<'tabular' \| 'summary' \| 'matrix' \| 'joined'>; … }[]` | optional | Analytics Reports | -| **datasets** | `{ name: string; label: string \| Record; description?: string \| Record; object: string; … }[]` | optional | Analytics semantic-layer datasets (ADR-0021) | -| **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Global and Object Actions. Unique per scope, not per stack: the runtime keys every action by its owning object's name (or 'global' when object-less), a colon, then the action name, and defineStack refuses two declarations that resolve to one key — both here, both on one object's actions, or one in each position, identical twins included (an embedded action is keyed by the object it is written on, not by its own objectName). One global and one object-bound action may share a name; on that object's route the object's own actions take precedence for by-name readers. composeStacks runs the same key rule across its input stacks (counting distinct stacks, not sites) and names both source stacks on a collision. | -| **flows** | `{ name: string; label: string; description?: string; successMessage?: string; … }[]` | optional | Screen Flows | -| **jobs** | `{ name: string; label?: string; description?: string; schedule: object \| object \| object; … }[]` | optional | Background / Scheduled Jobs (run by IJobService on cron/interval/once schedules) | -| **emailTemplates** | `{ name: string; label: string; category?: Enum<'auth' \| 'notification' \| 'workflow' \| 'marketing' \| 'custom'>; locale?: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | -| **docs** | `{ name: string; label?: string; description?: string; content: string; … }[]` | optional | Package documentation — flat Markdown items compiled from src/docs/*.md (ADR-0046) | -| **books** | `{ name: string; label?: string; description?: string; slug?: string; … }[]` | optional | Documentation navigation spines — ordered groups with derived membership (ADR-0046 §6) | -| **positions** | `{ name: string; label: string; description?: string; delegatable?: boolean; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | -| **sharingRules** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Record Sharing Rules | -| **apis** | `{ name: string; path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; summary?: string; … }[]` | optional | API Endpoints — declared endpoints are live from protocol 17; each is gated at publish (ADR-0121) | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Outbound Webhooks | -| **agents** | `{ name: string; label: string; avatar?: string; role: string; … }[]` | optional | AI Agents — platform-internal (ADR-0063 §2): the kernel ships exactly two (ask/build); third parties extend via skills, not agents | -| **tools** | `{ name: string; label: string; description: string; parameters: Record; … }[]` | optional | AI Tool metadata records — optional refinement layer, never required: the default path is skills referencing platform tools or materialised action_`` tools (ADR-0109) | -| **skills** | `{ name: string; label: string; description?: string; surface?: Enum<'ask' \| 'build' \| 'both'>; … }[]` | optional | AI Skills (reusable capability bundles — the third-party AI extension primitive, ADR-0063) | -| **hooks** | `{ name: string; label?: string; object: string \| string[]; events: Enum<'beforeFind' \| 'afterFind' \| 'beforeInsert' \| 'afterInsert' \| 'beforeUpdate' \| …>[]; … }[]` | optional | Object Lifecycle Hooks, as a JSON document carries them | -| **mappings** | `{ name: string; label?: string; sourceFormat?: Enum<'csv' \| 'json' \| 'xml' \| 'sql'>; targetObject: string; … }[]` | optional | Data Import/Export Mappings | -| **analyticsCubes** | `{ name: string; title?: string; description?: string; sql: string; … }[]` | optional | Analytics Semantic Layer Cubes | -| **connectors** | `{ name: string; label: string; type: Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>; description?: string; … }[]` | optional | External System Connectors. A provider-bound entry (has `provider`: openapi/mcp/rest) is materialized into a live, dispatchable connector at boot and referenced by flows via `connector_action`; credentials are `auth.credentialRef` references, never inline secrets. An entry with no `provider` is a catalog descriptor only (NOT dispatchable) — set `enabled: false` on deliberate descriptors. Unknown provider / unresolvable credentialRef / name conflict ⇒ hard boot error (ADR-0097). | -| **requires** | `string[]` | optional | Capability names this stack requires from the platform (canonical kebab-case tokens from PLATFORM_CAPABILITY_TOKENS; an unknown token is a defineStack error, declared-but-missing ⇒ fail-fast at startup) | -| **tiers** | `string[]` | optional | Plugin tier presets to enable; overrides --preset | - -### Nested Shape: `InstalledPackageAtEitherStage[option 2].upgradeHistory[number]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **fromVersion** | `string` | ✅ | Version before upgrade | -| **toVersion** | `string` | ✅ | Version after upgrade | -| **upgradedAt** | `string` | ✅ | Upgrade timestamp | -| **status** | `Enum<'success' \| 'failed' \| 'rolled_back'>` | ✅ | Upgrade outcome | -| **migrationLog** | `string[]` | optional | Migration step logs | - ---- - - --- ## ListInstalledPackagesRequest @@ -403,54 +89,6 @@ List installed packages request | **cursor** | `never` | optional | [REMOVED] `limit` / `cursor` were removed from GET /api/v1/packages in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — both were declared here and read by nothing: the serving door filters on `status` / `type` / `enabled` and then returns every remaining row, so no page was ever withheld and no continuation token was ever minted. `limit` also declared `.default(50)`, so a reader of the published schema was entitled to believe an unparameterised list is capped at 50 rows; it has never been capped at all, and nothing parses a query string through this schema, so that default has never been stamped onto anything. Delete the key. This route is NOT paginated — it answers the whole installed set, which is a bounded table of tens of rows, and `hasMore` on the response is a constant `false` that is now true by construction. Filter with `status`, `type` and `enabled` instead of asking for a window. A first-class package cursor, if one is ever designed, will be a response-minted opaque token, not this key. | ---- - -## ListInstalledPackagesResponse - -List installed packages response - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ packages: (object \| object)[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | - -### Nested Shape: `ListInstalledPackagesResponse.error` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | -| **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | -| **refusal** | `true` | optional | Producer-declared: the 5xx this envelope carries is a deliberate refusal whose `message` is authored for the caller, so a boundary that reads the declaration keeps it verbatim (until the withhold arms read it, a declared refusal is still withheld). Absent (the default) on a declared fault, whose `message` is withheld from the body and logged for the operator; redundant on a 4xx. Presence is the declaration — `true` is the only value. | -| **category** | `string` | optional | Error category (e.g. validation, authorization) | -| **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | -| **details** | `any` | optional | Additional error context (e.g. field validation errors) | -| **requestId** | `string` | optional | Request ID for tracking | - -### Nested Shape: `ListInstalledPackagesResponse.meta` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **timestamp** | `string` | ✅ | | -| **duration** | `integer` | optional | Server-side processing duration in milliseconds | -| **requestId** | `string` | optional | | -| **traceId** | `string` | optional | | - -### Nested Shape: `ListInstalledPackagesResponse.data` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **packages** | `({ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … } \| … +1 more)[]` | ✅ | Installed packages | -| **total** | `integer` | optional | Total matching packages | -| **nextCursor** | `string` | optional | Cursor for the next page | -| **hasMore** | `boolean` | ✅ | Whether more packages are available — this door serves one page, so always `false` | - - --- ## PackageApiErrorCode diff --git a/content/docs/references/api/package-lifecycle.mdx b/content/docs/references/api/package-lifecycle.mdx index 1564ca24c78..cc8ed88b4be 100644 --- a/content/docs/references/api/package-lifecycle.mdx +++ b/content/docs/references/api/package-lifecycle.mdx @@ -25,11 +25,12 @@ schema declares the `data` payload, envelope-free — the same convention as (`MetadataManager.publishPackage`) already has an exact published schema, `PackagePublishResultSchema` in `@objectstack/spec/system` — re-exported below into this `/api` namespace (ruling 5A: re-export, never a second -copy) because the route-ledger resolver looks names up only in -`@objectstack/spec/api`. +copy) because the route-ledger resolver looks names up only in the API +protocol's entries — `@objectstack/spec/api`, and `/api-assembled` for the +declarations that embed the assembled package body (#18576). The retired `PackageRollbackResponseSchema` and its -`PackageApiContracts.rollbackPackage` binding (see `./package-api.zod.ts`) +`PackageApiContracts.rollbackPackage` binding (see `./package-api-assembled.zod.ts`) declared a VERSION rollback against the live COMMIT-rollback path; `RollbackToPackageCommitResponseSchema` below is the true contract, authored after that retirement per the ruling's sequencing (3A). diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 035c94d90d7..d49933c191c 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -20,7 +20,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 31 | 444 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 32 | 444 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 14 | 75 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 159 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **195** | **1538** | 14 protocol modules | +| **Total** | **196** | **1538** | 14 protocol modules | --- @@ -62,7 +62,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 444 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 444 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -86,7 +86,8 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`metadata.zod.ts`](/docs/references/api/metadata) | `AppDefinitionResponse`, `ConceptListResponse`, `MetadataBulkRegisterRequest`, `MetadataBulkResponse`, `MetadataBulkUnregisterRequest`, `MetadataDeleteResponse`, `MetadataDependenciesResponse`, `MetadataDependentsResponse`, `MetadataExistsResponse`, `MetadataExportRequest`, `MetadataExportResponse`, `MetadataImportRequest`, `MetadataImportResponse`, `MetadataItemResponse`, `MetadataListResponse`, `MetadataNamesResponse`, `MetadataQueryRequest`, `MetadataQueryResponse`, `MetadataRegisterRequest`, `MetadataTypeInfoResponse`, `MetadataTypesResponse`, `MetadataValidateRequest`, `MetadataValidateResponse`, `ObjectDefinitionResponse` | | [`misc`](/docs/references/api/misc) *(no single source file)* | `ResolvedBook`, `ResolvedEntry`, `ResolvedGroup` | | [`odata.zod.ts`](/docs/references/api/odata) | `ODataConfig`, `ODataError`, `ODataFilterFunction`, `ODataMetadata`, `ODataQuery`, `ODataResponse` | -| [`package-api.zod.ts`](/docs/references/api/package-api) | `AssembledInstalledPackage`, `GetInstalledPackageRequest`, `GetInstalledPackageResponse`, `InstalledPackageAtEitherStage`, `ListInstalledPackagesRequest`, `ListInstalledPackagesResponse`, `PackageApiErrorCode`, `PackageInstallBody`, `PackageInstallRequest`, `PackageInstallResponse`, `PackagePathParams`, `PackageRollbackRequest`, `PackageUpgradeRequest`, `PackageUpgradeResponse`, `ResolveDependenciesRequest`, `ResolveDependenciesResponse`, `UninstallPackageApiRequest`, `UninstallPackageApiResponse`, `UploadArtifactRequest`, `UploadArtifactResponse` | +| [`package-api.zod.ts`](/docs/references/api/package-api) | `GetInstalledPackageRequest`, `ListInstalledPackagesRequest`, `PackageApiErrorCode`, `PackageInstallBody`, `PackageInstallRequest`, `PackageInstallResponse`, `PackagePathParams`, `PackageRollbackRequest`, `PackageUpgradeRequest`, `PackageUpgradeResponse`, `ResolveDependenciesRequest`, `ResolveDependenciesResponse`, `UninstallPackageApiRequest`, `UninstallPackageApiResponse`, `UploadArtifactRequest`, `UploadArtifactResponse` | +| [`package-api-assembled.zod.ts`](/docs/references/api/package-api-assembled) | `AssembledInstalledPackage`, `GetInstalledPackageResponse`, `InstalledPackageAtEitherStage`, `ListInstalledPackagesResponse` | | [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `ValidationMode` | | [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index fc23fd963d4..27e31168c33 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -123,21 +123,26 @@ import { PackageExportManifest, ReassignOrphanedMetadataResponse, DuplicatePackageResponse, - // [#17536] The element the two `/packages` READ doors are declared to serve. - // `ListInstalledPackagesResponseSchema.packages` is - // `z.array(InstalledPackageAtEitherStageSchema)` and - // `GetInstalledPackageResponseSchema.data` is that same schema - // (`spec/src/api/package-api.zod.ts`) — a union over the two manifest stages, - // authoring (`InstalledPackageSchema`) and assembled - // (`AssembledInstalledPackageSchema`), each a closed RUNTIME declaration. The - // client is a CONSUMER of that contract, so the widest value those doors are - // declared to answer is what they are declared to return here. ⚠️ What the - // published TYPE admits is wider than what the runtime parse accepts — the - // measurement, and what a caller does about it, are on `packages.list` below - // (#19324). The WRITE methods on the same object keep `InstalledPackage`: - // PR #17517 moved the read doors alone. - InstalledPackageAtEitherStage, } from '@objectstack/spec/api'; +// [#17536] The element the two `/packages` READ doors are declared to serve. +// `ListInstalledPackagesResponseSchema.packages` is +// `z.array(InstalledPackageAtEitherStageSchema)` and +// `GetInstalledPackageResponseSchema.data` is that same schema +// (`spec/src/api/package-api-assembled.zod.ts`) — a union over the two manifest +// stages, authoring (`InstalledPackageSchema`) and assembled +// (`AssembledInstalledPackageSchema`), each a closed RUNTIME declaration. The +// client is a CONSUMER of that contract, so the widest value those doors are +// declared to answer is what they are declared to return here. ⚠️ What the +// published TYPE admits is wider than what the runtime parse accepts — the +// measurement, and what a caller does about it, are on `packages.list` below +// (#19324). The WRITE methods on the same object keep `InstalledPackage`: +// PR #17517 moved the read doors alone. +// +// Imported from `@objectstack/spec/api-assembled`, not `/api`: the declarations +// that embed the assembled package body left the browser-facing `/api` entry +// (#18576 ruling, letter B). A TYPE import — erased from this package's +// bundle, so the client links none of that tree. +import type { InstalledPackageAtEitherStage } from '@objectstack/spec/api-assembled'; import type { ApprovalRequestRow, ApprovalActionRow, @@ -2494,7 +2499,7 @@ export class ObjectStackClient { * the row with a `packages/spec` schema and reading the parse's output: * * ```ts - * const parsed = AssembledInstalledPackageSchema.safeParse(pkg); // spec/api + * const parsed = AssembledInstalledPackageSchema.safeParse(pkg); // spec/api-assembled * if (parsed.success) { * // parsed.data.manifest — the ASSEMBLED stage, object definitions * } else { diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index 78185c990b5..8439bd817db 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -69,10 +69,12 @@ import type { SearchAllResponse } from '@objectstack/spec/api'; // [#17536] The two READ doors' declared element, and the branch of it that made // the client's authoring-stage declaration wrong. Both are imported as TYPES: // the pins below are the only thing that can observe a return-type move. +// Published from `@objectstack/spec/api-assembled` since the #18576 split: the +// declarations that embed the assembled package body left `/api`. import type { AssembledInstalledPackage, InstalledPackageAtEitherStage, -} from '@objectstack/spec/api'; +} from '@objectstack/spec/api-assembled'; import type { AnalyticsMetadataResponse, AnalyticsSqlResponse, diff --git a/packages/client/src/route-ledger-response-schema.test.ts b/packages/client/src/route-ledger-response-schema.test.ts index a417620c1f0..3c440839bf7 100644 --- a/packages/client/src/route-ledger-response-schema.test.ts +++ b/packages/client/src/route-ledger-response-schema.test.ts @@ -4,7 +4,8 @@ * `responseSchema` resolution guard (#5791, first step of #3877). * * WHAT THE FIELD IS. Every route ledger's entry type now carries an optional - * `responseSchema` — the NAME of the `@objectstack/spec/api` export declaring + * `responseSchema` — the NAME of the `@objectstack/spec/api` (or, since + * #18576, `@objectstack/spec/api-assembled`) export declaring * that route's response payload. #3877 measured the hole it opens onto: of 237 * ledgered routes, 215 are `sdk` surface and **zero** carried any schema * reference, so for ~90% of the mounted surface the problem was never @@ -17,7 +18,8 @@ * rots the same way with no edit at all. That is the "declared but nobody * verifies" surface #3877 exists to remove, so the field could not land without * the resolver that refuses it. Each name is looked up in the LIVE - * `@objectstack/spec/api` export namespace and required to be a real zod + * `@objectstack/spec/api` + `@objectstack/spec/api-assembled` export namespaces + * and required to be a real zod * schema, exercised rather than duck-typed. * * WHY HERE, of all packages. The five ledgers are five independent declarations @@ -41,6 +43,7 @@ import { describe, it, expect } from 'vitest'; import * as specApi from '@objectstack/spec/api'; +import * as specApiAssembled from '@objectstack/spec/api-assembled'; import { ROUTE_LEDGER } from '../../runtime/src/route-ledger'; import { REST_ROUTE_LEDGER } from '../../rest/src/rest-route-ledger'; import { STORAGE_ROUTE_LEDGER } from '../../services/service-storage/src/storage-route-ledger'; @@ -68,7 +71,18 @@ function declaredRows(): Array<{ ledger: string; route: string; responseSchema: ); } -const exportsOfSpecApi = specApi as unknown as Record; +/** + * The API protocol's exports, across BOTH entries that publish it. Since the + * #18576 ruling (letter B) the declarations whose payload embeds the assembled + * package body — the two package READ responses among them — ship from + * `@objectstack/spec/api-assembled` instead of `@objectstack/spec/api`, so a + * ledger row naming one of them resolves there. The two entries share no name + * (pinned below), so the union cannot hide which one answered. + */ +const exportsOfSpecApi = { + ...(specApi as unknown as Record), + ...(specApiAssembled as unknown as Record), +} as Record; /** * The resolver under test, extracted so the negative control below can drive @@ -84,7 +98,7 @@ const exportsOfSpecApi = specApi as unknown as Record; function resolutionFailure(name: string): string | undefined { if (name.trim() === '') return 'is empty'; if (!Object.prototype.hasOwnProperty.call(exportsOfSpecApi, name)) { - return 'is not an export of `@objectstack/spec/api`'; + return 'is not an export of `@objectstack/spec/api` or `@objectstack/spec/api-assembled`'; } const candidate = exportsOfSpecApi[name] as { safeParse?: (v: unknown) => unknown }; if (typeof candidate?.safeParse !== 'function') return 'is exported but is not a zod schema'; @@ -94,7 +108,7 @@ function resolutionFailure(name: string): string | undefined { } describe('[#5791] every ledgered `responseSchema` names a real spec schema', () => { - it('resolves each declared name against the live `@objectstack/spec/api` exports', () => { + it('resolves each declared name against the live `@objectstack/spec/api` / `/api-assembled` exports', () => { const broken = declaredRows() .map(({ ledger, route, responseSchema }) => { const why = resolutionFailure(responseSchema); @@ -105,7 +119,7 @@ describe('[#5791] every ledgered `responseSchema` names a real spec schema', () expect( broken, 'Ledger rows whose `responseSchema` does not resolve to a zod schema exported from ' - + '`@objectstack/spec/api`. Fix the name, or drop the field — an unresolvable ' + + '`@objectstack/spec/api` or `@objectstack/spec/api-assembled`. Fix the name, or drop the field — an unresolvable ' + 'declaration is worse than none (#3877).', ).toEqual([]); }); @@ -128,7 +142,7 @@ describe('[#5791] every ledgered `responseSchema` names a real spec schema', () // The failure path, driven. Each case is a way a hand-written name goes // wrong in review, and none of them is caught by `string`. expect(resolutionFailure('GetDiscoverResponseSchema')).toBe( - 'is not an export of `@objectstack/spec/api`', + 'is not an export of `@objectstack/spec/api` or `@objectstack/spec/api-assembled`', ); // one letter short of a real export expect(resolutionFailure('')).toBe('is empty'); expect(resolutionFailure('WELL_KNOWN_CAPABILITY_KEYS')).toBe( @@ -137,6 +151,14 @@ describe('[#5791] every ledgered `responseSchema` names a real spec schema', () // …and the positive control, so the rejections above are not a resolver // that rejects everything. expect(resolutionFailure('DiscoverySchema')).toBeUndefined(); + // …and one from the sibling entry, so the union really reaches it. + expect(resolutionFailure('ListInstalledPackagesResponseSchema')).toBeUndefined(); + }); + + it('the two API entries share no export name, so the union is unambiguous', () => { + const shared = Object.keys(specApiAssembled).filter((name) => + Object.prototype.hasOwnProperty.call(specApi, name)); + expect(shared, 'a name exported by both API entries').toEqual([]); }); it('the two #5791 landing rows are the discovery pair, in two different ledgers', () => { diff --git a/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts b/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts index c81095de20c..18cd35a0fa4 100644 --- a/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts +++ b/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts @@ -2,8 +2,11 @@ /** * #16781 deliverable 2 — the payloads `GET /packages` and - * `DELETE /packages/:id` actually serve, parsed against the contracts - * `@objectstack/spec/api` declares for them. + * `DELETE /packages/:id` actually serve, parsed against the contracts the + * API protocol declares for them — the delete response from + * `@objectstack/spec/api`, the two read responses from + * `@objectstack/spec/api-assembled` (they embed the assembled package body, and + * the #18576 ruling moved every such declaration off the browser-facing entry). * * ## What was measured, and why this file exists * @@ -45,8 +48,9 @@ * That is the mismatch #14242 identified one layer down, whose maintainer * ruling (2026-09-02, quoted in `stack.zod.ts` at `ArtifactPackageSchema`) was * to «declare the assembled stage rather than widen the authoring one». #17431 - * followed that ruling one layer up: `@objectstack/spec/api` now declares - * `AssembledInstalledPackageSchema`, and both read responses are bound to + * followed that ruling one layer up: the API protocol now declares + * `AssembledInstalledPackageSchema` (published from + * `@objectstack/spec/api-assembled` since #18576), and both read responses are bound to * `InstalledPackageAtEitherStageSchema` — a union over the two whole, closed * stage declarations. ⛔ Neither stage was widened; a row belonging to NEITHER * is still refused, and that is asserted below rather than assumed. @@ -75,11 +79,13 @@ import { describe, it, expect, vi } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; +import { UninstallPackageApiResponseSchema } from '@objectstack/spec/api'; +// The two READ responses embed the assembled package body, so they ship from +// `@objectstack/spec/api-assembled` rather than `/api` (#18576 ruling, letter B). import { ListInstalledPackagesResponseSchema, GetInstalledPackageResponseSchema, - UninstallPackageApiResponseSchema, -} from '@objectstack/spec/api'; +} from '@objectstack/spec/api-assembled'; import { InstalledPackageSchema } from '@objectstack/spec/kernel'; import { HttpDispatcher, type HttpDispatcherResult } from '../http-dispatcher.js'; diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index ddf3e858969..609cdeee096 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -119,7 +119,11 @@ export interface RouteLedgerEntry { /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ client?: string; /** - * Name of the `@objectstack/spec/api` export declaring this route's response + * Name of the API-protocol export declaring this route's response PAYLOAD — + * an export of `@objectstack/spec/api`, or of its sibling entry + * `@objectstack/spec/api-assembled`, which carries the API declarations whose + * payload embeds the assembled package body (the two package READ responses; + * the #18576 ruling moved them off the browser-facing `/api`). The * PAYLOAD — the `data` of the shared `{ success, data }` envelope where the * route emits one, the whole body where it does not. The envelope itself is * not this field's business; `pnpm check:route-envelope` guards it @@ -140,8 +144,8 @@ export interface RouteLedgerEntry { * can demand coverage for it; a name written ahead of the test it points at * would BE the "declared but unverified" surface the programme exists to * remove. `packages/client/src/route-ledger-response-schema.test.ts` resolves - * every name written here against the live `@objectstack/spec/api` exports, - * so a typo or a retired schema fails loudly rather than rotting. + * every name written here against the live exports of those two entries, so a + * typo or a retired schema fails loudly rather than rotting. * * A NAME rather than a live schema object, deliberately: this module stays * import-free — the client-side guards compile it as a relative SOURCE file, @@ -382,7 +386,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ // note records what its declaration does NOT carry. { route: 'GET /packages', domain: '/packages', disposition: 'sdk', client: 'packages.list', responseSchema: 'ListInstalledPackagesResponseSchema', - note: 'The schema names the WHOLE BODY here, envelope included (`BaseResponseSchema.extend({ data })`), not the `data` alone its lifecycle siblings above declare. This row was blank until now as a MEASURED verdict: an earlier contract review had added `hasMore`, but every row was still typed `InstalledPackageSchema`, whose `manifest` is the AUTHORING-stage `ManifestSchema` (`objects` = glob patterns), while a `defineStack()` host installs the ASSEMBLED body (`objects` = object definitions) — the stage mismatch the comment above names. It is filled by following that ruling one layer up: `@objectstack/spec/api` declares `AssembledInstalledPackageSchema` and binds both read responses to `InstalledPackageAtEitherStageSchema`, a union over the two whole CLOSED stage declarations — neither stage widened, and a row belonging to neither still refused. Fillable because `domains/packages-read-delete-response-conformance.test.ts` drives THIS handler and parses the payload it answers on BOTH authoring paths. ⚠️ The declaration is a strict SUBSET of the wire: each row also carries `writable`, this door\'s own computed verdict and not a declared record field, which a declared parse therefore strips — asserted by name in the same file rather than fixed' }, + note: 'The schema names the WHOLE BODY here, envelope included (`BaseResponseSchema.extend({ data })`), not the `data` alone its lifecycle siblings above declare. This row was blank until now as a MEASURED verdict: an earlier contract review had added `hasMore`, but every row was still typed `InstalledPackageSchema`, whose `manifest` is the AUTHORING-stage `ManifestSchema` (`objects` = glob patterns), while a `defineStack()` host installs the ASSEMBLED body (`objects` = object definitions) — the stage mismatch the comment above names. It is filled by following that ruling one layer up: the API protocol declares `AssembledInstalledPackageSchema` and binds both read responses to `InstalledPackageAtEitherStageSchema`, a union over the two whole CLOSED stage declarations — neither stage widened, and a row belonging to neither still refused. Fillable because `domains/packages-read-delete-response-conformance.test.ts` drives THIS handler and parses the payload it answers on BOTH authoring paths. ⚠️ The declaration is a strict SUBSET of the wire: each row also carries `writable`, this door\'s own computed verdict and not a declared record field, which a declared parse therefore strips — asserted by name in the same file rather than fixed' }, { route: 'POST /packages', domain: '/packages', disposition: 'sdk', client: 'packages.install' }, { route: 'GET /packages/:id', domain: '/packages', disposition: 'sdk', client: 'packages.get', responseSchema: 'GetInstalledPackageResponseSchema', diff --git a/packages/spec/PROTOCOL_MAP.md b/packages/spec/PROTOCOL_MAP.md index 494d5b646ed..f668ca8f77f 100644 --- a/packages/spec/PROTOCOL_MAP.md +++ b/packages/spec/PROTOCOL_MAP.md @@ -166,6 +166,7 @@ This document serves as the **Grand Map** of the ObjectStack specification. It l | [`contract.zod.ts`](src/api/contract.zod.ts) | | **API Contracts**. Versioned API signatures. | | [`storage.zod.ts`](src/api/storage.zod.ts) | | **Storage API**. File upload/download endpoints. | | [`package-api.zod.ts`](src/api/package-api.zod.ts) | | **Package API**. Package lifecycle endpoints (`/api/v1/packages`). | +| [`package-api-assembled.zod.ts`](src/api/package-api-assembled.zod.ts) | | **Package API — assembled stage**. The installed-package rows at the assembled stage, the two package read responses and the route map; published from `@objectstack/spec/api-assembled` so `/api` does not link the assembled package body. | --- diff --git a/packages/spec/api-surface/api-assembled.json b/packages/spec/api-surface/api-assembled.json new file mode 100644 index 00000000000..2311ff88751 --- /dev/null +++ b/packages/spec/api-surface/api-assembled.json @@ -0,0 +1,19 @@ +{ + "description": "Every exported `name (kind)` of one published entry point of @objectstack/spec — the breadth half of the ADR-0059 backward-compatibility gate. Sharded by entry point (#5837) so two PRs touching different entry points never share a file. Reads the BUILT dist/*.d.ts: regenerate with `pnpm --filter @objectstack/spec gen:api-surface` after a real build.", + "entry": "./api-assembled", + "exports": [ + "AssembledInstalledPackage (type)", + "AssembledInstalledPackageParsed (type)", + "AssembledInstalledPackageSchema (const)", + "GetInstalledPackageResponse (type)", + "GetInstalledPackageResponseParsed (type)", + "GetInstalledPackageResponseSchema (const)", + "InstalledPackageAtEitherStage (type)", + "InstalledPackageAtEitherStageParsed (type)", + "InstalledPackageAtEitherStageSchema (const)", + "ListInstalledPackagesResponse (type)", + "ListInstalledPackagesResponseParsed (type)", + "ListInstalledPackagesResponseSchema (const)", + "PackageApiContracts (const)" + ] +} diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 789b68f032f..f2fe07b5ee2 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -79,9 +79,6 @@ "AppDefinitionResponseSchema (const)", "ApproveAiPendingActionResponse (type)", "ApproveAiPendingActionResponseSchema (const)", - "AssembledInstalledPackage (type)", - "AssembledInstalledPackageParsed (type)", - "AssembledInstalledPackageSchema (const)", "AuditMetaItemRequest (type)", "AuditMetaItemRequestSchema (const)", "AuditMetaItemResponse (type)", @@ -457,9 +454,6 @@ "GetFlowResponseSchema (const)", "GetInstalledPackageRequest (type)", "GetInstalledPackageRequestSchema (const)", - "GetInstalledPackageResponse (type)", - "GetInstalledPackageResponseParsed (type)", - "GetInstalledPackageResponseSchema (const)", "GetLocalesRequest (type)", "GetLocalesRequestSchema (const)", "GetLocalesResponse (type)", @@ -579,9 +573,6 @@ "InstallPackageResponse (type)", "InstallPackageResponseSchema (const)", "InstalledPackage (type)", - "InstalledPackageAtEitherStage (type)", - "InstalledPackageAtEitherStageParsed (type)", - "InstalledPackageAtEitherStageSchema (const)", "ListAiConversationsRequest (type)", "ListAiConversationsRequestSchema (const)", "ListAiConversationsResponse (type)", @@ -613,9 +604,6 @@ "ListInstalledPackagesRequest (type)", "ListInstalledPackagesRequestParsed (type)", "ListInstalledPackagesRequestSchema (const)", - "ListInstalledPackagesResponse (type)", - "ListInstalledPackagesResponseParsed (type)", - "ListInstalledPackagesResponseSchema (const)", "ListNotificationsRequest (type)", "ListNotificationsRequestParsed (type)", "ListNotificationsRequestSchema (const)", @@ -767,7 +755,6 @@ "OperatorMapping (type)", "OperatorMappingSchema (const)", "PROVENANCE_WAIVERS (const)", - "PackageApiContracts (const)", "PackageApiErrorCode (const)", "PackageApiErrorCode (type)", "PackageExportManifest (type)", diff --git a/packages/spec/browser-reachable-entries.json b/packages/spec/browser-reachable-entries.json index c7d8f17394a..85619ddb0f1 100644 --- a/packages/spec/browser-reachable-entries.json +++ b/packages/spec/browser-reachable-entries.json @@ -15,6 +15,7 @@ ".", "./ai", "./api", + "./api-assembled", "./automation", "./data", "./identity", @@ -32,19 +33,19 @@ "_comment": "Entries measured for WEIGHT and left in `unjudged` on purpose, each with the reading that decided it. `unjudged` is a flat list of subpaths, so a member has nowhere to carry a reason — without this map a deliberate non-promotion and a never-examined entry are the same silence, which is the state #17535 was filed against. ⛔ An entry here is NOT a lesser `browserReachable`: this gate still asserts nothing about it. ⛔ An entry NOT here has not been measured, and its absence says nothing about its weight. ⛔ A reading here is a count plus the tree it was taken against — re-measuring REPLACES a row, it never appends to one.", "./api": { "verdict": "measured, and stays unjudged", - "why": "`browserReachable` is a SCHEMA-FREE PROMISE, not a byte budget. `./api`'s vocabulary IS the zod graph — the symbols a consumer reaches there are request/response schemas — so the promotion #17535 offers is not a threshold anyone can set: declared browser-reachable, this entry reds rule 1 on the same commit. Measured by ablation on f962be9d08: 4 problems, `dist/api/index.mjs` and `dist/api/index.js` each linking 'zod' and the undeclared external 'pg-connection-string'. Promotion would first require the entry to stop linking zod — a redesign of a published export surface, not a ledger edit — and `_unjudgedComment` above already reserves promotion to a maintainer decision plus a passing gate. So the honest record is this one.", + "why": "`browserReachable` is a SCHEMA-FREE PROMISE, not a byte budget. `./api`'s vocabulary IS the zod graph — the symbols a consumer reaches there are request/response schemas — so promotion is not a threshold anyone can set: declared browser-reachable, this entry reds rule 1 on the same commit. Re-measured by ablation after the #18576 split, at 5f845af5c4: 2 problems, `dist/api/index.mjs` and `dist/api/index.js` each linking 'zod'. The 'pg-connection-string' link the pre-split ablation (f962be9d08, 4 problems) also found is gone with the split, and `./api` no longer carries a `browser` condition. Promotion would still first require the entry to stop linking zod — a redesign of a published export surface, not a ledger edit — and `_unjudgedComment` above reserves promotion to a maintainer decision plus a passing gate. So the honest record is still this one.", "measured": { - "what": "the growth #17535 reports, re-derived in isolation: parent 0aa88eb6b0 vs the #17517 merge 9165d5cd4c, both built from source; `./contracts` and `./meta-spelling` measured byte-identical across the pair as controls", - "entryBundleRaw": "1433893 -> 1867678 bytes (+433785)", - "entryBundleGzip": "418675 -> 546266 bytes (+127591, +30.5%)", - "browserBundleOfWholeNamespaceGzip": "273904 -> 330104 bytes (+56200, +20.5%) — this is the axis #17535's +19.4% reproduces on", - "sourceGraphInputs": "110 -> 159 (+49) — #17535 reports 188 -> 237, a different absolute base and the same delta", - "narrowestRealConsumerImportGzip": "132121 -> 261221 bytes (+129100, +97.7%) — a browser bundle whose ONLY use of this entry is the two string constants `@object-ui/core` re-exports. The narrower the import, the WORSE the ratio: tree-shaking recovers proportionally less of the new graph than of the old one, so the headline percentage understates what the real consumers pay by roughly 5x.", - "onMainToday": "f962be9d08: 1914896 raw / 560968 gzip (node condition), 1912968 / 560390 (browser condition), 160 source graph inputs" + "what": "the #18576 split (maintainer ruling, letter B: the assembled-package declarations leave `./api` for `./api-assembled`), measured across its own branch: base fc6ddb87a4 vs head 5f845af5c4, both built from source in one worktree; bundles by esbuild 0.28.2, platform browser, conditions browser+import, minified, gzip -9", + "entryBundleRaw": "2082098 -> 1597172 bytes (esm)", + "entryBundleGzip": "612813 -> 469795 bytes (esm, -23.3%) — one bundle now serves both conditions", + "sourceGraphInputs": "171 -> 120 modules (esbuild metafile over src/api/index.ts); stack.zod, the datasource declaration and all nine driver modules are gone from the graph", + "narrowestRealConsumerImportGzip": "311124 -> 166529 bytes (-46.5%) — objectui `@object-ui/core` utils/column-sortability.ts bundled AS-IS (its only imports are two string constants and two types from this entry). The same probe on metadata-client.ts's one value import reads 311182 -> 166616 (-46.5%). The dynamic `import()` in app-shell clientValidation.ts pulls the whole namespace and reads 391171 -> 327732 (-16.2%).", + "wholeNamespaceGzip": "387421 -> 324465 bytes (-16.3%)", + "history": "the growth #17535 reported — parent 0aa88eb6b0 vs the #17517 merge 9165d5cd4c, narrowest consumer 132121 -> 261221 gzip — is the regression this split removes; the gap between 132121 and today's 166529 is the rest of `./api` growing since, not the assembled tree" }, - "objectuiLeg": "MEASURED, and it was the half #17535 could not reach. objectui at dda8f3815d — ⚠️ this session's attached checkout, NOT the pinned `.objectui-sha` 53ded82bf7, which is absent from that shallow clone and was ⛔ not fetched or bumped to take a reading — value-imports `@objectstack/spec/api` from 6 browser-shipped non-test source files: `@object-ui/core` utils/column-sortability.ts, `@object-ui/data-objectstack` metadata-client.ts, `@object-ui/app-shell` views/metadata-admin/clientValidation.ts, `@object-ui/plugin-chatbot` usePendingActions.ts, `@object-ui/react` utils/error-message.ts, `@object-ui/types` data.ts. So the bytes are paid by a real downstream browser consumer, not hypothetically. Control for the scan: `./contracts` reports 9 such files, matching the sites this file's own `browserReachable` entry already names.", + "objectuiLeg": "MEASURED at objectui 62597c5880, which IS the pinned `.objectui-sha` (the pre-split reading took dda8f3815d, not the pin). Six browser-shipped non-test files import `@objectstack/spec/api`: three VALUE imports — `@object-ui/core` utils/column-sortability.ts, `@object-ui/data-objectstack` metadata-client.ts, `@object-ui/app-shell` views/metadata-admin/clientValidation.ts (dynamic) — and three type-only ones that cost a bundle nothing — `@object-ui/plugin-chatbot` usePendingActions.ts, `@object-ui/react` utils/error-message.ts, `@object-ui/types` data.ts. None imports a name the split moved, so all six keep resolving from `./api` unchanged; `@object-ui/types` also re-exports this entry as a type-only `API` namespace, and nothing in objectui reads a moved name through it.", "cloudLeg": "NOT MEASURED. The `cloud` repo was not attached to the session that took these readings. ⛔ Its absence is not a zero — a reading that did not cover a population vouches only for the part it did cover.", - "notAnOutlier": "⚠️ `./api` is one of fifteen unjudged entries and the measurement does NOT single it out. By the same scan, objectui browser-shipped source value-imports twelve of the fifteen, several of them harder than this one: `./ui` 48 files, `./data` 30, `./kernel` 12, against `./api`'s 6; `./kernel` is the heavier bundle (1544209 raw / 457761 gzip on f962be9d08). Only `./marketplace`, `./qa` and `./studio` are at zero. ⛔ This row is therefore a record about `./api` alone and says NOTHING about the other fourteen — it is not a clean bill for them, and their silence is still the untested kind.", + "notAnOutlier": "⚠️ `./api` is one of sixteen unjudged entries and the measurement does NOT single it out. The weight scan below was taken when `unjudged` held fifteen — at f962be9d08 / objectui dda8f3815d, before #18576 split `./api-assembled` off `./api` — and was not re-taken for the split: objectui browser-shipped source value-imported twelve of those fifteen, several of them harder than this one: `./ui` 48 files, `./data` 30, `./kernel` 12, against `./api`'s 6; `./kernel` is the heavier bundle (1544209 raw / 457761 gzip on f962be9d08). Only `./marketplace`, `./qa` and `./studio` were at zero. The sixteenth, `./api-assembled`, was not in that scan; at the pinned objectui 62597c5880 no source file names it. ⛔ This row is therefore a record about `./api` alone and says NOTHING about the other fifteen — it is not a clean bill for them, and their silence is still the untested kind.", "whatWouldChangeThis": "Nothing in this gate has a weight axis for a schema-bearing entry — there is no byte threshold in it, for any entry, judged or not. Giving `./api` one means a NEW rule, which is a different decision from the one this row records and belongs to whoever takes that decision." } }, diff --git a/packages/spec/export-origins/api-assembled.json b/packages/spec/export-origins/api-assembled.json new file mode 100644 index 00000000000..584038cb43d --- /dev/null +++ b/packages/spec/export-origins/api-assembled.json @@ -0,0 +1,19 @@ +{ + "description": "Which SOURCE DECLARATION each name exported by one public entry point of @objectstack/spec resolves to, after its alias chain is unwound: `# ()`. Two exports share an origin string iff they are the same declaration — so equal origins across two entries are a harmless re-export, and different origins under one name are the #4411 dual-source trap. Generated from src/ (no build needed) and read by the export-surface pin tests, which compare against it instead of each building their own ts.createProgram — that was ~55s of compilation per CI lap and a non-deterministic timeout that ejected unrelated PRs from the merge queue (#4796). Sharded by entry point (#5837) so two retirement PRs never share a file. Carries NO line numbers: the pins asserted the line as `\\d+`, and recording it would rewrite this artifact on every edit that shifts a line in any .zod.ts. Regenerate with `pnpm --filter @objectstack/spec gen:export-origins` and read the diff.", + "entry": "./api-assembled", + "exports": { + "AssembledInstalledPackage": "src/api/package-api-assembled.zod.ts#AssembledInstalledPackage (type)", + "AssembledInstalledPackageParsed": "src/api/package-api-assembled.zod.ts#AssembledInstalledPackageParsed (type)", + "AssembledInstalledPackageSchema": "src/api/package-api-assembled.zod.ts#AssembledInstalledPackageSchema (const)", + "GetInstalledPackageResponse": "src/api/package-api-assembled.zod.ts#GetInstalledPackageResponse (type)", + "GetInstalledPackageResponseParsed": "src/api/package-api-assembled.zod.ts#GetInstalledPackageResponseParsed (type)", + "GetInstalledPackageResponseSchema": "src/api/package-api-assembled.zod.ts#GetInstalledPackageResponseSchema (const)", + "InstalledPackageAtEitherStage": "src/api/package-api-assembled.zod.ts#InstalledPackageAtEitherStage (type)", + "InstalledPackageAtEitherStageParsed": "src/api/package-api-assembled.zod.ts#InstalledPackageAtEitherStageParsed (type)", + "InstalledPackageAtEitherStageSchema": "src/api/package-api-assembled.zod.ts#InstalledPackageAtEitherStageSchema (const)", + "ListInstalledPackagesResponse": "src/api/package-api-assembled.zod.ts#ListInstalledPackagesResponse (type)", + "ListInstalledPackagesResponseParsed": "src/api/package-api-assembled.zod.ts#ListInstalledPackagesResponseParsed (type)", + "ListInstalledPackagesResponseSchema": "src/api/package-api-assembled.zod.ts#ListInstalledPackagesResponseSchema (const)", + "PackageApiContracts": "src/api/package-api-assembled.zod.ts#PackageApiContracts (const)" + } +} diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 05b68eced5d..dc2f25bd619 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -74,9 +74,6 @@ "AppDefinitionResponseSchema": "src/api/metadata.zod.ts#AppDefinitionResponseSchema (const)", "ApproveAiPendingActionResponse": "src/api/protocol.zod.ts#ApproveAiPendingActionResponse (type)", "ApproveAiPendingActionResponseSchema": "src/api/protocol.zod.ts#ApproveAiPendingActionResponseSchema (const)", - "AssembledInstalledPackage": "src/api/package-api.zod.ts#AssembledInstalledPackage (type)", - "AssembledInstalledPackageParsed": "src/api/package-api.zod.ts#AssembledInstalledPackageParsed (type)", - "AssembledInstalledPackageSchema": "src/api/package-api.zod.ts#AssembledInstalledPackageSchema (const)", "AuditMetaItemRequest": "src/api/protocol.zod.ts#AuditMetaItemRequest (type)", "AuditMetaItemRequestSchema": "src/api/protocol.zod.ts#AuditMetaItemRequestSchema (const)", "AuditMetaItemResponse": "src/api/protocol.zod.ts#AuditMetaItemResponse (type)", @@ -435,9 +432,6 @@ "GetFlowResponseSchema": "src/api/automation-api.zod.ts#GetFlowResponseSchema (const)", "GetInstalledPackageRequest": "src/api/package-api.zod.ts#GetInstalledPackageRequest (type)", "GetInstalledPackageRequestSchema": "src/api/package-api.zod.ts#GetInstalledPackageRequestSchema (const)", - "GetInstalledPackageResponse": "src/api/package-api.zod.ts#GetInstalledPackageResponse (type)", - "GetInstalledPackageResponseParsed": "src/api/package-api.zod.ts#GetInstalledPackageResponseParsed (type)", - "GetInstalledPackageResponseSchema": "src/api/package-api.zod.ts#GetInstalledPackageResponseSchema (const)", "GetLocalesRequest": "src/api/protocol.zod.ts#GetLocalesRequest (type)", "GetLocalesRequestSchema": "src/api/protocol.zod.ts#GetLocalesRequestSchema (const)", "GetLocalesResponse": "src/api/protocol.zod.ts#GetLocalesResponse (type)", @@ -553,9 +547,6 @@ "InstallPackageResponse": "src/kernel/package-registry.zod.ts#InstallPackageResponse (type)", "InstallPackageResponseSchema": "src/kernel/package-registry.zod.ts#InstallPackageResponseSchema (const)", "InstalledPackage": "src/kernel/package-registry.zod.ts#InstalledPackage (type)", - "InstalledPackageAtEitherStage": "src/api/package-api.zod.ts#InstalledPackageAtEitherStage (type)", - "InstalledPackageAtEitherStageParsed": "src/api/package-api.zod.ts#InstalledPackageAtEitherStageParsed (type)", - "InstalledPackageAtEitherStageSchema": "src/api/package-api.zod.ts#InstalledPackageAtEitherStageSchema (const)", "ListAiConversationsRequest": "src/api/protocol.zod.ts#ListAiConversationsRequest (type)", "ListAiConversationsRequestSchema": "src/api/protocol.zod.ts#ListAiConversationsRequestSchema (const)", "ListAiConversationsResponse": "src/api/protocol.zod.ts#ListAiConversationsResponse (type)", @@ -587,9 +578,6 @@ "ListInstalledPackagesRequest": "src/api/package-api.zod.ts#ListInstalledPackagesRequest (type)", "ListInstalledPackagesRequestParsed": "src/api/package-api.zod.ts#ListInstalledPackagesRequestParsed (type)", "ListInstalledPackagesRequestSchema": "src/api/package-api.zod.ts#ListInstalledPackagesRequestSchema (const)", - "ListInstalledPackagesResponse": "src/api/package-api.zod.ts#ListInstalledPackagesResponse (type)", - "ListInstalledPackagesResponseParsed": "src/api/package-api.zod.ts#ListInstalledPackagesResponseParsed (type)", - "ListInstalledPackagesResponseSchema": "src/api/package-api.zod.ts#ListInstalledPackagesResponseSchema (const)", "ListNotificationsRequest": "src/api/protocol.zod.ts#ListNotificationsRequest (type)", "ListNotificationsRequestParsed": "src/api/protocol.zod.ts#ListNotificationsRequestParsed (type)", "ListNotificationsRequestSchema": "src/api/protocol.zod.ts#ListNotificationsRequestSchema (const)", @@ -738,7 +726,6 @@ "OperatorMapping": "src/api/query-adapter.zod.ts#OperatorMapping (type)", "OperatorMappingSchema": "src/api/query-adapter.zod.ts#OperatorMappingSchema (const)", "PROVENANCE_WAIVERS": "src/api/error-code-ledger.zod.ts#PROVENANCE_WAIVERS (const)", - "PackageApiContracts": "src/api/package-api.zod.ts#PackageApiContracts (const)", "PackageApiErrorCode": "src/api/package-api.zod.ts#PackageApiErrorCode (type)", "PackageExportManifest": "src/api/package-lifecycle.zod.ts#PackageExportManifest (type)", "PackageExportManifestParsed": "src/api/package-lifecycle.zod.ts#PackageExportManifestParsed (type)", diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index 361af0f460a..f97267a17e6 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (202 schemas) +## 3. Schema Inventory by Domain (203 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 @@ -88,7 +88,7 @@ here is verifiable from the installed package. | system | 34 | Auth, Cache, Compliance, Dev Login, Encryption, HTTP Server, License, Logging, Metrics | | kernel | 31 | Plugin, Manifest, Events (6 sub-modules), Feature, Context, Package Registry | | data | 30 | Object, Field, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube | -| api | 30 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle | +| api | 31 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage) | | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | | automation | 14 | Flow, Approval, BPMN Interop, Control Flow, State Machine, Webhook, Schedule Organization | | shared | 15 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Value Domain, Epoch Instant (EpochMs), Duration (DurationMs / DurationSeconds) | @@ -130,6 +130,11 @@ here is verifiable from the installed package. - `ApiEndpointSchema`: REST endpoints. - `ResponseEnvelopeConfigSchema`, `ApiErrorSchema`: Request/Response envelopes. - `DiscoverySchema`: Service discovery. +- NOT here: the installed-package read responses (`ListInstalledPackagesResponseSchema`, + `GetInstalledPackageResponseSchema`), the rows they carry (`AssembledInstalledPackageSchema`, + `InstalledPackageAtEitherStageSchema`) and `PackageApiContracts` embed the assembled package + body, so they import from `@objectstack/spec/api-assembled` — which keeps `/api` light enough + for browser code. --- diff --git a/packages/spec/package.json b/packages/spec/package.json index 96c8bc10351..d08edbd1135 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -108,23 +108,33 @@ } }, "./api": { + "import": { + "types": "./dist/api/index.d.mts", + "default": "./dist/api/index.mjs" + }, + "require": { + "types": "./dist/api/index.d.ts", + "default": "./dist/api/index.js" + } + }, + "./api-assembled": { "browser": { "import": { - "types": "./dist/api/index.d.mts", - "default": "./dist/browser/api/index.mjs" + "types": "./dist/api-assembled/index.d.mts", + "default": "./dist/browser/api-assembled/index.mjs" }, "require": { - "types": "./dist/api/index.d.ts", - "default": "./dist/browser/api/index.js" + "types": "./dist/api-assembled/index.d.ts", + "default": "./dist/browser/api-assembled/index.js" } }, "import": { - "types": "./dist/api/index.d.mts", - "default": "./dist/api/index.mjs" + "types": "./dist/api-assembled/index.d.mts", + "default": "./dist/api-assembled/index.mjs" }, "require": { - "types": "./dist/api/index.d.ts", - "default": "./dist/api/index.js" + "types": "./dist/api-assembled/index.d.ts", + "default": "./dist/api-assembled/index.js" } }, "./ui": { diff --git a/packages/spec/scripts/build-declaration-map.ts b/packages/spec/scripts/build-declaration-map.ts index 332f5a64d13..9bce15cf8d6 100644 --- a/packages/spec/scripts/build-declaration-map.ts +++ b/packages/spec/scripts/build-declaration-map.ts @@ -83,6 +83,7 @@ */ import ts from 'typescript'; +import { SPLIT_ENTRIES } from './lib/split-entries'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -367,6 +368,15 @@ export function buildDeclarationMap(pkgDir: string): ComposedMap { const originsByCategory = new Map>( originShards.map((s) => [s.name, s.doc.exports]), ); + // [#18576] A SPLIT entry publishes part of its HOME category's protocol + // (`lib/split-entries.ts`): the schema manifest keys its defs + // `/`, so they resolve against the home's origins joined with the + // split entry's. The two entries share no name (the route-ledger resolver in + // packages/client pins that), so the join cannot shadow either side. + for (const [split, { home }] of Object.entries(SPLIT_ENTRIES)) { + const extra = originsByCategory.get(split); + if (extra) originsByCategory.set(home, { ...(originsByCategory.get(home) ?? {}), ...extra }); + } const defKeys = manifestShards.flatMap((s) => s.doc.schemas); // Anti-vacuity floor, same instinct as build-export-origins' entry-point diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index 0da1d529d89..4f9dbd55259 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -60,6 +60,7 @@ import { type ZodFileInput, } from './lib/schema-index'; import { schemaNameFromExportKey } from './lib/schema-name'; +import { formatSplitEntryCoverage, splitEntryCoverage } from './lib/split-entries'; import { renderSchemaSection } from './lib/schema-section'; import { API_SURFACE_DIR_NAME, readApiSurfaceFrom } from './lib/sharded-artifacts'; @@ -367,6 +368,14 @@ function groupSchemasByPage(): Map`. +import * as ApiAssembled from '../src/api-assembled'; import * as Automation from '../src/automation'; import * as Contracts from '../src/contracts'; import * as Data from '../src/data'; @@ -150,7 +155,7 @@ import * as UI from '../src/ui'; // packages/spec/src/index.ts). Build subpath-by-subpath instead so every // category folder under json-schema/ gets populated. const Protocol: Record> = { - AI, API, Automation, Contracts, Data, Identity, Integration, + AI, API: { ...API, ...ApiAssembled }, Automation, Contracts, Data, Identity, Integration, Kernel, Marketplace, QA, Security, Shared, Studio, System, UI, }; diff --git a/packages/spec/scripts/export-origins.test.ts b/packages/spec/scripts/export-origins.test.ts index 03104d24525..353990f7305 100644 --- a/packages/spec/scripts/export-origins.test.ts +++ b/packages/spec/scripts/export-origins.test.ts @@ -49,6 +49,7 @@ const ENTRY_NAMESPACES: ReadonlyArray<[string, () => Promise]> = [ ['.', () => import('../src/index')], ['./ai', () => import('../src/ai/index')], ['./api', () => import('../src/api/index')], + ['./api-assembled', () => import('../src/api-assembled/index')], ['./automation', () => import('../src/automation/index')], ['./contracts', () => import('../src/contracts/index')], ['./data', () => import('../src/data/index')], diff --git a/packages/spec/scripts/lib/category-title.ts b/packages/spec/scripts/lib/category-title.ts index cc1067c4b9a..255dc13f4d2 100644 --- a/packages/spec/scripts/lib/category-title.ts +++ b/packages/spec/scripts/lib/category-title.ts @@ -70,6 +70,13 @@ export const CATEGORY_TITLES: Readonly> = { ai: 'AI Protocol', api: 'API Protocol', + // [#18576] A SPLIT entry of `api`, not a protocol of its own (see + // `lib/split-entries.ts`): the API declarations whose payload embeds the + // assembled package body, published from their own entry so the + // browser-facing `./api` does not link that tree. Titled "Entry", not + // "Protocol", so `check-docs-spec-enumerations.mjs` counts it as a subpath and + // never as a protocol namespace; its schemas are documented under `api`. + 'api-assembled': 'API Assembled-Stage Entry', automation: 'Automation Protocol', contracts: 'Contracts Protocol', conversions: 'Conversions Protocol', diff --git a/packages/spec/scripts/lib/docs-import-surface.ts b/packages/spec/scripts/lib/docs-import-surface.ts index b3fa2afe27c..ae7697a957b 100644 --- a/packages/spec/scripts/lib/docs-import-surface.ts +++ b/packages/spec/scripts/lib/docs-import-surface.ts @@ -46,6 +46,8 @@ * `build-docs.ts`. */ +import { splitEntriesHomedAt } from './split-entries'; + /** Kinds that guarantee `import type { N }` resolves. */ const TYPE_KINDS: ReadonlySet = new Set(['type', 'interface', 'class', 'enum']); @@ -96,6 +98,13 @@ export function resolveTypeName(schemaName: string, surface: CategorySurface): s } export interface ResolvedImports { + /** + * The entry the page's import lines name — `@objectstack/spec/`. The + * category itself, unless the page documents declarations a SPLIT entry of + * that category publishes (`lib/split-entries.ts`, #18576): the API + * reference's assembled-stage page imports from `api-assembled`, not `api`. + */ + entry: string; /** Const names for `import { … }`, in page order, dead names dropped. */ valueNames: string[]; /** Type names for `import type { … }`, in page order, dead names dropped. */ @@ -114,13 +123,27 @@ export function resolveImports( category: string, schemaNames: readonly string[], surfaces: ReadonlyMap, + splitEntries: readonly string[] = splitEntriesHomedAt(category), ): ResolvedImports { - const surface = surfaces.get(category); + // One page, one entry. A page is one source file's schemas, and a split entry + // re-exports whole files, so the page's names come from the category's own + // entry or from exactly one of its split entries — whichever exports the + // first name that resolves anywhere. Names that then do not resolve in THAT + // entry are gaps, reported against the category exactly as before, so a page + // split across two entries is loud rather than half-printed. + const entry = + [category, ...splitEntries].find((candidate) => { + const s = surfaces.get(candidate); + return s !== undefined + && schemaNames.some((n) => resolveValueName(n, s) !== null || resolveTypeName(n, s) !== null); + }) ?? category; + const surface = surfaces.get(entry); if (!surface) { // Pages exist for a category the package does not publish as an entry // point: every `from '@objectstack/spec/'` on the page is dead, // not just a name on it. Report once, emit nothing. return { + entry, valueNames: [], typeNames: [], exampleValue: null, @@ -142,7 +165,7 @@ export function resolveImports( else gaps.push(`${category}/${name} — no type export`); } - return { valueNames, typeNames, exampleValue: valueNames[0] ?? null, gaps }; + return { entry, valueNames, typeNames, exampleValue: valueNames[0] ?? null, gaps }; } // ── The committed baseline's BYTES ─────────────────────────────────────────── diff --git a/packages/spec/scripts/lib/schema-closure.ts b/packages/spec/scripts/lib/schema-closure.ts index 0397b941029..5ee292ea8c7 100644 --- a/packages/spec/scripts/lib/schema-closure.ts +++ b/packages/spec/scripts/lib/schema-closure.ts @@ -42,6 +42,8 @@ * it still warns — pinned by `schema-closure.test.ts`, both directions. */ +import { isSplitEntry, SPLIT_ENTRIES, type SplitEntry } from './split-entries'; + /** * Category directory -> the citation in this repo that declares it ships no * schema closure. @@ -98,8 +100,14 @@ export const CATEGORIES_WITHOUT_SCHEMA_CLOSURE: Readonly> export function schemaClosureAbsenceIsDeclared( category: string, exempt: Readonly> = CATEGORIES_WITHOUT_SCHEMA_CLOSURE, + splits: Readonly> = SPLIT_ENTRIES, ): boolean { - return Object.hasOwn(exempt, category); + // The second declaration (#18576): a SPLIT entry has a schema closure, and + // its schemas publish under its HOME category's directory (`lib/split-entries.ts`), + // so `json-schema//` is absent by design — declared there, with its + // citation, and expiring by `splitEntryCoverage` there, which `build-docs.ts` + // runs on the same walk as the coverage check below. + return Object.hasOwn(exempt, category) || isSplitEntry(category, splits); } /** Directories whose exemption no longer describes the tree. */ diff --git a/packages/spec/scripts/lib/split-entries.ts b/packages/spec/scripts/lib/split-entries.ts new file mode 100644 index 00000000000..8ca374ea1bc --- /dev/null +++ b/packages/spec/scripts/lib/split-entries.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Entry points that publish PART OF ANOTHER CATEGORY'S protocol — a packaging + * split, not a new protocol (#18576). + * + * ## Why this exists + * + * Everywhere else in this package one `src//` directory is one + * protocol category AND one `@objectstack/spec/` entry: the JSON + * Schema tree (`json-schema//`), the reference pages, the + * authorable-surface keys (`/:`) and the import line a page + * prints are all keyed by the same word. + * + * `./api-assembled` breaks that one-to-one on purpose. The maintainer ruling + * on #18576 (letter B) moved the API-protocol declarations whose payload embeds + * the ASSEMBLED package body off `@objectstack/spec/api`, because that body + * links the whole metadata vocabulary and the datasource/driver validators and + * `./api` is imported by browser code. The declarations did not change + * protocol: they are still `api` schemas, their JSON Schema ids stay + * `api/`, and they are documented in the API reference beside the rest + * of the Package API. Only the import path moved. + * + * So a split entry has a HOME category, and the tools that key by category + * need to know it: + * + * - `build-schemas.ts` walks the split entry's exports as part of its home + * category, so no published schema id moves; + * - `build-docs.ts` does not look for `json-schema//` (its schemas are + * under the home) — `lib/schema-closure.ts` reads this map for that; + * - `lib/docs-import-surface.ts` spells a page's import line from whichever + * of the home and its split entries actually exports the page's names. + * + * ⛔ An entry here is a claim that the split entry's every export is a + * declaration of its home protocol. It is not a place to park a new protocol: + * a directory that declares something new gets its own category, title and + * JSON Schema tree like every other one. + */ +export interface SplitEntry { + /** The category whose protocol this entry publishes part of. */ + readonly home: string; + /** The citation that declares the split. */ + readonly citation: string; +} + +/** Split-entry directory under `src/` (== its subpath name) -> its home. */ +export const SPLIT_ENTRIES: Readonly> = { + 'api-assembled': { + home: 'api', + citation: + 'maintainer ruling on #18576 (letter B): the API declarations that embed the assembled package ' + + 'body leave the browser-facing `./api` for `./api-assembled` — see src/api-assembled/index.ts and ' + + 'src/api/package-api-assembled.zod.ts', + }, +}; + +/** Is this `src/` directory a split entry (its schemas publish under a home category)? */ +export function isSplitEntry(dir: string, splits: Readonly> = SPLIT_ENTRIES): boolean { + return Object.hasOwn(splits, dir); +} + +/** The split entries whose home is `category`, in declaration order. */ +export function splitEntriesHomedAt( + category: string, + splits: Readonly> = SPLIT_ENTRIES, +): string[] { + return Object.entries(splits) + .filter(([, entry]) => entry.home === category) + .map(([dir]) => dir); +} + +/** Split entries that no longer describe the tree. */ +export interface SplitEntryCoverage { + /** Declared split, but `json-schema//` exists — it publishes on its own now. */ + selfPublishing: string[]; + /** Declared split, but its HOME publishes no `json-schema//` — the claim points at nothing. */ + homeless: string[]; + /** Declared split, and `src//` is gone. */ + orphaned: string[]; +} + +/** + * Place the declared splits against the tree. The exemption a split buys in + * `lib/schema-closure.ts` (no warning for its absent schema directory) is only + * safe while all three hold, so `build-docs.ts` stops on any of them — the same + * expiry discipline as `schemaClosureExemptionCoverage`, one datum over. + */ +export function splitEntryCoverage( + allCategories: readonly string[], + categoriesWithSchemaDir: readonly string[], + splits: Readonly> = SPLIT_ENTRIES, +): SplitEntryCoverage { + const onDisk = new Set(allCategories); + const withSchemaDir = new Set(categoriesWithSchemaDir); + const dirs = Object.keys(splits).sort(); + return { + selfPublishing: dirs.filter((d) => withSchemaDir.has(d)), + homeless: dirs.filter((d) => !withSchemaDir.has(splits[d].home)), + orphaned: dirs.filter((d) => !onDisk.has(d)), + }; +} + +/** The build-stopping message for {@link splitEntryCoverage}, or null when clean. */ +export function formatSplitEntryCoverage(coverage: SplitEntryCoverage): string | null { + const lines = [ + ...coverage.selfPublishing.map((d) => ` - ${d} (declared a split entry, but json-schema/${d}/ now exists — it publishes on its own; delete the entry)`), + ...coverage.homeless.map((d) => ` - ${d} (declared a split entry, but its home publishes no json-schema/ directory — fix the home or delete the entry)`), + ...coverage.orphaned.map((d) => ` - ${d} (declared a split entry, but packages/spec/src/${d}/ is gone — delete the entry)`), + ]; + if (lines.length === 0) return null; + return ( + `SPLIT_ENTRIES in scripts/lib/split-entries.ts no longer describes this tree:\n\n${lines.join('\n')}\n\n` + + `A split entry is exempt from the missing-schema-directory warning only because its schemas are\n` + + `published under its home category. An entry that no longer matches the tree silences a reading\n` + + `nobody decided to silence.\n` + ); +} diff --git a/packages/spec/scripts/split-entries.test.ts b/packages/spec/scripts/split-entries.test.ts new file mode 100644 index 00000000000..e2a5e7eb948 --- /dev/null +++ b/packages/spec/scripts/split-entries.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Unit pins for the split-entry declaration (#18576): an entry that publishes +// part of another category's protocol keeps its schemas under the HOME +// category (JSON Schema ids, reference pages) while its import lines name the +// split entry — and the declaration expires the moment the tree stops matching. + +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + formatSplitEntryCoverage, + isSplitEntry, + SPLIT_ENTRIES, + splitEntriesHomedAt, + splitEntryCoverage, +} from './lib/split-entries'; +import { schemaClosureAbsenceIsDeclared } from './lib/schema-closure'; +import { loadEntrySurfaces, resolveImports } from './lib/docs-import-surface'; +import { CATEGORY_TITLES } from './lib/category-title'; + +/** This package's root — every read below stays inside it. */ +const PKG_DIR = path.resolve(__dirname, '..'); +const read = (rel: string) => fs.readFileSync(path.join(PKG_DIR, rel), 'utf-8'); + +describe('SPLIT_ENTRIES — the declared list', () => { + it('declares api-assembled, homed at api, and nothing else', () => { + expect(Object.keys(SPLIT_ENTRIES)).toEqual(['api-assembled']); + expect(SPLIT_ENTRIES['api-assembled'].home).toBe('api'); + expect(splitEntriesHomedAt('api')).toEqual(['api-assembled']); + expect(splitEntriesHomedAt('data')).toEqual([]); + }); + + it('is backed by the tree it cites — the entry exists and re-exports only its home\'s source', () => { + // The claim an entry here makes is "every export is a declaration of the + // home protocol". The control: the entry index re-exports from the home's + // own directory and nowhere else. + const index = read('src/api-assembled/index.ts'); + const reexports = [...index.matchAll(/export \* from '([^']+)'/g)].map((m) => m[1]); + expect(reexports).toEqual(['../api/package-api-assembled.zod']); + expect(JSON.parse(read('package.json')).exports).toHaveProperty('./api-assembled'); + }); + + it('is titled as an entry, not as a protocol namespace', () => { + // `check-docs-spec-enumerations.mjs` counts a subpath whose title ends in + // ` Protocol` as a protocol NAMESPACE. A split entry is not one. + expect(CATEGORY_TITLES['api-assembled']).toBeDefined(); + expect(CATEGORY_TITLES['api-assembled']).not.toMatch(/ Protocol$/); + }); +}); + +describe('the missing-schema-directory warning treats a split entry as declared', () => { + it('answers yes for the split entry, and still no for an undeclared category', () => { + expect(isSplitEntry('api-assembled')).toBe(true); + expect(schemaClosureAbsenceIsDeclared('api-assembled')).toBe(true); + expect(schemaClosureAbsenceIsDeclared('conversions')).toBe(false); + expect(schemaClosureAbsenceIsDeclared('api')).toBe(false); + }); +}); + +describe('splitEntryCoverage — a split may not outlive its condition', () => { + const splits = { 'api-assembled': { home: 'api', citation: 'test' } }; + + it('is clean while the split is on disk, publishes nothing itself, and its home publishes', () => { + const coverage = splitEntryCoverage(['api', 'api-assembled', 'data'], ['api', 'data'], splits); + expect(coverage).toEqual({ selfPublishing: [], homeless: [], orphaned: [] }); + expect(formatSplitEntryCoverage(coverage)).toBeNull(); + }); + + it('reports a split that grew its own json-schema directory', () => { + const coverage = splitEntryCoverage(['api', 'api-assembled'], ['api', 'api-assembled'], splits); + expect(coverage.selfPublishing).toEqual(['api-assembled']); + expect(formatSplitEntryCoverage(coverage)).toContain('json-schema/api-assembled/ now exists'); + }); + + it('reports a split whose home publishes nothing', () => { + const coverage = splitEntryCoverage(['api', 'api-assembled'], ['data'], splits); + expect(coverage.homeless).toEqual(['api-assembled']); + expect(formatSplitEntryCoverage(coverage)).toContain('its home publishes no json-schema/'); + }); + + it('reports a split whose directory is gone', () => { + const coverage = splitEntryCoverage(['api'], ['api'], splits); + expect(coverage.orphaned).toEqual(['api-assembled']); + expect(formatSplitEntryCoverage(coverage)).toContain('packages/spec/src/api-assembled/ is gone'); + }); +}); + +describe('resolveImports — a page documented under the home imports from the entry that exports it', () => { + const surfaces = loadEntrySurfaces({ + './home': ['WidgetSchema (const)', 'Widget (type)'], + './home-split': ['GadgetSchema (const)', 'Gadget (type)'], + }); + + it('keeps the home entry for a page whose names the home exports', () => { + const r = resolveImports('home', ['Widget'], surfaces, ['home-split']); + expect(r.entry).toBe('home'); + expect(r.valueNames).toEqual(['WidgetSchema']); + expect(r.gaps).toEqual([]); + }); + + it('names the split entry for a page whose names only the split exports', () => { + const r = resolveImports('home', ['Gadget'], surfaces, ['home-split']); + expect(r.entry).toBe('home-split'); + expect(r.valueNames).toEqual(['GadgetSchema']); + expect(r.typeNames).toEqual(['Gadget']); + expect(r.gaps).toEqual([]); + }); + + it('is loud about a page split across both entries — the second entry\'s names are gaps', () => { + const r = resolveImports('home', ['Widget', 'Gadget'], surfaces, ['home-split']); + expect(r.entry).toBe('home'); + expect(r.gaps.sort()).toEqual(['home/Gadget — no schema const export', 'home/Gadget — no type export']); + }); + + it('NEGATIVE CONTROL: without the split declared, the split-only page is all gaps', () => { + const r = resolveImports('home', ['Gadget'], surfaces, []); + expect(r.entry).toBe('home'); + expect(r.valueNames).toEqual([]); + expect(r.gaps.length).toBe(2); + }); +}); diff --git a/packages/spec/src/api-assembled/index.ts b/packages/spec/src/api-assembled/index.ts new file mode 100644 index 00000000000..f5fcfbffc6e --- /dev/null +++ b/packages/spec/src/api-assembled/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `@objectstack/spec/api-assembled` — the API-protocol declarations whose + * payload embeds the ASSEMBLED package body. + * + * A packaging split, not a new protocol: every name here is an API-protocol + * declaration (`../api/package-api-assembled.zod.ts`, documented and published + * as JSON Schema under the `api` category). It is served from its own entry + * because the assembled body links the whole metadata vocabulary plus the + * datasource and driver-config validators, and `@objectstack/spec/api` — which + * browser code imports — must not (maintainer ruling on #18576, letter B). + * + * Import from here when you need the installed-package rows at the assembled + * stage, the two package READ responses bound to them, or the package route + * map; import every other API contract from `@objectstack/spec/api`. + */ +export * from '../api/package-api-assembled.zod'; diff --git a/packages/spec/src/api/api-entry-graph.pin.test.ts b/packages/spec/src/api/api-entry-graph.pin.test.ts new file mode 100644 index 00000000000..95f33c39ac2 --- /dev/null +++ b/packages/spec/src/api/api-entry-graph.pin.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pin: `@objectstack/spec/api` does not reach the assembled package body. + * + * The maintainer ruling on #18576 (letter B) split `./api`: the declarations + * whose payload embeds the assembled package body moved to + * `@objectstack/spec/api-assembled` (`./package-api-assembled.zod.ts`), so the + * browser-facing entry stops linking `../stack.zod` — the whole metadata + * vocabulary — and, behind it, the datasource declaration and the + * driver-config validators. Measured when the split landed: a browser bundle + * whose only use of `./api` is two string constants from `./sortability.zod` + * roughly halved. + * + * Nothing else holds that boundary. `browser-reachable-entries.json` leaves + * `./api` unjudged (it links zod, and its weight has no rule), so without this + * pin one `export *` of a stack-dependent module from `./index.ts` would put + * the whole tree back into every `./api` bundle and every gate would stay + * green. This walks the entry's STATIC value-import graph from source — the + * edges a bundler follows — and refuses it reaching any of the three modules + * the split exists to keep out. + * + * `import type` / `export type` edges are not followed: they are erased at + * build time and cost a bundle nothing. + */ + +import { describe, expect, it } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SRC = resolve(HERE, '..'); + +/** The modules `./api` must not reach — the tree the split moved out. */ +const FORBIDDEN = ['stack.zod.ts', 'data/datasource.zod.ts', 'data/driver/config-registry.zod.ts']; + +// A relative specifier in a value-bearing `import … from` / `export … from` +// statement, or a bare side-effect `import '…'`. Statements that open with +// `import type` / `export type` are dropped before this runs. +const EDGE = /(?:^|\n)\s*(?:import|export)\s[^;]*?from\s*['"](\.[^'"]+)['"]|(?:^|\n)\s*import\s*['"](\.[^'"]+)['"]/g; +const TYPE_ONLY = /(?:^|\n)\s*(?:import|export)\s+type\s[^;]*?from\s*['"][^'"]+['"]\s*;?/g; + +function resolveSpecifier(fromFile: string, spec: string): string { + const base = resolve(dirname(fromFile), spec.replace(/\.js$/, '')); + for (const candidate of [`${base}.ts`, `${base}/index.ts`, base]) { + if (existsSync(candidate) && candidate.endsWith('.ts')) return candidate; + } + // An edge that does not resolve would make the walk incomplete, and an + // incomplete walk reporting "not reached" is exactly the false green this + // pin exists to prevent — so it fails instead. + throw new Error(`unresolved relative import ${spec} in ${relative(SRC, fromFile)}`); +} + +function valueGraph(entry: string): Set { + const seen = new Set(); + const queue = [resolve(SRC, entry)]; + while (queue.length > 0) { + const file = queue.pop()!; + if (seen.has(file)) continue; + seen.add(file); + // Comment lines are dropped line by line (a docblock line opens with `*`), + // never with a `/* … */` regex: glob strings such as `src/**/*.ts` in this + // tree would open a false comment and swallow real import statements. + const source = readFileSync(file, 'utf8') + .split('\n') + .filter((line) => !/^\s*(\*|\/\*|\/\/)/.test(line)) + .join('\n') + .replace(TYPE_ONLY, '\n'); + for (const m of source.matchAll(EDGE)) { + queue.push(resolveSpecifier(file, (m[1] ?? m[2])!)); + } + } + return new Set([...seen].map((f) => relative(SRC, f).split('\\').join('/'))); +} + +describe('`@objectstack/spec/api` stays off the assembled package body (#18576 ruling, letter B)', () => { + const api = valueGraph('api/index.ts'); + const assembled = valueGraph('api-assembled/index.ts'); + + it('`./api` reaches none of stack.zod, the datasource declaration or the driver-config registry', () => { + expect(FORBIDDEN.filter((f) => api.has(f))).toEqual([]); + }); + + it('`./api` does not re-export the assembled-stage module', () => { + expect(api.has('api/package-api-assembled.zod.ts')).toBe(false); + }); + + it('positive control: the same walk DOES find all three from `./api-assembled`', () => { + // Without this, a walker that silently stopped following edges would pass + // the two assertions above over nothing. + expect(FORBIDDEN.filter((f) => assembled.has(f))).toEqual(FORBIDDEN); + }); + + it('anti-vacuity: the `./api` walk covers the entry\'s real graph', () => { + // 120 value-graph modules when the split landed; far below that means the + // edge pattern stopped matching, not that the entry shrank. + expect(api.size).toBeGreaterThan(60); + expect(api.has('api/sortability.zod.ts')).toBe(true); + expect(api.has('api/package-api.zod.ts')).toBe(true); + }); +}); diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts index f164145ccda..1152e047b00 100644 --- a/packages/spec/src/api/index.ts +++ b/packages/spec/src/api/index.ts @@ -79,14 +79,24 @@ export * from './query-adapter.zod'; export * from './export.zod'; export * from './automation-api.zod'; export * from './package-api.zod'; +// ⛔ `./package-api-assembled.zod` is deliberately NOT re-exported here. Its +// declarations (`AssembledInstalledPackageSchema`, +// `InstalledPackageAtEitherStageSchema`, `ListInstalledPackagesResponseSchema`, +// `GetInstalledPackageResponseSchema`, `PackageApiContracts`) embed the +// assembled package body, which links the whole metadata vocabulary and the +// datasource/driver validators; they are published from +// `@objectstack/spec/api-assembled` so this browser-facing entry does not carry +// that tree (maintainer ruling on #18576, letter B). `./api-entry-graph.pin.test.ts` +// holds the boundary. // #12038 — the package lifecycle response contracts (ADR-0067 commit // timeline, draft batch doors, ADR-0070 export/adopt/duplicate), including // the ruling-5A `/api` re-export of `PackagePublishResultSchema`. export * from './package-lifecycle.zod'; // Ruling 5A (#12038): the book-tree response contract is declared beside its // resolver in `../system/book.zod` — re-exported here (never a second copy) -// so the route-ledger resolver, which searches only `@objectstack/spec/api`, -// can name it. +// so the route-ledger resolver, which searches only the API protocol's two +// entries (`@objectstack/spec/api` and, since #18576, `/api-assembled`), can +// name it. export { ResolvedEntrySchema, ResolvedGroupSchema, ResolvedBookSchema } from '../system/book.zod'; // …with their existing types (the interfaces `resolveBookTree` is typed by, // pinned type-identical to the schemas in `system/book.test.ts`) — the same diff --git a/packages/spec/src/api/package-api-assembled.zod.ts b/packages/spec/src/api/package-api-assembled.zod.ts new file mode 100644 index 00000000000..1be097dfed6 --- /dev/null +++ b/packages/spec/src/api/package-api-assembled.zod.ts @@ -0,0 +1,267 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { BaseResponseSchema } from './contract.zod'; +import { InstalledPackageSchema } from '../kernel/package-registry.zod'; +import { lazySchema } from '../shared/lazy-schema'; +import { RecordStagePackageBodySchema } from '../stack.zod'; +import { + GetInstalledPackageRequestSchema, + ListInstalledPackagesRequestSchema, + PackageInstallBodySchema, + PackageInstallResponseSchema, + UninstallPackageApiRequestSchema, + UninstallPackageApiResponseSchema, +} from './package-api.zod'; + +/** + * The Package API declarations that carry the ASSEMBLED package body. + * + * Published from `@objectstack/spec/api-assembled`, never from + * `@objectstack/spec/api`. Everything here is part of the Package API + * (`/api/v1/packages`, `./package-api.zod.ts`); what sets these five apart is + * that each one embeds the assembled package body, `RecordStagePackageBodySchema` + * from `../stack.zod` — or, for the route map, names a schema that does: + * + * - `AssembledInstalledPackageSchema` — the installed row at the assembled stage; + * - `InstalledPackageAtEitherStageSchema` — the union the read doors serve; + * - `ListInstalledPackagesResponseSchema` / `GetInstalledPackageResponseSchema` + * — the two read responses, bound to that union; + * - `PackageApiContracts` — the route map, which names both read responses. + * + * ## Why they have their own entry + * + * The assembled body is the WHOLE metadata vocabulary: `../stack.zod` reaches + * every collection schema, the datasource declaration and, behind it, the + * driver-config validators and the server-only pg URL grammar. Declared inside + * `@objectstack/spec/api` (#17517), that tree became part of every bundle of the + * entry — and a browser module that imported two string constants from + * `./sortability.zod` paid for all of it, roughly doubling its gzipped bundle, + * because the entry ships as one self-contained bundle and little of that tree + * can be dropped by a consumer's tree-shaking. The maintainer ruling on #18576 + * (letter B) removed the cost rather than watching it: the browser-facing + * `./api` no longer carries these declarations, and this entry does. + * + * ⛔ Their MEANING did not change with the move — same schemas, same refusals, + * same JSON Schema ids (`api/...`, still published under `json-schema/api/`, + * because they are API-protocol declarations; only the import path moved). + * + * ⛔ Only a declaration that genuinely needs the assembled body belongs here. + * Everything else in the Package API stays in `./package-api.zod.ts`, which + * `@objectstack/spec/api` publishes; `./api-entry-graph.pin.test.ts` pins that + * `./api` reaches neither `../stack.zod` nor the datasource declaration. + */ + +// ========================================== +// Installed Package Rows — the two declared manifest STAGES +// ========================================== + +/** + * One installed-package row whose `manifest` is the ASSEMBLED package body — + * the assembled-stage counterpart of {@link InstalledPackageSchema}. + * + * ## The stage this exists to name + * + * `InstalledPackageSchema.manifest` is `ManifestSchema`, the AUTHORING stage: + * its `objects` is `z.array(z.string())`, GLOB PATTERNS naming files a + * file-based loader should read. What a `defineStack()` host installs is the + * ASSEMBLED body, whose `objects` are object DEFINITIONS — `ObjectQL.registerApp` + * is handed exactly that and iterates it into `registerObject(objDef, …)`, and + * `SchemaRegistry.installPackage` records what it was handed. So the read doors + * serve rows the authoring declaration refuses, with a single surviving reason: + * the manifest stage. + * + * That is the mismatch #14242 identified one layer down, and this declaration + * follows its ruling rather than re-deriving one. The maintainer's decision + * (2026-09-02, road B), quoted at `ArtifactPackageSchema` in `../stack.zod`, + * was to «declare the assembled stage rather than widen the authoring one». + * ⛔ Widening `ManifestSchema.objects` into a union of both spellings was road + * C and was REJECTED by name: a union AT THE KEY makes neither stage checkable, + * which is the tolerate-at-the-consumer shape Prime Directive #12 refuses. So + * `ManifestSchema` is untouched here — still `strictObject`, still globs — and + * the assembled stage gets its own name, built from `AssembledPackageBodySchema` + * (#14242's own declaration) rather than a second transcription of it. + * + * The body half is deliberately typed `Record`; the reason is + * recorded at `AssembledPackageBodySchema` and is not repeated here. The RUNTIME + * schema still carries the manifest's every field plus every collection's full + * declaration, so a wrong-shaped body is refused exactly as it is there. + * + * ## The row's manifest is the RECORD stage, not the assembled one + * + * `SchemaRegistry.installPackage` does not store the caller's object; it stores + * `toRecordManifest(manifest)`, a structural JSON projection that DROPS + * functions, class instances, `Map`, `Set` and every other exotic value. So the + * row this API serves is JSON by construction, and two of the assembled body's + * 55 collections cannot survive that projection in the shape they declare: + * + * - `functions` — a `z.function()` branch (a named callable); + * - `hooks` — a `z.custom()` branch (a lifecycle handler). + * + * Those same two are the reason `AssembledPackageBodySchema` has NO JSON Schema + * at all: `z.toJSONSchema` refuses a function and a custom type, and embedding + * the body verbatim in the two published response schemas below made BOTH of + * them disappear from `json-schema/api/`, which the build's own disappearance + * ratchet refuses. `build-schemas.ts` names the remedy taken here: + * «make it emit — narrow the unrepresentable member». + * + * ⚠️ ⛔ Those two members are NOT why `ArtifactPackageSchema` and + * `ObjectStackDefinitionSchema` publish no JSON Schema — an earlier version of + * this docblock said they were, and it is false. `src/stack.zod.ts` is not one + * of the subpath namespaces `build-schemas.ts` walks, so neither schema is ever + * reached by the emit loop; repairing the two branches would not make either + * appear. What the narrowing below buys is this file's own two responses, which + * ARE in the emit loop. + * + * ⭐ The narrowing is a DECLARATION rather than a hole. Until #17518 these two + * keys were `z.unknown().optional()` here — accepted without being checked — + * and that hole is what `RecordStagePackageBodySchema` replaces: the registry + * record stage, declared in `../stack.zod` beside the assembled and artifact + * stages, is the assembled body with both collections lowered and + * `functions[].handler` optional. ⛔ Never widen either key back to `unknown` + * to make a row fit: a row that parses through neither declared stage is a + * producer defect, and the record stage exists to keep saying so. The set of + * members that need the treatment is MEASURED, never hand-picked — pinned + * key-by-key in `./package-api.test.ts`, so a new collection with no JSON form + * reddens there, naming itself. + */ +export const AssembledInstalledPackageSchema = lazySchema(() => InstalledPackageSchema.extend({ + manifest: RecordStagePackageBodySchema.describe('The ASSEMBLED package body this row carries, at the stage the registry records it'), +}).describe('Installed package row whose manifest is the assembled package body')); +export type AssembledInstalledPackage = z.input; +/** Post-parse shape of {@link AssembledInstalledPackage} — defaults applied, transforms run (ADR-0122). */ +export type AssembledInstalledPackageParsed = z.infer; + +/** + * One installed-package row at WHICHEVER manifest stage it was installed at — + * the element the read doors (`GET /packages`, `GET /packages/:id`) serve. + * + * ## Why this surface names BOTH stages, where the artifact names one + * + * #14242 bound the artifact's `packages[]` to the assembled stage ALONE, and + * its stated reason is a property of that surface: «a glob in a compiled + * artifact names files nobody will read». The installed-packages table is not + * a compiled artifact. It is the record of what was installed, and BOTH stages + * reach it through DECLARED doors: + * + * - {@link PackageInstallRequestSchema} declares `manifest: ManifestSchema` — + * the AUTHORING stage — and `POST /packages` hands that body straight to + * `SchemaRegistry.installPackage`, which stores a JSON projection of it; + * - a `defineStack()` host reaches the same table through + * `ObjectQL.registerApp`, which installs the ASSEMBLED body. + * + * ⇒ a read contract naming only the assembled stage would refuse a row this + * API's own install contract is declared to produce. Naming only the authoring + * stage is the defect this declaration closes. So the row is declared as what + * it is: one of two stages, each named by its own closed declaration. + * + * ## ⛔ This is a union of two whole STAGES, never a tolerant shape + * + * Road C's defect was a union INSIDE a key: `objects: (string | ObjectDef)[]` + * describes no stage, and admits an array that mixes globs with definitions. + * This union is over two complete, closed declarations, so every parse is a + * FULL parse of one coherent stage and a body belonging to neither — a mixed + * `objects` array among them — is refused by both branches and therefore by + * this schema. That refusal is pinned in + * `packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts`, + * beside the two doors, so «it accepts both» can never quietly become «it + * accepts anything». + * + * ⛔ Never relax either branch to make a payload fit. A row that parses through + * neither stage is a producer defect, and this is the declaration that has to + * keep saying so. + */ +export const InstalledPackageAtEitherStageSchema = lazySchema(() => z.union([ + InstalledPackageSchema, + AssembledInstalledPackageSchema, +]).describe('Installed package row at whichever manifest stage it was installed at')); +export type InstalledPackageAtEitherStage = z.input; +/** Post-parse shape of {@link InstalledPackageAtEitherStage} — defaults applied, transforms run (ADR-0122). */ +export type InstalledPackageAtEitherStageParsed = z.infer; + +/** + * Response for listing installed packages. + */ +export const ListInstalledPackagesResponseSchema = lazySchema(() => BaseResponseSchema.extend({ + data: z.object({ + packages: z.array(InstalledPackageAtEitherStageSchema).describe('Installed packages'), + total: z.number().int().optional().describe('Total matching packages'), + nextCursor: z.string().optional().describe('Cursor for the next page'), + // The door sends a constant `false` here, and since #17667 removed the + // request half that is TRUE BY CONSTRUCTION rather than merely convenient: + // with no `limit` and no `cursor` to ask with, nothing can request a page, + // so there is never a next one to announce and `nextCursor` stays absent. + // ⛔ Do not "fix" the constant back into a computed value without first + // restoring a request-side way to ask for a page — a `true` nobody can act + // on is the same defect this card closed, pointing the other way. + hasMore: z.boolean().describe('Whether more packages are available — this door serves one page, so always `false`'), + }), +}).describe('List installed packages response')); +export type ListInstalledPackagesResponse = z.input; +/** Post-parse shape of {@link ListInstalledPackagesResponse} — defaults applied, transforms run (ADR-0122). */ +export type ListInstalledPackagesResponseParsed = z.infer; + +/** + * Response for getting a single installed package. + */ +export const GetInstalledPackageResponseSchema = lazySchema(() => BaseResponseSchema.extend({ + data: InstalledPackageAtEitherStageSchema.describe('Installed package details'), +}).describe('Get installed package response')); +export type GetInstalledPackageResponse = z.input; +/** Post-parse shape of {@link GetInstalledPackageResponse} — defaults applied, transforms run (ADR-0122). */ +export type GetInstalledPackageResponseParsed = z.infer; + +// ========================================== +// 11. Package API Contract Registry +// ========================================== + +/** + * Standard Package API contracts map. + * Used for generating SDKs, documentation, and route registration. + */ +export const PackageApiContracts = { + listPackages: { + method: 'GET' as const, + path: '/api/v1/packages', + input: ListInstalledPackagesRequestSchema, + output: ListInstalledPackagesResponseSchema, + }, + getPackage: { + method: 'GET' as const, + path: '/api/v1/packages/:packageId', + input: GetInstalledPackageRequestSchema, + output: GetInstalledPackageResponseSchema, + }, + // `installPackage` REBOUND (#18058) — it named `/api/v1/packages/install`, + // a path the composed runtime mounts nowhere (the dispatcher answers + // `handled=false`; `packages/rest` mounts only `/packages/publish`). The + // serving install door is the bare `POST /api/v1/packages`, and its body is + // declared at BOTH the forms it accepts — see `PackageInstallBodySchema`. + installPackage: { + method: 'POST' as const, + path: '/api/v1/packages', + input: PackageInstallBodySchema, + output: PackageInstallResponseSchema, + }, + // `upgradePackage`, `resolveDependencies` and `uploadArtifact` REMOVED + // (#19116, ADR-0087 semantic entry + // `package-api-contracts-unmounted-entries-retired`) — they bound + // `POST /api/v1/packages/upgrade`, `/resolve-dependencies` and `/upload`, + // three paths the composed runtime mounts nowhere (the dispatcher answers + // `handled=false`; `packages/rest` mounts only `/packages/publish`), and no + // serving door existed to rebind them onto as `installPackage` was. Their + // request/response schemas (sections 5–7) stay published, bound to no route. + // ⛔ A package upgrade / dependency-resolution / upload route is declared + // here only in the same change that MOUNTS it — never ahead of its door. + // `rollbackPackage` RETIRED (#12038 3A) — it bound the version-rollback + // schemas to the live `/api/v1/packages/:packageId/rollback` path, which + // actually serves the ADR-0067 COMMIT rollback (`rollbackToPackageCommit`). + // The live route's true contract is `RollbackToPackageCommitResponseSchema` + // (`./package-lifecycle.zod`), named by its route-ledger row. + uninstallPackage: { + method: 'DELETE' as const, + path: '/api/v1/packages/:packageId', + input: UninstallPackageApiRequestSchema, + output: UninstallPackageApiResponseSchema, + }, +}; diff --git a/packages/spec/src/api/package-api.test.ts b/packages/spec/src/api/package-api.test.ts index e6c35124b22..2b5ee21a81e 100644 --- a/packages/spec/src/api/package-api.test.ts +++ b/packages/spec/src/api/package-api.test.ts @@ -2,9 +2,7 @@ import { describe, it, expect } from 'vitest'; import { PackagePathParamsSchema, ListInstalledPackagesRequestSchema, - ListInstalledPackagesResponseSchema, GetInstalledPackageRequestSchema, - GetInstalledPackageResponseSchema, PackageInstallRequestSchema, PackageInstallBodySchema, PackageInstallResponseSchema, @@ -18,10 +16,16 @@ import { UninstallPackageApiRequestSchema, UninstallPackageApiResponseSchema, PackageApiErrorCode, +} from './package-api.zod'; +// The declarations that embed the assembled package body live one file over +// and ship from `@objectstack/spec/api-assembled` (#18576 ruling, letter B). +import { + ListInstalledPackagesResponseSchema, + GetInstalledPackageResponseSchema, PackageApiContracts, AssembledInstalledPackageSchema, InstalledPackageAtEitherStageSchema, -} from './package-api.zod'; +} from './package-api-assembled.zod'; import { InstalledPackageSchema } from '../kernel/package-registry.zod'; import { ManifestSchema } from '../kernel/manifest.zod'; import { AssembledPackageBodySchema } from '../stack.zod'; diff --git a/packages/spec/src/api/package-api.zod.ts b/packages/spec/src/api/package-api.zod.ts index 8692fa228b0..f1f2073210a 100644 --- a/packages/spec/src/api/package-api.zod.ts +++ b/packages/spec/src/api/package-api.zod.ts @@ -9,7 +9,6 @@ import { PackageArtifactSchema } from '../kernel/package-artifact.zod'; import { ManifestSchema } from '../kernel/manifest.zod'; import { ArtifactReferenceSchema } from '../marketplace/marketplace.zod'; import { retiredKey } from '../shared/retired-key'; -import { RecordStagePackageBodySchema } from '../stack.zod'; /** * # Package API Protocol @@ -26,6 +25,30 @@ import { RecordStagePackageBodySchema } from '../stack.zod'; * POST /api/v1/packages/:packageId/rollback — Rollback a package * DELETE /api/v1/packages/:packageId — Uninstall a package * ``` + * + * ## Five declarations of this API live one file over, on purpose + * + * The two READ responses (`ListInstalledPackagesResponseSchema`, + * `GetInstalledPackageResponseSchema`), the installed-row stages they are bound + * to (`AssembledInstalledPackageSchema`, `InstalledPackageAtEitherStageSchema`) + * and the `PackageApiContracts` map that names both responses are declared in + * `./package-api-assembled.zod.ts` and published from + * `@objectstack/spec/api-assembled`, not from `@objectstack/spec/api`. + * + * The reason is weight, not meaning. Four of them carry the ASSEMBLED package + * body (the fifth, the route map, names two of those four), which is the whole + * metadata vocabulary (`../stack.zod`) plus the datasource and driver-config + * validators behind it. While they sat in this + * file, every `@objectstack/spec/api` bundle linked that tree, and a browser + * consumer that imported two string constants from `./sortability.zod` paid + * for all of it: measured at about twice the gzipped bundle of the same import + * before the stage declarations arrived. The maintainer ruling on #18576 + * (letter B) split the entry so the browser-facing half does not carry them. + * + * ⛔ Nothing in this file may import `../stack.zod` or anything that reaches + * `../data/datasource.zod`: that edge is exactly what the split removed from + * `@objectstack/spec/api`, and `./api-entry-graph.pin.test.ts` refuses it. + * A declaration that needs the assembled body goes in the sibling file. */ // ========================================== @@ -41,133 +64,6 @@ export const PackagePathParamsSchema = lazySchema(() => z.object({ })); export type PackagePathParams = z.input; -// ========================================== -// Installed Package Rows — the two declared manifest STAGES -// ========================================== - -/** - * One installed-package row whose `manifest` is the ASSEMBLED package body — - * the assembled-stage counterpart of {@link InstalledPackageSchema}. - * - * ## The stage this exists to name - * - * `InstalledPackageSchema.manifest` is `ManifestSchema`, the AUTHORING stage: - * its `objects` is `z.array(z.string())`, GLOB PATTERNS naming files a - * file-based loader should read. What a `defineStack()` host installs is the - * ASSEMBLED body, whose `objects` are object DEFINITIONS — `ObjectQL.registerApp` - * is handed exactly that and iterates it into `registerObject(objDef, …)`, and - * `SchemaRegistry.installPackage` records what it was handed. So the read doors - * serve rows the authoring declaration refuses, with a single surviving reason: - * the manifest stage. - * - * That is the mismatch #14242 identified one layer down, and this declaration - * follows its ruling rather than re-deriving one. The maintainer's decision - * (2026-09-02, road B), quoted at `ArtifactPackageSchema` in `../stack.zod`, - * was to «declare the assembled stage rather than widen the authoring one». - * ⛔ Widening `ManifestSchema.objects` into a union of both spellings was road - * C and was REJECTED by name: a union AT THE KEY makes neither stage checkable, - * which is the tolerate-at-the-consumer shape Prime Directive #12 refuses. So - * `ManifestSchema` is untouched here — still `strictObject`, still globs — and - * the assembled stage gets its own name, built from `AssembledPackageBodySchema` - * (#14242's own declaration) rather than a second transcription of it. - * - * The body half is deliberately typed `Record`; the reason is - * recorded at `AssembledPackageBodySchema` and is not repeated here. The RUNTIME - * schema still carries the manifest's every field plus every collection's full - * declaration, so a wrong-shaped body is refused exactly as it is there. - * - * ## The row's manifest is the RECORD stage, not the assembled one - * - * `SchemaRegistry.installPackage` does not store the caller's object; it stores - * `toRecordManifest(manifest)`, a structural JSON projection that DROPS - * functions, class instances, `Map`, `Set` and every other exotic value. So the - * row this API serves is JSON by construction, and two of the assembled body's - * 55 collections cannot survive that projection in the shape they declare: - * - * - `functions` — a `z.function()` branch (a named callable); - * - `hooks` — a `z.custom()` branch (a lifecycle handler). - * - * Those same two are the reason `AssembledPackageBodySchema` has NO JSON Schema - * at all: `z.toJSONSchema` refuses a function and a custom type, and embedding - * the body verbatim in the two published response schemas below made BOTH of - * them disappear from `json-schema/api/`, which the build's own disappearance - * ratchet refuses. `build-schemas.ts` names the remedy taken here: - * «make it emit — narrow the unrepresentable member». - * - * ⚠️ ⛔ Those two members are NOT why `ArtifactPackageSchema` and - * `ObjectStackDefinitionSchema` publish no JSON Schema — an earlier version of - * this docblock said they were, and it is false. `src/stack.zod.ts` is not one - * of the subpath namespaces `build-schemas.ts` walks, so neither schema is ever - * reached by the emit loop; repairing the two branches would not make either - * appear. What the narrowing below buys is this file's own two responses, which - * ARE in the emit loop. - * - * ⭐ The narrowing is a DECLARATION rather than a hole. Until #17518 these two - * keys were `z.unknown().optional()` here — accepted without being checked — - * and that hole is what `RecordStagePackageBodySchema` replaces: the registry - * record stage, declared in `../stack.zod` beside the assembled and artifact - * stages, is the assembled body with both collections lowered and - * `functions[].handler` optional. ⛔ Never widen either key back to `unknown` - * to make a row fit: a row that parses through neither declared stage is a - * producer defect, and the record stage exists to keep saying so. The set of - * members that need the treatment is MEASURED, never hand-picked — pinned - * key-by-key in `./package-api.test.ts`, so a new collection with no JSON form - * reddens there, naming itself. - */ -export const AssembledInstalledPackageSchema = lazySchema(() => InstalledPackageSchema.extend({ - manifest: RecordStagePackageBodySchema.describe('The ASSEMBLED package body this row carries, at the stage the registry records it'), -}).describe('Installed package row whose manifest is the assembled package body')); -export type AssembledInstalledPackage = z.input; -/** Post-parse shape of {@link AssembledInstalledPackage} — defaults applied, transforms run (ADR-0122). */ -export type AssembledInstalledPackageParsed = z.infer; - -/** - * One installed-package row at WHICHEVER manifest stage it was installed at — - * the element the read doors (`GET /packages`, `GET /packages/:id`) serve. - * - * ## Why this surface names BOTH stages, where the artifact names one - * - * #14242 bound the artifact's `packages[]` to the assembled stage ALONE, and - * its stated reason is a property of that surface: «a glob in a compiled - * artifact names files nobody will read». The installed-packages table is not - * a compiled artifact. It is the record of what was installed, and BOTH stages - * reach it through DECLARED doors: - * - * - {@link PackageInstallRequestSchema} declares `manifest: ManifestSchema` — - * the AUTHORING stage — and `POST /packages` hands that body straight to - * `SchemaRegistry.installPackage`, which stores a JSON projection of it; - * - a `defineStack()` host reaches the same table through - * `ObjectQL.registerApp`, which installs the ASSEMBLED body. - * - * ⇒ a read contract naming only the assembled stage would refuse a row this - * API's own install contract is declared to produce. Naming only the authoring - * stage is the defect this declaration closes. So the row is declared as what - * it is: one of two stages, each named by its own closed declaration. - * - * ## ⛔ This is a union of two whole STAGES, never a tolerant shape - * - * Road C's defect was a union INSIDE a key: `objects: (string | ObjectDef)[]` - * describes no stage, and admits an array that mixes globs with definitions. - * This union is over two complete, closed declarations, so every parse is a - * FULL parse of one coherent stage and a body belonging to neither — a mixed - * `objects` array among them — is refused by both branches and therefore by - * this schema. That refusal is pinned in - * `packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts`, - * beside the two doors, so «it accepts both» can never quietly become «it - * accepts anything». - * - * ⛔ Never relax either branch to make a payload fit. A row that parses through - * neither stage is a producer defect, and this is the declaration that has to - * keep saying so. - */ -export const InstalledPackageAtEitherStageSchema = lazySchema(() => z.union([ - InstalledPackageSchema, - AssembledInstalledPackageSchema, -]).describe('Installed package row at whichever manifest stage it was installed at')); -export type InstalledPackageAtEitherStage = z.input; -/** Post-parse shape of {@link InstalledPackageAtEitherStage} — defaults applied, transforms run (ADR-0122). */ -export type InstalledPackageAtEitherStageParsed = z.infer; - // ========================================== // 2. List Packages (GET /api/v1/packages) // ========================================== @@ -247,27 +143,8 @@ export type ListInstalledPackagesRequest = z.input; -/** - * Response for listing installed packages. - */ -export const ListInstalledPackagesResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: z.object({ - packages: z.array(InstalledPackageAtEitherStageSchema).describe('Installed packages'), - total: z.number().int().optional().describe('Total matching packages'), - nextCursor: z.string().optional().describe('Cursor for the next page'), - // The door sends a constant `false` here, and since #17667 removed the - // request half that is TRUE BY CONSTRUCTION rather than merely convenient: - // with no `limit` and no `cursor` to ask with, nothing can request a page, - // so there is never a next one to announce and `nextCursor` stays absent. - // ⛔ Do not "fix" the constant back into a computed value without first - // restoring a request-side way to ask for a page — a `true` nobody can act - // on is the same defect this card closed, pointing the other way. - hasMore: z.boolean().describe('Whether more packages are available — this door serves one page, so always `false`'), - }), -}).describe('List installed packages response')); -export type ListInstalledPackagesResponse = z.input; -/** Post-parse shape of {@link ListInstalledPackagesResponse} — defaults applied, transforms run (ADR-0122). */ -export type ListInstalledPackagesResponseParsed = z.infer; +// The response half, `ListInstalledPackagesResponseSchema`, is declared in +// `./package-api-assembled.zod.ts` — its rows carry the assembled package body. // ========================================== // 3. Get Package (GET /api/v1/packages/:packageId) @@ -301,15 +178,8 @@ export const GetInstalledPackageRequestSchema = lazySchema(() => PackagePathPara }).describe('Get installed package request')); export type GetInstalledPackageRequest = z.input; -/** - * Response for getting a single installed package. - */ -export const GetInstalledPackageResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: InstalledPackageAtEitherStageSchema.describe('Installed package details'), -}).describe('Get installed package response')); -export type GetInstalledPackageResponse = z.input; -/** Post-parse shape of {@link GetInstalledPackageResponse} — defaults applied, transforms run (ADR-0122). */ -export type GetInstalledPackageResponseParsed = z.infer; +// The response half, `GetInstalledPackageResponseSchema`, is declared in +// `./package-api-assembled.zod.ts` — its row carries the assembled package body. // ========================================== // 4. Install Package (POST /api/v1/packages) @@ -339,9 +209,10 @@ export type GetInstalledPackageResponseParsed = z.infer; // ========================================== -// 5. Upgrade Package (request/response shapes — bound to no route, see §11) +// 5. Upgrade Package (request/response shapes — bound to no route, see `PackageApiContracts` in ./package-api-assembled.zod.ts) // ========================================== /** @@ -641,7 +513,7 @@ export type PackageUpgradeResponse = z.input; // ========================================== -// 6. Resolve Dependencies (request/response shapes — bound to no route, see §11) +// 6. Resolve Dependencies (request/response shapes — bound to no route, see `PackageApiContracts` in ./package-api-assembled.zod.ts) // ========================================== /** @@ -674,7 +546,7 @@ export type ResolveDependenciesResponse = z.input; // ========================================== -// 7. Upload Artifact (request/response shapes — bound to no route, see §11) +// 7. Upload Artifact (request/response shapes — bound to no route, see `PackageApiContracts` in ./package-api-assembled.zod.ts) // ========================================== /** @@ -831,58 +703,3 @@ export const PackageApiErrorCode = z.enum([ 'upload_failed', ]); export type PackageApiErrorCode = z.input; - -// ========================================== -// 11. Package API Contract Registry -// ========================================== - -/** - * Standard Package API contracts map. - * Used for generating SDKs, documentation, and route registration. - */ -export const PackageApiContracts = { - listPackages: { - method: 'GET' as const, - path: '/api/v1/packages', - input: ListInstalledPackagesRequestSchema, - output: ListInstalledPackagesResponseSchema, - }, - getPackage: { - method: 'GET' as const, - path: '/api/v1/packages/:packageId', - input: GetInstalledPackageRequestSchema, - output: GetInstalledPackageResponseSchema, - }, - // `installPackage` REBOUND (#18058) — it named `/api/v1/packages/install`, - // a path the composed runtime mounts nowhere (the dispatcher answers - // `handled=false`; `packages/rest` mounts only `/packages/publish`). The - // serving install door is the bare `POST /api/v1/packages`, and its body is - // declared at BOTH the forms it accepts — see `PackageInstallBodySchema`. - installPackage: { - method: 'POST' as const, - path: '/api/v1/packages', - input: PackageInstallBodySchema, - output: PackageInstallResponseSchema, - }, - // `upgradePackage`, `resolveDependencies` and `uploadArtifact` REMOVED - // (#19116, ADR-0087 semantic entry - // `package-api-contracts-unmounted-entries-retired`) — they bound - // `POST /api/v1/packages/upgrade`, `/resolve-dependencies` and `/upload`, - // three paths the composed runtime mounts nowhere (the dispatcher answers - // `handled=false`; `packages/rest` mounts only `/packages/publish`), and no - // serving door existed to rebind them onto as `installPackage` was. Their - // request/response schemas (sections 5–7) stay published, bound to no route. - // ⛔ A package upgrade / dependency-resolution / upload route is declared - // here only in the same change that MOUNTS it — never ahead of its door. - // `rollbackPackage` RETIRED (#12038 3A) — it bound the version-rollback - // schemas to the live `/api/v1/packages/:packageId/rollback` path, which - // actually serves the ADR-0067 COMMIT rollback (`rollbackToPackageCommit`). - // The live route's true contract is `RollbackToPackageCommitResponseSchema` - // (`./package-lifecycle.zod`), named by its route-ledger row. - uninstallPackage: { - method: 'DELETE' as const, - path: '/api/v1/packages/:packageId', - input: UninstallPackageApiRequestSchema, - output: UninstallPackageApiResponseSchema, - }, -}; diff --git a/packages/spec/src/api/package-lifecycle.zod.ts b/packages/spec/src/api/package-lifecycle.zod.ts index 97ff0976ba9..b6d76264afd 100644 --- a/packages/spec/src/api/package-lifecycle.zod.ts +++ b/packages/spec/src/api/package-lifecycle.zod.ts @@ -21,11 +21,12 @@ * (`MetadataManager.publishPackage`) already has an exact published schema, * `PackagePublishResultSchema` in `@objectstack/spec/system` — re-exported * below into this `/api` namespace (ruling 5A: re-export, never a second - * copy) because the route-ledger resolver looks names up only in - * `@objectstack/spec/api`. + * copy) because the route-ledger resolver looks names up only in the API + * protocol's entries — `@objectstack/spec/api`, and `/api-assembled` for the + * declarations that embed the assembled package body (#18576). * * The retired `PackageRollbackResponseSchema` and its - * `PackageApiContracts.rollbackPackage` binding (see `./package-api.zod.ts`) + * `PackageApiContracts.rollbackPackage` binding (see `./package-api-assembled.zod.ts`) * declared a VERSION rollback against the live COMMIT-rollback path; * `RollbackToPackageCommitResponseSchema` below is the true contract, * authored after that retirement per the ruling's sequencing (3A). diff --git a/packages/spec/src/kernel/package-registry.zod.ts b/packages/spec/src/kernel/package-registry.zod.ts index abd76387f9d..7694ddaddee 100644 --- a/packages/spec/src/kernel/package-registry.zod.ts +++ b/packages/spec/src/kernel/package-registry.zod.ts @@ -71,7 +71,7 @@ export const InstalledPackageSchema = lazySchema(() => z.object({ * `PermissionSet[]` collection. * * The assembled stage has its own declaration rather than a widening of this - * one: `AssembledInstalledPackageSchema` (`../api/package-api.zod.ts`) over + * one: `AssembledInstalledPackageSchema` (`../api/package-api-assembled.zod.ts`) over * `AssembledPackageBodySchema` (`../stack.zod.ts`), the maintainer's road B * of 2026-09-02 — «declare the assembled stage rather than widen the * authoring one». The read doors (`GET /packages`, `GET /packages/:id`) diff --git a/packages/spec/src/migrations/entries/semantic/18.api-assembled-entry-split.ts b/packages/spec/src/migrations/entries/semantic/18.api-assembled-entry-split.ts new file mode 100644 index 00000000000..ed52a0c8b02 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.api-assembled-entry-split.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'api-assembled-entry-split', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span. + surface: + 'api.AssembledInstalledPackageSchema / api.InstalledPackageAtEitherStageSchema / ' + + 'api.ListInstalledPackagesResponseSchema / api.GetInstalledPackageResponseSchema / ' + + 'api.PackageApiContracts, with the types AssembledInstalledPackage, InstalledPackageAtEitherStage, ' + + 'ListInstalledPackagesResponse, GetInstalledPackageResponse and their Parsed twins — imported from ' + + '@objectstack/spec/api', + replacement: + 'the same names, unchanged, imported from `@objectstack/spec/api-assembled` — change the import ' + + 'path and nothing else. Every schema parses and refuses exactly what it did, the route map has the ' + + 'same four entries, and the JSON Schema ids are unchanged (`json-schema/api/AssembledInstalledPackage.json` ' + + 'and its three siblings are still published under `api/`). Every OTHER Package API declaration — ' + + 'the request schemas of both read doors, the install / uninstall / upgrade / rollback shapes, ' + + '`PackageApiErrorCode` — stays on `@objectstack/spec/api`.', + reason: + 'Maintainer ruling on #18576 (batch #145 item 1, letter B, 「同意,其他也同意」): split the API entry ' + + 'so its browser-facing half no longer carries the assembled-package declarations. Those five embed ' + + 'the ASSEMBLED package body, which reaches the whole metadata vocabulary and, behind it, the ' + + 'datasource declaration and the driver-config validators; declared inside `@objectstack/spec/api`, ' + + 'that tree was part of every bundle of the entry, and a browser module importing two string ' + + 'constants from it paid for all of it. Measured on the splitting PR: that module (objectui ' + + '`@object-ui/core` column-sortability) bundles to 166,529 bytes gzipped instead of 311,124. The ' + + 'split moves an import path, which is TypeScript source rather than metadata — nothing authors, ' + + 'stores or parses it — so there is no source a D2 conversion could rewrite, and the move is ' + + 'recorded here.', + acceptanceCriteria: + 'No code imports any of the five names, their types or their Parsed twins from ' + + '`@objectstack/spec/api` — each such import is a TS2305 "has no exported member" error after ' + + 'upgrade (TS2724 with a did-you-mean when a similarly named export exists; the suggested name is ' + + 'a different schema, not the replacement), and at runtime the binding is undefined. The same ' + + 'names import cleanly from ' + + '`@objectstack/spec/api-assembled`. No metadata document, stored row or JSON Schema reference ' + + 'needs editing: the schemas and their published ids did not change.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 30cf33582d0..5e9bae319b7 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5801,6 +5801,43 @@ const step18: MigrationStep = { + 'one day on SQL), so re-check what the widget was supposed to show rather than trusting ' + 'the old result set.', }, + { + id: 'api-assembled-entry-split', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span. + surface: + 'api.AssembledInstalledPackageSchema / api.InstalledPackageAtEitherStageSchema / ' + + 'api.ListInstalledPackagesResponseSchema / api.GetInstalledPackageResponseSchema / ' + + 'api.PackageApiContracts, with the types AssembledInstalledPackage, InstalledPackageAtEitherStage, ' + + 'ListInstalledPackagesResponse, GetInstalledPackageResponse and their Parsed twins — imported from ' + + '@objectstack/spec/api', + replacement: + 'the same names, unchanged, imported from `@objectstack/spec/api-assembled` — change the import ' + + 'path and nothing else. Every schema parses and refuses exactly what it did, the route map has the ' + + 'same four entries, and the JSON Schema ids are unchanged (`json-schema/api/AssembledInstalledPackage.json` ' + + 'and its three siblings are still published under `api/`). Every OTHER Package API declaration — ' + + 'the request schemas of both read doors, the install / uninstall / upgrade / rollback shapes, ' + + '`PackageApiErrorCode` — stays on `@objectstack/spec/api`.', + reason: + 'Maintainer ruling on #18576 (batch #145 item 1, letter B, 「同意,其他也同意」): split the API entry ' + + 'so its browser-facing half no longer carries the assembled-package declarations. Those five embed ' + + 'the ASSEMBLED package body, which reaches the whole metadata vocabulary and, behind it, the ' + + 'datasource declaration and the driver-config validators; declared inside `@objectstack/spec/api`, ' + + 'that tree was part of every bundle of the entry, and a browser module importing two string ' + + 'constants from it paid for all of it. Measured on the splitting PR: that module (objectui ' + + '`@object-ui/core` column-sortability) bundles to 166,529 bytes gzipped instead of 311,124. The ' + + 'split moves an import path, which is TypeScript source rather than metadata — nothing authors, ' + + 'stores or parses it — so there is no source a D2 conversion could rewrite, and the move is ' + + 'recorded here.', + acceptanceCriteria: + 'No code imports any of the five names, their types or their Parsed twins from ' + + '`@objectstack/spec/api` — each such import is a TS2305 "has no exported member" error after ' + + 'upgrade (TS2724 with a did-you-mean when a similarly named export exists; the suggested name is ' + + 'a different schema, not the replacement), and at runtime the binding is undefined. The same ' + + 'names import cleanly from ' + + '`@objectstack/spec/api-assembled`. No metadata document, stored row or JSON Schema reference ' + + 'needs editing: the schemas and their published ids did not change.', + }, { id: 'api-error-retry-after-unit-in-key', surface: 'EnhancedApiError.retryAfter (api/errors.zod.ts) — the ADR-0112 error envelope on the wire', diff --git a/packages/spec/tsup.config.ts b/packages/spec/tsup.config.ts index f15872daeca..b27a9a511e8 100644 --- a/packages/spec/tsup.config.ts +++ b/packages/spec/tsup.config.ts @@ -77,6 +77,11 @@ const entries = [ 'src/kernel/index.ts', 'src/automation/index.ts', 'src/api/index.ts', + // The API-protocol declarations that embed the ASSEMBLED package body, split + // off `./api` so the browser-facing entry stops linking the whole metadata + // vocabulary and the datasource/driver validators (maintainer ruling on + // #18576, letter B). See `src/api-assembled/index.ts`. + 'src/api-assembled/index.ts', 'src/ui/index.ts', 'src/ai/index.ts', 'src/security/index.ts', @@ -117,12 +122,17 @@ const browserConditionedEntries = [ 'src/data/index.ts', 'src/system/index.ts', 'src/kernel/index.ts', - // `./api` joined the poisoned set when the package read API began declaring - // the assembled manifest stage: its record body reaches the datasource - // declaration, and with it the driver-config validators. Same seam, same - // swap, same degradation the 2026-08-22 ruling accepted — not a second - // mechanism. - 'src/api/index.ts', + // `./api-assembled` carries the package read API's assembled-stage + // declarations: their record body reaches the datasource declaration, and + // with it the driver-config validators. Same seam, same swap, same + // degradation the 2026-08-22 ruling accepted — not a second mechanism. + // + // ⛔ `./api` is NOT here any more, and must not come back. It joined this set + // when those declarations lived in it; the #18576 ruling (letter B) moved + // them to `./api-assembled`, so `./api`'s graph no longer reaches the pg + // grammar and its ordinary bundles are what a browser bundler loads — which + // `check:browser-reachable-entries` rule 2 now judges directly. + 'src/api-assembled/index.ts', ]; /**