diff --git a/.changeset/17518-inert-json-package-body-stages.md b/.changeset/17518-inert-json-package-body-stages.md new file mode 100644 index 00000000000..5ce069b34f8 --- /dev/null +++ b/.changeset/17518-inert-json-package-body-stages.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": minor +--- + +A package body now has a declaration at every stage it really passes through: `ArtifactStagePackageBodySchema` and `RecordStagePackageBodySchema` join `AssembledPackageBodySchema`, and the installed-package read rows are declared against the record stage instead of two `z.unknown()` holes (#17518). + +ADR-0130 D4 says an artifact is inert JSON — "a plugin written inside `packages[i].manifest` could never be constructed by a loader, so a reader that resolved it there would register garbage where it used to skip in silence". Of `AssembledPackageBodySchema`'s 55 members exactly two declare that they accept a callable: `functions`, whose entry union opens with `z.function()`, and `hooks`, whose `handler` carries a `z.custom()` branch. One unrepresentable member costs every embedder its whole JSON Schema, which is why `api/ListInstalledPackagesResponse` and `api/GetInstalledPackageResponse` could only carry the body with both keys written `z.unknown().optional()` — accepted without being checked, as that file's own docblock said. + +- **⛔ The assembled body is untouched, and that is the point.** Those callables are LIVE on the stage it declares itself for: `composeStacks(stacks, { manifest: 'preserve' })` builds exactly such a body and the load path registers it, and `stack.zod.ts` states the invariant that binds the two. Narrowing in place would refuse a published composition function's own output. The two JSON stages are declared BESIDE it instead. +- **Artifact stage** — what `objectstack build` writes: `functions` entries are the lowered spellings (a bare handler ref, or `FlowFunctionLoweredDeclarationSchema`), `hooks[].handler` is a string. **Record stage** — what `SchemaRegistry.installPackage` stores: the artifact stage with `functions[].handler` OPTIONAL, in both the map-record and the array form. That single difference is the whole distance between the two: `build` mints a ref for every callable, while `toRecordManifest`'s structural projection DROPS the callable and mints nothing in its place, so a record states what each function is named and what it declared with `handler` absent where the callable was. Measured: both bodies convert under `z.toJSONSchema` over the whole body, where the assembled body still does not. +- **`FlowFunctionLoweredDeclarationSchema` is exported** from `@objectstack/spec/automation`, with its `FlowFunctionLoweredDeclaration` / `…Parsed` aliases. It was a module-local `const`, and `export * from './flow-function.zod'` only re-exports what is already exported — so `unemitted-schemas.baseline.json`'s reason for `Automation.FlowFunctionDeclarationSchema`, which says the lowered record "is the serialisable half … and it publishes normally", pointed at a schema no consumer could reach. It publishes now: `automation/FlowFunctionLoweredDeclaration` is in the schema manifest. +- **`effect` is READ, not minted.** `FlowFunctionDeclarationSchema.effect` is `FlowFunctionEffectSchema.default('pure')` — a default, not a requirement — and the array member's is `.optional()` with no default. Both JSON stages inherit each form's optionality by deriving from it rather than restating it. +- **⚠️ What narrows, stated plainly**: on the two installed-package responses, `functions` and `hooks` move from `unknown` (accepts anything) to their declared JSON shapes. No row the doors really serve is withdrawn — measured through the real `SchemaRegistry.installPackage` on the shape `examples/app-showcase` ships, on the array form, and on the already-lowered body an artifact boot installs. Every other key, `objects` included, is checked exactly as before, and both stages still refuse an authoring glob and an unknown key. +- One correction in the same edit: `package-api.zod.ts` said those two members were also why `ArtifactPackageSchema` and `ObjectStackDefinitionSchema` publish no JSON Schema. They are not — `src/stack.zod.ts` is not one of the subpath namespaces `build-schemas.ts` walks, so neither is ever reached by the emit loop. diff --git a/.changeset/17518-registry-record-reports-every-function.md b/.changeset/17518-registry-record-reports-every-function.md new file mode 100644 index 00000000000..1f532c9932d --- /dev/null +++ b/.changeset/17518-registry-record-reports-every-function.md @@ -0,0 +1,12 @@ +--- +"@objectstack/objectql": patch +--- + +`GET /packages` reports every function a package declares. A bare callable `functions` entry is normalised to the declared form at the assembly boundary, so the registry record no longer drops it (#17518). + +`SchemaRegistry.installPackage` stores `toRecordManifest(manifest)`, a structural JSON projection whose rule is "a live object reached the record" and deliberately ⛔ not a key denylist. That rule treated the two authored `functions` spellings unequally through no fault of its own: a DECLARED entry (`{ handler, effect: 'writes' }`) is a plain object, so it survived with its callable dropped, while a BARE callable entry IS the callable, so the whole key vanished. `examples/app-showcase` ships one of each, so a package declaring two functions was reported as declaring one — a machine-readable read door under-reporting by construction. + +- **The repair is at the assembly boundary, ⛔ not in the projection.** `installPackage` makes the two spellings structurally equal before projecting, so the structural rule is untouched and no key name is special-cased. The projection then leaves `{ effect }` for both. +- **⛔ No ref is minted.** `objectstack build` mints refs with `uniqueName(base, taken)` and dedupes by function identity, so a ref minted in the registry is not guaranteed to be the one `build` mints — a record could assert a handler that resolves in no sibling module. An absent `handler` is the honest statement "declared here, not serialisable", which is exactly what `@objectstack/spec`'s new `RecordStagePackageBodySchema` declares. +- **⛔ No entry is dropped**, either: under-reporting by design was the other arm, and it also throws away the `effect` declaration, the one half that survived. +- The caller's manifest is never mutated — `ObjectQL.registerApp` and the hook binder read the live callables off that object — and a copy is made only when an entry really needed rewriting. The ARRAY form is untouched: its entries are objects carrying their own `name`, so the projection already kept them. diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index d09cd420711..2c9e97b8cd8 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -47,7 +47,7 @@ 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 | +| **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 | @@ -89,7 +89,7 @@ Installed package row whose manifest is the assembled package body | **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** | `any` | optional | Named handler functions, as they survived the record JSON projection | +| **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 | @@ -113,7 +113,7 @@ Installed package row whose manifest is the assembled package body | **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** | `any` | optional | Object lifecycle hooks, as they survived the record JSON projection | +| **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). | @@ -208,7 +208,7 @@ 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 | +| **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 | @@ -303,7 +303,7 @@ 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 | +| **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 | @@ -345,7 +345,7 @@ Installed package row whose manifest is the assembled package body | **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** | `any` | optional | Named handler functions, as they survived the record JSON projection | +| **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 | @@ -369,7 +369,7 @@ Installed package row whose manifest is the assembled package body | **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** | `any` | optional | Object lifecycle hooks, as they survived the record JSON projection | +| **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). | diff --git a/content/docs/references/automation/flow-function.mdx b/content/docs/references/automation/flow-function.mdx index 83f1451ed5b..5159d97a622 100644 --- a/content/docs/references/automation/flow-function.mdx +++ b/content/docs/references/automation/flow-function.mdx @@ -65,8 +65,8 @@ the platform's own counters stop being wrong for it. ## TypeScript Usage ```typescript -import { FlowFunctionEffectSchema } from '@objectstack/spec/automation'; -import type { FlowFunctionEffect } from '@objectstack/spec/automation'; +import { FlowFunctionEffectSchema, FlowFunctionLoweredDeclarationSchema } from '@objectstack/spec/automation'; +import type { FlowFunctionEffect, FlowFunctionLoweredDeclaration } from '@objectstack/spec/automation'; // Validate data const result = FlowFunctionEffectSchema.parse(data); @@ -86,3 +86,17 @@ What a script-node function does to data: 'pure' (computes and returns — the c --- +## FlowFunctionLoweredDeclaration + +A lowered `functions` declaration: what the function declared about itself, with its callable replaced by a handler ref + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **handler** | `string` | ✅ | The lowered handler ref (built artifacts) — the callable rides in the sibling ESM module | +| **effect** | `Enum<'pure' \| 'writes'>` | optional (default: `"pure"`) | What the function does to data — omit for the pure default | + + +--- + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 7e1f9b6f13e..a2e0ce9dd5c 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1534 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1535 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | :--- | ---: | ---: | :--- | | [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. | -| [Automation Protocol](/docs/references/automation) | 14 | 74 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | +| [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. | | [Integration Protocol](/docs/references/integration) | 1 | 24 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | @@ -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 | 273 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 158 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **195** | **1534** | 14 protocol modules | +| **Total** | **195** | **1535** | 14 protocol modules | --- @@ -104,7 +104,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. ## Automation Protocol -**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **14 pages, 74 schemas** +**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **14 pages, 75 schemas** Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. @@ -116,7 +116,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu | [`control-flow.zod.ts`](/docs/references/automation/control-flow) | `FlowRegion`, `LoopConfig`, `ParallelBranch`, `ParallelConfig`, `RetryPolicy`, `TryCatchConfig`, `TryCatchErrorValue` | | [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary`, `ScheduleState` | | [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` | -| [`flow-function.zod.ts`](/docs/references/automation/flow-function) | `FlowFunctionEffect` | +| [`flow-function.zod.ts`](/docs/references/automation/flow-function) | `FlowFunctionEffect`, `FlowFunctionLoweredDeclaration` | | [`io-node-config.zod.ts`](/docs/references/automation/io-node-config) | `HttpConfig`, `NotifyConfig` | | [`node-executor.zod.ts`](/docs/references/automation/node-executor) | `ActionCategory`, `ActionDescriptor`, `ActionParadigm`, `NodeExecutorDescriptor`, `WaitEventType`, `WaitExecutorConfig`, `WaitResumePayload`, `WaitTimeoutBehavior` | | [`schedule-organization.zod.ts`](/docs/references/automation/schedule-organization) | `ScheduleOrganization` | diff --git a/packages/objectql/src/registry-package-manifest-serializable.test.ts b/packages/objectql/src/registry-package-manifest-serializable.test.ts index b52c8991e47..1f4a8dad473 100644 --- a/packages/objectql/src/registry-package-manifest-serializable.test.ts +++ b/packages/objectql/src/registry-package-manifest-serializable.test.ts @@ -192,3 +192,127 @@ describe('SchemaRegistry.installPackage — the record is serializable', () => { expect(registry.getNamespaceOwners('showcase')).toEqual(['com.example.showcase']); }); }); + +/** + * #17518 — the record reports EVERY declared function, in one shape. + * + * The projection above is structural and stays so: 「a live object reached the + * record」, ⛔ never a key denylist. That rule treated the two authored + * `functions` spellings unequally through no fault of its own — a DECLARED + * entry (`{ handler, effect }`) is a plain object, so it survived with its + * callable dropped, while a BARE callable entry IS the callable, so the whole + * key disappeared. `examples/app-showcase` ships one of each, so a package + * declaring two functions was reported as declaring one on `GET /packages`. + * + * The repair makes the two spellings structurally EQUAL before the projection + * runs, at the assembly boundary. ⛔ No ref is minted: `objectstack build` + * mints refs with `uniqueName(base, taken)` and dedupes by function identity, + * so a ref minted here could name a handler that resolves in no sibling module. + * An ABSENT `handler` is the honest statement 「declared here, not + * serialisable」, and `RecordStagePackageBodySchema` in `@objectstack/spec` + * declares exactly that. + */ +describe('SchemaRegistry.installPackage — the record reports every declared function', () => { + let registry: SchemaRegistry; + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + registry.logLevel = 'silent'; + }); + + /** The two spellings `examples/app-showcase/objectstack.config.ts` ships. */ + const showcaseFunctions = () => ({ + summarizeCompletedTask: () => 'summary', + sweepProjectHealth: { handler: () => 'swept', effect: 'writes' as const }, + }); + + it('records BOTH spellings — the bare one as a handler-less declaration', () => { + registry.installPackage(baseManifest({ functions: showcaseFunctions() })); + const stored = registry.getPackage('com.example.showcase')!.manifest as any; + + expect(Object.keys(stored.functions).sort()).toEqual(['summarizeCompletedTask', 'sweepProjectHealth']); + // The bare entry keeps what the bare spelling MEANS — the declaration + // `effect: 'pure'`, written the short way — read off the declaration + // schema's own default rather than invented here. + expect(stored.functions.summarizeCompletedTask).toEqual({ effect: 'pure' }); + // The declared entry is unchanged in substance: only the callable is gone. + expect(stored.functions.sweepProjectHealth).toEqual({ effect: 'writes' }); + }); + + it('⛔ mints no handler ref — an absent `handler` is the honest statement', () => { + registry.installPackage(baseManifest({ functions: showcaseFunctions() })); + const stored = registry.getPackage('com.example.showcase')!.manifest as any; + + for (const entry of Object.values(stored.functions) as Array>) { + expect('handler' in entry).toBe(false); + } + }); + + it('leaves an ALREADY LOWERED body exactly as it found it', () => { + // What `objectstack build` writes, and what an artifact boot installs when + // no runtime module re-attached the callables: a bare ref and a lowered + // record. Neither is a callable, so neither is normalised. + const lowered = { bare: 'bare', declared: { handler: 'declared', effect: 'writes' } }; + registry.installPackage(baseManifest({ functions: lowered })); + + expect((registry.getPackage('com.example.showcase')!.manifest as any).functions).toEqual(lowered); + }); + + it('leaves the ARRAY form alone — its entries are objects that name themselves', () => { + // An array entry carries its own `name`, so the projection already keeps + // it; only its callable goes. Its `effect` is `.optional()` with NO + // default, so ⛔ nothing is written where the schema declares nothing. + registry.installPackage(baseManifest({ + functions: [{ name: 'syncBilling', handler: () => 'billed', effect: 'writes' }], + })); + const stored = registry.getPackage('com.example.showcase')!.manifest as any; + + expect(stored.functions).toEqual([{ name: 'syncBilling', effect: 'writes' }]); + const bare = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + bare.logLevel = 'silent'; + bare.installPackage(baseManifest({ functions: [{ name: 'syncBilling', handler: () => 'billed' }] })); + expect((bare.getPackage('com.example.showcase')!.manifest as any).functions) + .toEqual([{ name: 'syncBilling' }]); + }); + + it('⛔ does not mutate the caller’s manifest — the live callables stay live', () => { + // `ObjectQL.registerApp` and the hook binder read the callables off THIS + // object. Normalising must copy, exactly as the projection does. + const functions = showcaseFunctions(); + const manifest = baseManifest({ functions }); + registry.installPackage(manifest); + + expect(typeof manifest.functions.summarizeCompletedTask).toBe('function'); + expect(manifest.functions).toBe(functions); + expect(typeof functions.sweepProjectHealth.handler).toBe('function'); + }); + + it('touches nothing when there is nothing to normalise', () => { + // A manifest with no `functions` key, and one whose entries are all already + // declared, must reach the projection as the SAME object — the normaliser + // copies only when it really rewrote an entry. + const untouched = baseManifest(); + registry.installPackage(untouched); + const stored = registry.getPackage('com.example.showcase')!.manifest as any; + expect('functions' in stored).toBe(false); + expect(stored.objects).toEqual(untouched.objects); + }); + + it('hooks are NOT normalised — an inline handler is simply dropped', () => { + // `hooks[].handler` is optional on its own declaration, so a hook whose + // inline callable is projected away is still a well-formed record. ⛔ This + // repair does not reach into a second collection. + registry.installPackage(baseManifest({ + hooks: [ + { name: 'on_insert', object: 'invoice', events: ['beforeInsert'], handler: () => 'hooked' }, + { name: 'on_update', object: 'invoice', events: ['beforeUpdate'], handler: 'on_update' }, + ], + })); + const stored = registry.getPackage('com.example.showcase')!.manifest as any; + + expect(stored.hooks).toEqual([ + { name: 'on_insert', object: 'invoice', events: ['beforeInsert'] }, + { name: 'on_update', object: 'invoice', events: ['beforeUpdate'], handler: 'on_update' }, + ]); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index f75d202b2b9..b0af60d72dc 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -45,6 +45,7 @@ import { resolveTenancyPosture, resolveSearchPinyinEnabled } from '@objectstack/ import { postureEnforcesWall } from '@objectstack/spec/security'; import { provisionSearchCompanion, SEARCH_COMPANION_FIELD } from './search-companion.js'; import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema, checkFieldCompleteness } from '@objectstack/spec/kernel'; +import { DEFAULT_FLOW_FUNCTION_EFFECT } from '@objectstack/spec/automation'; import { AppSchema } from '@objectstack/spec/ui'; import { applyProtection } from '@objectstack/spec/shared'; // [ADR-0130 D1] The ONE derivation of the key a package is installed under @@ -1364,6 +1365,74 @@ function toRecordManifest(manifest: ObjectStackManifest): ObjectStackManifest { return out as ObjectStackManifest; } +/** + * Rewrite a BARE callable `functions` map entry into the declared form + * (`{ handler, effect }`) before {@link toRecordManifest} projects the body. + * + * ## The under-report this closes + * + * `functions` accepts two authored spellings, and the projection treats them + * unequally through no fault of its own: + * + * - `{ sweepProjectHealth: { handler: fn, effect: 'writes' } }` — the value is + * a plain object, so it survives with its callable dropped: `{ effect: + * 'writes' }`. The function is still REPORTED, minus what cannot serialise. + * - `{ summarizeCompletedTask: fn }` — the value IS the callable, so the whole + * ENTRY is dropped, key and all. The function vanishes from the record. + * + * `examples/app-showcase` ships one of each, so `GET /packages` reported ONE + * function for a package declaring TWO. A machine-readable read door that + * under-reports is a defect independent of how its rows are declared. + * + * ## Why it is fixed HERE and not in the projection + * + * {@link toRecordManifest}'s rule is STRUCTURAL — 「a live object reached the + * record」, ⛔ never a key denylist — and teaching it that a function found + * under `functions.` means something other than a function found + * anywhere else would overturn exactly that. So the repair is at the assembly + * boundary instead: the two spellings are made structurally EQUAL before the + * projection runs, and the projection then leaves `{ effect }` for both. + * + * ⛔ No ref is minted. `packages/cli`'s `lowerCallables` mints refs with + * `uniqueName(base, taken)` and dedupes by function IDENTITY, so a ref minted + * here would not be guaranteed equal to the one `objectstack build` mints — a + * record could then assert a handler that resolves in no sibling module. An + * ABSENT `handler` on a record is the honest statement 「declared here, not + * serialisable」, and `RecordStagePackageBodySchema` in `@objectstack/spec` + * declares exactly that. + * + * `effect` is the declaration schema's OWN default + * ({@link DEFAULT_FLOW_FUNCTION_EFFECT}, `'pure'`, which + * `FlowFunctionDeclarationSchema.effect` carries as `.default(…)`), ⛔ not a + * value invented here: the bare spelling IS that declaration written the short + * way, and `normalizeFlowFunctionEntry` already reads it the same way at boot. + * The ARRAY form needs nothing — its entries are objects that carry their own + * `name`, so the projection keeps them — and its `effect` is `.optional()` with + * no default, so nothing is written where the schema declares nothing. + * + * ⛔ The caller's manifest is never mutated: the live object is what every + * other read in `installPackage` uses, and a copy is returned only when an + * entry really needed rewriting. + */ +function withDeclaredFunctionEntries(manifest: ObjectStackManifest): ObjectStackManifest { + if (manifest === null || typeof manifest !== 'object') return manifest; + const functions = (manifest as { functions?: unknown }).functions; + if (!functions || typeof functions !== 'object' || Array.isArray(functions)) return manifest; + + let rewrote = false; + const declared: Record = {}; + for (const [name, entry] of Object.entries(functions as Record)) { + if (typeof entry === 'function') { + declared[name] = { handler: entry, effect: DEFAULT_FLOW_FUNCTION_EFFECT }; + rewrote = true; + } else { + declared[name] = entry; + } + } + if (!rewrote) return manifest; + return { ...manifest, functions: declared } as ObjectStackManifest; +} + /** * [#16159] The ADR-0112 `code` strings this file's three install-time and * registration refusals carry, as constants a consumer can import. @@ -4224,7 +4293,12 @@ export class SchemaRegistry { // {@link toRecordManifest}. Every other read below (`manifest.id`, // `manifest.namespace`) deliberately keeps reading the ARGUMENT: the // projection is what the registry hands out, never what it decides with. - manifest: toRecordManifest(manifest), + // + // {@link withDeclaredFunctionEntries} runs FIRST and at this boundary + // only: it makes the two authored `functions` spellings structurally + // equal so the structural projection reports BOTH, without teaching the + // projection a key name. + manifest: toRecordManifest(withDeclaredFunctionEntries(manifest)), ...lifecycle, installedAt: now, updatedAt: now, diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 6189e5214c2..7dd61f5447b 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -130,6 +130,9 @@ "FlowFunctionEntry (type)", "FlowFunctionEntryParsed (type)", "FlowFunctionEntrySchema (const)", + "FlowFunctionLoweredDeclaration (type)", + "FlowFunctionLoweredDeclarationParsed (type)", + "FlowFunctionLoweredDeclarationSchema (const)", "FlowGraph (interface)", "FlowNode (type)", "FlowNodeAction (const)", diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index d0a072af865..63797641405 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -15,6 +15,9 @@ "ArtifactPackageEntrySchema (const)", "ArtifactPackageParsed (type)", "ArtifactPackageSchema (const)", + "ArtifactStagePackageBody (type)", + "ArtifactStagePackageBodyParsed (type)", + "ArtifactStagePackageBodySchema (const)", "AssembledPackageBody (type)", "AssembledPackageBodyParsed (type)", "AssembledPackageBodySchema (const)", @@ -130,6 +133,9 @@ "PreviousReleaseRegistries (interface)", "RETIRED_DEFS_BY_MAJOR (const)", "RETIRED_KEYS_BY_MAJOR (const)", + "RecordStagePackageBody (type)", + "RecordStagePackageBodyParsed (type)", + "RecordStagePackageBodySchema (const)", "ReleaseSurfaceDiff (interface)", "STACK_DEFINITION_KEYS (const)", "STACK_KEY_GUIDANCE (const)", diff --git a/packages/spec/authorable-defaults/automation.json b/packages/spec/authorable-defaults/automation.json index 21266b7d520..1cd83127d43 100644 --- a/packages/spec/authorable-defaults/automation.json +++ b/packages/spec/authorable-defaults/automation.json @@ -42,6 +42,7 @@ "automation/Flow:version = 1", "automation/FlowEdge:isDefault = false", "automation/FlowEdge:type = \"default\"", + "automation/FlowFunctionLoweredDeclaration:effect = \"pure\"", "automation/FlowVariable:isInput = false", "automation/FlowVariable:isOutput = false", "automation/LoopConfig:iteratorVariable = \"item\"", diff --git a/packages/spec/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json index 35396430b34..a3cb0dd2dd6 100644 --- a/packages/spec/authorable-surface/automation.json +++ b/packages/spec/authorable-surface/automation.json @@ -178,6 +178,8 @@ "automation/FlowEdge:source", "automation/FlowEdge:target", "automation/FlowEdge:type", + "automation/FlowFunctionLoweredDeclaration:effect", + "automation/FlowFunctionLoweredDeclaration:handler", "automation/FlowNode:boundaryConfig", "automation/FlowNode:config", "automation/FlowNode:connectorConfig", diff --git a/packages/spec/declaration-map/automation.json b/packages/spec/declaration-map/automation.json index 7436be618c4..97be6e386b4 100644 --- a/packages/spec/declaration-map/automation.json +++ b/packages/spec/declaration-map/automation.json @@ -69,8 +69,11 @@ "Flow": "automation/Flow", "FlowEdge": "automation/FlowEdge", "FlowEdgeSchema": "automation/FlowEdge", + "FlowFunctionDeclarationSchema": "automation/FlowFunctionLoweredDeclaration", "FlowFunctionEffect": "automation/FlowFunctionEffect", "FlowFunctionEffectSchema": "automation/FlowFunctionEffect", + "FlowFunctionLoweredDeclaration": "automation/FlowFunctionLoweredDeclaration", + "FlowFunctionLoweredDeclarationSchema": "automation/FlowFunctionLoweredDeclaration", "FlowNode": "automation/FlowNode", "FlowNodeAction": "automation/FlowNodeAction", "FlowNodeSchema": "automation/FlowNode", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 38c06eb037d..7e6d0dd6876 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 205, - "droppedRefinementSites": 561, + "droppedRefinementSites": 565, "refinementSitesThatDidProject": 366, "refinementSitesWithNoJsonFormToCompare": 9 }, @@ -61,6 +61,7 @@ "manifest.flows.element", "manifest.flows.element.errorHandling", "manifest.flows.element.nodes.element.in.waitEventConfig", + "manifest.hooks.element.object", "manifest.jobs.element.schedule.options[0].timezone", "manifest.navigationContributions.element.items.element.lazy.options[0]", "manifest.objectExtensions.element", @@ -158,6 +159,7 @@ "data.options[1].manifest.flows.element", "data.options[1].manifest.flows.element.errorHandling", "data.options[1].manifest.flows.element.nodes.element.in.waitEventConfig", + "data.options[1].manifest.hooks.element.object", "data.options[1].manifest.jobs.element.schedule.options[0].timezone", "data.options[1].manifest.objectExtensions.element", "data.options[1].manifest.objects.element", @@ -239,6 +241,7 @@ "options[1].manifest.flows.element", "options[1].manifest.flows.element.errorHandling", "options[1].manifest.flows.element.nodes.element.in.waitEventConfig", + "options[1].manifest.hooks.element.object", "options[1].manifest.jobs.element.schedule.options[0].timezone", "options[1].manifest.objectExtensions.element", "options[1].manifest.objects.element", @@ -281,6 +284,7 @@ "data.packages.element.options[1].manifest.flows.element", "data.packages.element.options[1].manifest.flows.element.errorHandling", "data.packages.element.options[1].manifest.flows.element.nodes.element.in.waitEventConfig", + "data.packages.element.options[1].manifest.hooks.element.object", "data.packages.element.options[1].manifest.jobs.element.schedule.options[0].timezone", "data.packages.element.options[1].manifest.objectExtensions.element", "data.packages.element.options[1].manifest.objects.element", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 1eaea1c7be5..8b1dd265d11 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -126,6 +126,9 @@ "FlowFunctionEntry": "src/automation/flow-function.zod.ts#FlowFunctionEntry (type)", "FlowFunctionEntryParsed": "src/automation/flow-function.zod.ts#FlowFunctionEntryParsed (type)", "FlowFunctionEntrySchema": "src/automation/flow-function.zod.ts#FlowFunctionEntrySchema (const)", + "FlowFunctionLoweredDeclaration": "src/automation/flow-function.zod.ts#FlowFunctionLoweredDeclaration (type)", + "FlowFunctionLoweredDeclarationParsed": "src/automation/flow-function.zod.ts#FlowFunctionLoweredDeclarationParsed (type)", + "FlowFunctionLoweredDeclarationSchema": "src/automation/flow-function.zod.ts#FlowFunctionLoweredDeclarationSchema (const)", "FlowGraph": "src/automation/control-flow.zod.ts#FlowGraph (interface)", "FlowNode": "src/automation/flow.zod.ts#FlowNode (type)", "FlowNodeAction": "src/automation/flow.zod.ts#FlowNodeAction (type)", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index aae573e5662..206c67586bd 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -15,6 +15,9 @@ "ArtifactPackageEntrySchema": "src/stack.zod.ts#ArtifactPackageEntrySchema (const)", "ArtifactPackageParsed": "src/stack.zod.ts#ArtifactPackageParsed (type)", "ArtifactPackageSchema": "src/stack.zod.ts#ArtifactPackageSchema (const)", + "ArtifactStagePackageBody": "src/stack.zod.ts#ArtifactStagePackageBody (type)", + "ArtifactStagePackageBodyParsed": "src/stack.zod.ts#ArtifactStagePackageBodyParsed (type)", + "ArtifactStagePackageBodySchema": "src/stack.zod.ts#ArtifactStagePackageBodySchema (const)", "AssembledPackageBody": "src/stack.zod.ts#AssembledPackageBody (type)", "AssembledPackageBodyParsed": "src/stack.zod.ts#AssembledPackageBodyParsed (type)", "AssembledPackageBodySchema": "src/stack.zod.ts#AssembledPackageBodySchema (const)", @@ -129,6 +132,9 @@ "PreviousReleaseRegistries": "src/migrations/spec-changes.ts#PreviousReleaseRegistries (interface)", "RETIRED_DEFS_BY_MAJOR": "src/migrations/registry.ts#RETIRED_DEFS_BY_MAJOR (const)", "RETIRED_KEYS_BY_MAJOR": "src/migrations/registry.ts#RETIRED_KEYS_BY_MAJOR (const)", + "RecordStagePackageBody": "src/stack.zod.ts#RecordStagePackageBody (type)", + "RecordStagePackageBodyParsed": "src/stack.zod.ts#RecordStagePackageBodyParsed (type)", + "RecordStagePackageBodySchema": "src/stack.zod.ts#RecordStagePackageBodySchema (const)", "ReleaseSurfaceDiff": "src/migrations/spec-changes.ts#ReleaseSurfaceDiff (interface)", "STACK_DEFINITION_KEYS": "src/stack.zod.ts#STACK_DEFINITION_KEYS (const)", "STACK_KEY_GUIDANCE": "src/data/authoring-key-lint.ts#STACK_KEY_GUIDANCE (const)", diff --git a/packages/spec/json-schema.manifest/automation.json b/packages/spec/json-schema.manifest/automation.json index 0ebb133885f..c42c760c510 100644 --- a/packages/spec/json-schema.manifest/automation.json +++ b/packages/spec/json-schema.manifest/automation.json @@ -39,6 +39,7 @@ "automation/Flow", "automation/FlowEdge", "automation/FlowFunctionEffect", + "automation/FlowFunctionLoweredDeclaration", "automation/FlowNode", "automation/FlowNodeAction", "automation/FlowRegion", diff --git a/packages/spec/src/api/package-api.test.ts b/packages/spec/src/api/package-api.test.ts index 929402ccba0..882fc97f660 100644 --- a/packages/spec/src/api/package-api.test.ts +++ b/packages/spec/src/api/package-api.test.ts @@ -653,7 +653,7 @@ describe('`InstalledPackageAtEitherStageSchema` admits both stages and NOTHING e }); }); -describe('the record-body override set is MEASURED, never hand-picked', () => { +describe('the set the record stage must RE-DECLARE is MEASURED, never hand-picked', () => { /** Does this schema have a JSON Schema form at all? */ const emits = (schema: unknown): boolean => { try { @@ -665,12 +665,12 @@ describe('the record-body override set is MEASURED, never hand-picked', () => { }; it('exactly `functions` and `hooks` have no JSON form on the assembled body', () => { - // The two published response schemas below embed the assembled body. Any - // collection with no JSON form makes them BOTH vanish from - // `json-schema/api/`, which the build's disappearance ratchet refuses — so - // the read-API record body overrides exactly this set, and this pin is what - // keeps the two in step. A new non-serialisable collection reddens HERE, - // naming itself, rather than unpublishing two response schemas. + // The two published response schemas below embed the row, whose manifest is + // the RECORD stage. Any collection with no JSON form makes them BOTH vanish + // from `json-schema/api/`, which the build's disappearance ratchet refuses + // — so the record stage declares exactly this set in its lowered form, and + // this pin is what keeps the two in step. A new non-serialisable collection + // reddens HERE, naming itself, rather than unpublishing two responses. const shape = (AssembledPackageBodySchema as unknown as { shape: Record }).shape; const noJsonForm = Object.keys(shape).filter((k) => !emits(shape[k])); expect(noJsonForm.sort()).toEqual(['functions', 'hooks']); @@ -684,6 +684,63 @@ describe('the record-body override set is MEASURED, never hand-picked', () => { }); }); +describe('#17518 the row\'s manifest is the RECORD stage — a declaration, ⛔ not `z.unknown()`', () => { + /** + * What `toRecordManifest` really leaves on a `GET /packages` row: each + * `functions` declaration MINUS its callable, and a hook whose inline handler + * is gone. Until #17518 both keys were `z.unknown().optional()` here, i.e. + * accepted without being checked. + */ + const RECORD_ROW = { + ...LIFECYCLE, + manifest: { + ...MANIFEST_BASE, + objects: [{ name: 'stage_lead', fields: { title: { type: 'text' } } }], + functions: { + summarizeCompletedTask: { effect: 'pure' }, + sweepProjectHealth: { effect: 'writes' }, + }, + hooks: [{ name: 'on_insert', object: 'stage_lead', events: ['beforeInsert'] }], + }, + }; + + it('parses a row carrying the residual the projection really produces', () => { + expect(AssembledInstalledPackageSchema.safeParse(RECORD_ROW).success).toBe(true); + expect(InstalledPackageAtEitherStageSchema.safeParse(RECORD_ROW).success).toBe(true); + }); + + it('parses a row carrying what `objectstack build` lowered', () => { + const lowered = { + ...RECORD_ROW, + manifest: { + ...RECORD_ROW.manifest, + functions: { bare: 'bare', declared: { handler: 'declared', effect: 'writes' } }, + hooks: [{ name: 'on_insert', object: 'stage_lead', events: ['beforeInsert'], handler: 'on_insert' }], + }, + }; + expect(AssembledInstalledPackageSchema.safeParse(lowered).success).toBe(true); + }); + + it('⛔ REFUSES a live callable — a row the registry can never serve', () => { + // The direction that matters: the two keys moved from "accepts anything" to + // a declaration, so a value no JSON row can hold is refused by name instead + // of waved through. ⛔ Never widen either key back to `unknown` to make a + // payload fit: a row parsing through neither declared stage is a producer + // defect. + const live = { + ...RECORD_ROW, + manifest: { ...RECORD_ROW.manifest, functions: { sweepProjectHealth: () => 'ran' } }, + }; + const verdict = AssembledInstalledPackageSchema.safeParse(live); + expect(verdict.success).toBe(false); + expect(verdict.error!.issues.some((i) => i.path.join('.').startsWith('manifest.functions'))).toBe(true); + }); + + it('⛔ still refuses the AUTHORING spelling of `objects` — the stage boundary did not move', () => { + expect(AssembledInstalledPackageSchema.safeParse(GLOB_ROW).success).toBe(false); + }); +}); + describe('the read-API responses are declared at both stages (#17431)', () => { const envelope = (data: unknown) => ({ success: true, data }); diff --git a/packages/spec/src/api/package-api.zod.ts b/packages/spec/src/api/package-api.zod.ts index acb73653a4a..7204ae4f233 100644 --- a/packages/spec/src/api/package-api.zod.ts +++ b/packages/spec/src/api/package-api.zod.ts @@ -9,7 +9,7 @@ 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 { AssembledPackageBodySchema } from '../stack.zod'; +import { RecordStagePackageBodySchema } from '../stack.zod'; /** * # Package API Protocol @@ -77,15 +77,9 @@ export type PackagePathParams = z.input; * 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 — with - * the one measured exception {@link AssembledPackageRecordBodySchema} states - * and pins. - */ -/** - * The assembled package body AS THE REGISTRY RECORDS IT — the same declaration, - * with the two collections that have no JSON form left unchecked. + * declaration, so a wrong-shaped body is refused exactly as it is there. * - * ## Why this exists at all, measured rather than assumed + * ## 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 @@ -97,37 +91,34 @@ export type PackagePathParams = z.input; * - `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, which is also - * why `ArtifactPackageSchema` and `ObjectStackDefinitionSchema` publish none. - * 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 and whose only other remedy is retiring two - * published defs. `build-schemas.ts` names the remedy taken here instead: + * 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». * - * ⛔ The override set is NOT hand-picked, and must never become so. It is the - * measured set of shape members with no JSON form, pinned key-by-key in - * `./package-api.test.ts`: a new collection with no JSON form reddens there, - * naming itself, instead of silently unpublishing these responses again. - * - * ⚠️ What `unknown` costs, stated plainly: on THIS surface those two keys are - * accepted without being checked. It is a widening from today, where both are - * refused outright by `ManifestSchema`'s strict close while the door really can - * serve them — so the declaration moves from wrong to incomplete, never from - * checked to tolerant. Every other key, `objects` included, is checked at the - * assembled stage exactly as `AssembledPackageBodySchema` declares it. The - * ARTIFACT surface is untouched and keeps both collections fully declared. + * ⚠️ ⛔ 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. */ -const AssembledPackageRecordBodySchema = lazySchema(() => - (AssembledPackageBodySchema as unknown as z.ZodObject).extend({ - functions: z.unknown().optional() - .describe('Named handler functions, as they survived the record JSON projection'), - hooks: z.unknown().optional() - .describe('Object lifecycle hooks, as they survived the record JSON projection'), - }).describe('One package as assembled, as the registry RECORDS it (JSON only)')); - export const AssembledInstalledPackageSchema = lazySchema(() => InstalledPackageSchema.extend({ - manifest: AssembledPackageRecordBodySchema.describe('The ASSEMBLED package body this row carries'), + 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). */ diff --git a/packages/spec/src/automation/flow-function.zod.ts b/packages/spec/src/automation/flow-function.zod.ts index 24358351950..11f8f16f8d5 100644 --- a/packages/spec/src/automation/flow-function.zod.ts +++ b/packages/spec/src/automation/flow-function.zod.ts @@ -207,12 +207,25 @@ export type FlowFunctionDeclarationParsed = z.infer FlowFunctionDeclarationSchema.extend({ +export const FlowFunctionLoweredDeclarationSchema = lazySchema(() => FlowFunctionDeclarationSchema.extend({ handler: z.string().min(1) .describe('The lowered handler ref (built artifacts) — the callable rides in the sibling ESM module'), }).describe('A lowered `functions` declaration: what the function declared about itself, with its callable replaced by a handler ref')); +export type FlowFunctionLoweredDeclaration = z.input; +/** Post-parse shape of {@link FlowFunctionLoweredDeclaration} — defaults applied, transforms run (ADR-0122). */ +export type FlowFunctionLoweredDeclarationParsed = z.infer; + /** * One entry of the `functions` map, in the four shapes it legitimately takes: * the handler alone (pure), a {@link FlowFunctionDeclarationSchema} that states diff --git a/packages/spec/src/stack-json-stage-package-body.test.ts b/packages/spec/src/stack-json-stage-package-body.test.ts new file mode 100644 index 00000000000..359b1ba7081 --- /dev/null +++ b/packages/spec/src/stack-json-stage-package-body.test.ts @@ -0,0 +1,252 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #17518 — the two JSON stages of a package body, declared beside the assembled + * one, and pinned as three genuinely different shapes rather than three names + * for one. + * + * ## The defect, stated as the measurement that found it + * + * ADR-0130 D4 says an artifact is inert JSON: 「a plugin written inside + * `packages[i].manifest` could never be constructed by a loader, so a reader + * that resolved it there would register garbage where it used to skip in + * silence.」 Of {@link AssembledPackageBodySchema}'s 55 members exactly two + * declare that they accept a callable — `functions` (a `z.function()` branch) + * and `hooks` (a `z.custom()` branch) — and one unrepresentable member costs + * EVERY embedder its whole JSON Schema. That is why the installed-package read + * responses could only carry the body with both keys written `z.unknown()`: + * accepted without being checked. + * + * ⛔ The remedy is NOT narrowing the assembled body. Those callables are LIVE + * on the stage that schema declares itself for — `composeStacks(stacks, { + * manifest: 'preserve' })` builds exactly such a body and the load path + * registers it — so narrowing in place would refuse a published composition + * function's own output. The stages get their own declarations instead, and + * this file's job is to keep them from collapsing back into one. + * + * ## The four stages, and which two are new + * + * | stage | declaration | `functions` entry | + * |---|---|---| + * | authoring | `ManifestSchema` + the stack's collections | anything the author writes | + * | in-memory assembled | {@link AssembledPackageBodySchema} | a live callable, or lowered | + * | on-disk artifact | {@link ArtifactStagePackageBodySchema} | lowered ONLY | + * | registry record | {@link RecordStagePackageBodySchema} | lowered, `handler` optional | + * + * The last two are new. They differ from each other in exactly one thing — + * whether `handler` is required — because that is the whole distance between + * what `objectstack build` writes (it lowers every callable to a ref) and what + * `toRecordManifest` projects (it DROPS the callable and mints nothing in its + * place, which is correct: a ref minted anywhere but `build` is not guaranteed + * to be the ref `build` mints). + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +import { + ArtifactStagePackageBodySchema, + AssembledPackageBodySchema, + RecordStagePackageBodySchema, +} from './stack.zod'; +import * as Automation from './automation'; +import { FlowFunctionLoweredDeclarationSchema } from './automation/flow-function.zod'; + +/** Does this schema have a JSON Schema form at all? */ +const emits = (schema: unknown): boolean => { + try { + z.toJSONSchema(schema as never, { io: 'input' } as never); + return true; + } catch { + return false; + } +}; + +const shapeKeys = (schema: unknown): string[] => + Object.keys((schema as { shape: Record }).shape).sort(); + +const IDENTITY = { + id: 'com.example.stages', + name: 'Stages', + version: '1.0.0', + type: 'app' as const, + namespace: 'stages', +}; + +const liveCallable = () => 'ran'; + +/** What a `defineStack()` host hands the load path: the callable is still there. */ +const IN_MEMORY_BODY = { + ...IDENTITY, + functions: { + summarizeCompletedTask: liveCallable, + sweepProjectHealth: { handler: liveCallable, effect: 'writes' as const }, + }, + hooks: [{ name: 'on_insert', object: 'task', events: ['beforeInsert' as const], handler: liveCallable }], +}; + +/** What `objectstack build` writes into `dist/objectstack.json`: refs only. */ +const ARTIFACT_BODY = { + ...IDENTITY, + functions: { + summarizeCompletedTask: 'summarizeCompletedTask', + sweepProjectHealth: { handler: 'sweepProjectHealth', effect: 'writes' as const }, + }, + hooks: [{ name: 'on_insert', object: 'task', events: ['beforeInsert' as const], handler: 'on_insert' }], +}; + +/** + * What `toRecordManifest` leaves behind: each declaration MINUS its callable. + * The bare entry reaches this shape because `installPackage` normalises it to + * the declared form first — see `withDeclaredFunctionEntries` in + * `@objectstack/objectql`'s registry, which is what stops the record + * under-reporting a package's functions. + */ +const RECORD_BODY = { + ...IDENTITY, + functions: { + summarizeCompletedTask: { effect: 'pure' as const }, + sweepProjectHealth: { effect: 'writes' as const }, + }, + hooks: [{ name: 'on_insert', object: 'task', events: ['beforeInsert' as const] }], +}; + +describe('#17518 the two JSON stages CONVERT, which is the whole point of declaring them', () => { + it('both new bodies convert under `z.toJSONSchema` — over the WHOLE body, not two members', () => { + // Stated over the whole body on purpose: the tax this closes is paid by + // EMBEDDERS, and an embedder loses its JSON Schema to any one + // unrepresentable member anywhere beneath it. + expect(emits(ArtifactStagePackageBodySchema)).toBe(true); + expect(emits(RecordStagePackageBodySchema)).toBe(true); + }); + + it('CONTROL: the assembled body still does NOT convert, and that is correct', () => { + // Without this half the pin above could pass while measuring nothing. The + // assembled body keeps its callables because `composeStacks` really builds + // one; ⛔ it is not the schema to narrow. + expect(emits(AssembledPackageBodySchema)).toBe(false); + }); + + it('LIT and DARK controls on the probe itself', () => { + expect(emits(z.string())).toBe(true); + expect(emits(z.object({ a: z.function() as never }))).toBe(false); + }); + + it('the lowered declaration is REACHABLE from the `automation` namespace', () => { + // Step 1 of the ruling, and the reason + // `unemitted-schemas.baseline.json`'s entry for + // `Automation.FlowFunctionDeclarationSchema` can say the lowered record + // 「publishes normally」 without lying: `export *` only re-exports bindings + // that are already exported, so a module-local const was reachable by + // nobody. + expect(Object.keys(Automation)).toContain('FlowFunctionLoweredDeclarationSchema'); + expect(emits(FlowFunctionLoweredDeclarationSchema)).toBe(true); + }); +}); + +describe('#17518 the three stages have the SAME key set — they narrow, they do not drop', () => { + it('artifact and record carry every member the assembled body carries', () => { + const assembled = shapeKeys(AssembledPackageBodySchema); + expect(shapeKeys(ArtifactStagePackageBodySchema)).toEqual(assembled); + expect(shapeKeys(RecordStagePackageBodySchema)).toEqual(assembled); + // Anti-vacuity: the body really is the wide one, not an empty shape. + expect(assembled.length).toBeGreaterThan(40); + }); + + it('exactly `functions` and `hooks` are the members with no JSON form', () => { + // The set the two JSON stages have to re-declare is MEASURED, never + // hand-picked. A new collection with no JSON form reddens HERE, naming + // itself, instead of silently unpublishing the read-API responses. + const shape = (AssembledPackageBodySchema as unknown as { shape: Record }).shape; + expect(Object.keys(shape).filter((k) => !emits(shape[k])).sort()).toEqual(['functions', 'hooks']); + }); + + it('the JSON stages\' ARRAY member declares the same keys as the authoring one', () => { + // `functions`' array member is declared INLINE inside the assembled body's + // own shape, and narrowing it in place is the one thing this pair may not + // do — so the JSON stages transcribe it. This is the drift guard that + // transcription owes: a key added to the authoring array entry and not to + // the lowered one reddens by name. + const arrayEntryKeys = (body: unknown): string[] => { + const functions = (body as { shape: Record }).shape.functions; + const union = (functions as unknown as { def: { innerType: { def: { options: unknown[] } } } }).def.innerType; + const arrayMember = union.def.options.find( + (option) => (option as { def: { type: string } }).def.type === 'array', + ); + const element = (arrayMember as { def: { element: unknown } }).def.element; + return Object.keys((element as { shape: Record }).shape).sort(); + }; + const authored = arrayEntryKeys(AssembledPackageBodySchema); + expect(authored).toEqual(['effect', 'handler', 'name', 'packageId']); + expect(arrayEntryKeys(ArtifactStagePackageBodySchema)).toEqual(authored); + expect(arrayEntryKeys(RecordStagePackageBodySchema)).toEqual(authored); + }); +}); + +describe('#17518 each stage accepts ITS OWN payload and refuses the neighbouring ones', () => { + it('assembled accepts the live composed body; both JSON stages refuse it', () => { + expect(AssembledPackageBodySchema.safeParse(IN_MEMORY_BODY).success).toBe(true); + expect(ArtifactStagePackageBodySchema.safeParse(IN_MEMORY_BODY).success).toBe(false); + expect(RecordStagePackageBodySchema.safeParse(IN_MEMORY_BODY).success).toBe(false); + }); + + it('artifact accepts what `objectstack build` writes — BOTH lowered spellings', () => { + // `build` emits `{ myFn: 'myFn' }` for a bare entry and + // `{ myFn: { handler: 'myFn', effect } }` for a declared one, so a stage + // admitting only the record form would refuse artifacts this repo writes. + expect(ArtifactStagePackageBodySchema.safeParse(ARTIFACT_BODY).success).toBe(true); + expect(RecordStagePackageBodySchema.safeParse(ARTIFACT_BODY).success).toBe(true); + }); + + it('record accepts the handler-less declaration; ⛔ the ARTIFACT stage refuses it', () => { + // This asymmetry IS the fourth stage. An artifact always has a ref, because + // `build` mints one; a record never does, because the projection drops the + // callable and the registry ⛔ mints nothing. + expect(RecordStagePackageBodySchema.safeParse(RECORD_BODY).success).toBe(true); + expect(ArtifactStagePackageBodySchema.safeParse(RECORD_BODY).success).toBe(false); + }); + + it('⛔ neither JSON stage became tolerant — globs and unknown keys are still refused', () => { + // The two keys moved from `unknown` (accepts anything) to a declaration. + // ⛔ Nothing else moved, and this is where a future widening reddens. + for (const schema of [ArtifactStagePackageBodySchema, RecordStagePackageBodySchema]) { + expect(schema.safeParse({ ...IDENTITY, objects: ['./src/*.object.yml'] }).success).toBe(false); + expect(schema.safeParse({ ...IDENTITY, namesapce: 'typo' }).success).toBe(false); + expect(schema.safeParse({ ...IDENTITY, functions: { f: liveCallable } }).success).toBe(false); + expect(schema.safeParse({ ...IDENTITY, hooks: [{ name: 'h', object: 'task', events: ['beforeInsert'], handler: liveCallable }] }).success).toBe(false); + } + }); +}); + +describe('#17518 `effect` is READ off the declaration schema, never minted here', () => { + it('a lowered entry with no `effect` materialises the declaration default', () => { + // `FlowFunctionDeclarationSchema.effect` is + // `FlowFunctionEffectSchema.default(DEFAULT_FLOW_FUNCTION_EFFECT)` — a + // DEFAULT, not a requirement — and both JSON stages inherit that by + // deriving from it rather than restating it. + const parsed = ArtifactStagePackageBodySchema.parse({ + ...IDENTITY, + functions: { probeSweep: { handler: 'probeSweep' } }, + }) as { functions: Record }; + expect(parsed.functions.probeSweep.effect).toBe('pure'); + }); + + it('the ARRAY member states no default, so nothing is written where none is declared', () => { + const parsed = ArtifactStagePackageBodySchema.parse({ + ...IDENTITY, + functions: [{ name: 'probeSweep', handler: 'probeSweep' }], + }) as { functions: Array> }; + expect('effect' in parsed.functions[0]).toBe(false); + }); + + it('a misspelled `effect` is still refused by name on the lowered record', () => { + // The strictness travels with the derivation: the lowered form is + // `FlowFunctionDeclarationSchema.extend(...)`, so it keeps the named + // surface and the `` `efect` → `effect` `` prescription. + const verdict = ArtifactStagePackageBodySchema.safeParse({ + ...IDENTITY, + functions: { probeSweep: { handler: 'probeSweep', efect: 'writes' } }, + }); + expect(verdict.success).toBe(false); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index ee2c569c6fa..0773da2053e 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -34,7 +34,7 @@ import { ActionSchema, InlineActionSchema } from './ui/action.zod'; // Automation Protocol import { FlowSchema } from './automation/flow.zod'; import { resolveFlowTriggerKind } from './automation/flow-trigger-kind'; -import { FlowFunctionEntrySchema, FlowFunctionEffectSchema } from './automation/flow-function.zod'; +import { FlowFunctionEntrySchema, FlowFunctionEffectSchema, FlowFunctionLoweredDeclarationSchema } from './automation/flow-function.zod'; import { JobSchema } from './system/job.zod'; // Security Protocol @@ -1293,6 +1293,148 @@ export type AssembledPackageBody = z.input; /** Post-parse shape of {@link AssembledPackageBody} — defaults applied, transforms run (ADR-0122). */ export type AssembledPackageBodyParsed = z.infer; +/** + * `hooks`, as a JSON document can hold it: the assembled declaration with its + * `handler` narrowed to the lowered string ref. + * + * The narrowing is written HERE rather than on `HookSchema`, because the + * authoring door must keep accepting the inline callable — `objectstack build` + * is what lowers it, and `data/hook.zod.ts` says so in its own words: 「The + * JSON artifact therefore only ever contains the string form.」 `handler` keeps + * the base's `.optional()`: a hook that carries a `body` instead declares no + * handler at all, and the registry record of a hook whose handler was an inline + * callable has none either (the projection drops it). + */ +function jsonStageHooksKey() { + return z.array(HookSchema.extend({ + handler: z.string().optional() + .describe('Handler function name — the lowered string ref the JSON artifact carries'), + })).optional().describe('Object Lifecycle Hooks, as a JSON document carries them'); +} + +/** + * `functions`, as a JSON document can hold it: the two LOWERED members of + * {@link FlowFunctionEntrySchema} for the map form, and the array member with + * its callable branch dropped. + * + * Both lowered spellings are kept, not just the record one: `objectstack build` + * emits `{ myFn: 'myFn' }` for a bare entry and + * `{ myFn: { handler: 'myFn', effect: 'writes' } }` for a declared one, so a + * stage that admitted only the second would refuse artifacts this repo really + * writes. + * + * @param handlerOptional the RECORD stage's one difference from the artifact + * stage. A registry row is `toRecordManifest`'s structural JSON projection of + * the live assembled body, and that projection drops the callable it finds + * under `handler` — so the record of a declared function is the declaration + * MINUS its handler. Nothing replaces it, and nothing should: a ref minted + * anywhere but `objectstack build` is not guaranteed to be the ref `build` + * mints, so an absent `handler` is the honest statement 「declared here, not + * serialisable」. `effect` carries whatever optionality each form already gives + * it — `.default('pure')` on the map declaration, `.optional()` on the array + * entry — and neither is restated here. + */ +function jsonStageFunctionsKey(handlerOptional: boolean) { + const handlerRef = z.string().min(1) + .describe('The lowered handler ref (built artifacts) — the callable rides in the sibling ESM module'); + return z.union([ + z.record(z.string(), z.union([ + handlerRef, + handlerOptional + ? FlowFunctionLoweredDeclarationSchema.extend({ handler: handlerRef.optional() }) + : FlowFunctionLoweredDeclarationSchema, + ])), + // Transcribed rather than derived from the authoring array member above: + // that member is declared INLINE inside the assembled body's own shape, and + // narrowing it in place is the one thing this pair may not do. The key sets + // are held equal by a pin in `stack-json-stage-package-body.test.ts`, so a + // key added there and not here reddens by name. + z.array(z.object({ + name: z.string(), + handler: handlerOptional ? handlerRef.optional() : handlerRef, + packageId: z.string().optional(), + effect: FlowFunctionEffectSchema.optional(), + })), + ]).optional().describe('Named handler functions, lowered to the refs a JSON document carries'); +} + +/** + * One package as an INERT JSON release artifact carries it — the third of the + * four stages a package body passes through, and the first one that is really + * JSON. + * + * ## Why this is a separate declaration and not a narrowing of the assembled body + * + * {@link AssembledPackageBodySchema} spans the IN-MEMORY composed stage, where + * `functions` and `hooks` legitimately hold live callables: + * `composeStacks(stacks, { manifest: 'preserve' })` builds exactly such a body + * and the load path registers it, which is the invariant this file states + * further down. Narrowing the assembled body would refuse a published + * composition function's own output — so the artifact stage gets its own name + * instead, and the assembled one is ⛔ untouched. + * + * ## What it buys, measured + * + * Exactly two of the assembled body's members have no JSON Schema form — + * `functions` (a `z.function()` branch) and `hooks` (a `z.custom()` branch) — + * and one unrepresentable member costs every embedder its whole JSON Schema. + * With both narrowed to their lowered spellings this body converts under + * `z.toJSONSchema`, so a JSON surface that wants the assembled stage can + * declare it instead of writing `z.unknown()` and accepting anything. + * + * ADR-0130 D4 is what says an artifact is inert JSON: 「a plugin written inside + * `packages[i].manifest` could never be constructed by a loader, so a reader + * that resolved it there would register garbage where it used to skip in + * silence.」 A callable in an artifact is the same case. + */ +/* + * ANNOTATED with the same STRUCTURAL type as the assembled body above, for the + * same two measured reasons recorded there (TS7056 on the inferred type; a + * named alias turning `stack.zod` into a shared chunk). ⛔ Do not replace either + * annotation with an inferred or named type without re-reading that note. + */ +export const ArtifactStagePackageBodySchema: z.ZodType, Record> = + lazySchema(() => + ManifestSchema.extend({ + ...assembledPackageBodyShape(), + functions: jsonStageFunctionsKey(false), + hooks: jsonStageHooksKey(), + }).describe('One package as an inert-JSON release artifact carries it (ADR-0130 D4)')); + +/** The artifact-stage package body as authored. */ +export type ArtifactStagePackageBody = z.input; +/** Post-parse shape of {@link ArtifactStagePackageBody} — defaults applied, transforms run (ADR-0122). */ +export type ArtifactStagePackageBodyParsed = z.infer; + +/** + * One package as the package REGISTRY records it — the fourth stage, and the + * one that had no declaration until now. + * + * It is {@link ArtifactStagePackageBodySchema} with `functions[].handler` + * OPTIONAL, in both the map-record form and the array form, and nothing else. + * That single difference is the whole distance between an artifact and a + * record: an artifact is written by `objectstack build`, which lowers every + * callable to a ref, while a record is `toRecordManifest`'s structural JSON + * projection of a LIVE body, which drops the callable and mints nothing in its + * place. A record therefore reports what each function is named and what it + * declared, with `handler` absent where the callable was. + * + * ⛔ Never widen this to `z.unknown()` to make a row fit. A row that parses + * through neither this stage nor the authoring one is a producer defect, and + * this is the declaration that has to keep saying so. + */ +/* ANNOTATED structurally — see the note on the artifact stage above. */ +export const RecordStagePackageBodySchema: z.ZodType, Record> = + lazySchema(() => + (ArtifactStagePackageBodySchema as unknown as z.ZodObject).extend({ + functions: jsonStageFunctionsKey(true), + }).describe('One package as the package registry records it — the artifact stage with `functions[].handler` optional')); + +/** The record-stage package body as authored. */ +export type RecordStagePackageBody = z.input; +/** Post-parse shape of {@link RecordStagePackageBody} — defaults applied, transforms run (ADR-0122). */ +export type RecordStagePackageBodyParsed = z.infer; + /** * One package carried by a release artifact, in its ASSEMBLED form — the * element type of `packages` on {@link ObjectStackDefinitionSchema}. diff --git a/skills/objectstack-platform/references/_index.md b/skills/objectstack-platform/references/_index.md index 02b7c7bb756..5db87a4239d 100644 --- a/skills/objectstack-platform/references/_index.md +++ b/skills/objectstack-platform/references/_index.md @@ -18,7 +18,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/kernel/plugin-loading.zod.ts` — Plugin Loading Protocol - `node_modules/@objectstack/spec/src/kernel/plugin.zod.ts` — Exports: PluginContextSchema, PluginSchema - `node_modules/@objectstack/spec/src/kernel/service-registry.zod.ts` — Service Registry Protocol -- `node_modules/@objectstack/spec/src/stack.zod.ts` — Exports: DatasourceMappingRuleSchema, ArtifactPackageEntrySchema, AssembledPackageBodySchema, ArtifactPackageSchema, ObjectStackDefinitionSchema +- `node_modules/@objectstack/spec/src/stack.zod.ts` — Exports: DatasourceMappingRuleSchema, ArtifactPackageEntrySchema, AssembledPackageBodySchema, ArtifactStagePackageBodySchema, RecordStagePackageBodySchema ## Transitive dependencies