Skip to content

Commit 23365ea

Browse files
feat(spec)!: the AI runtime's two JSON-Schema slots refuse an untyped subschema that carries a type-scoped keyword, at its path (#21289) (#21353)
Fixes #21289 Clause-②: yes Dispatched by the PM claim `5945676057` (PM loop round 1, `domain:spec` seat 2), on the triage ruling `5944126361` and the cloud seat's verbatim reading `5945577882` (cloud `main` `55f04d12`, `packages/service-ai/src/tools/json-schema-untyped.ts`). `action.ai.outputSchema` and `agent.structuredOutput.schema` were open records. The cloud AI runtime compiles both through one guard that refuses a schema in which an untyped subschema carries a type-scoped keyword, before the action or agent runs. Both slots now come from **one factory** that mirrors that guard exactly and refuses each offending subschema **at its own path**, naming the keyword and the `type` to declare. The changeset carries `Clause-②: yes (narrowing)`, the BREAKING banner at `minor`, and the ADR-0087 marker `registered ai-json-schema-untyped-subschema-refused` (D3 only). ## What changed - **New non-barrel module `packages/spec/src/shared/ai-json-schema-slot.ts`.** It adds no public export (`check:api-surface`, `check:export-origins` and `check:declaration-map` are unchanged). It holds: - the three lists, verbatim from the cloud guard: the 22 `TYPE_SCOPED_KEYWORDS`, the 5 map keywords and the 14 slot keywords; - `findUntypedSubschemas`, a pure, total walker. A node is refused when it is an object (not an array, not `null`), its `type` reads `undefined`, and a listed key is present with any value (`k in node`, as the runtime tests it). The walk is unconditional, so it descends under typed parents too. `$ref` is not followed. A node already on the current descent path is not re-entered, so a cyclic in-memory object terminates; no JSON document can be cyclic, so this changes no verdict; - `aiJsonSchemaSlot(root)`: `z.record(z.string(), z.unknown())` plus one `superRefine` that adds one `custom` issue per offending node. The issue path is the subschema path under the slot. The message starts with the slot as an author writes it, for example `ai.outputSchema.properties.customer uses "properties" without a "type", which the AI runtime's schema reader does not check, so the runtime refuses this whole schema before it runs; declare its "type" — "properties" applies to "type": "object"`. There is no tracker number in the text. - **Every offender is reported, not only the first.** The runtime stops at its first finding. Issues come out in the runtime's visit order (the node, then the map keywords, then the slot keywords, each in list order), so the first issue is the one the runtime names. The accept set is identical either way. - **`ui/action.zod.ts`:** `ai.outputSchema` is `aiJsonSchemaSlot('ai.outputSchema')`. **`ai/agent.zod.ts`:** `StructuredOutputConfigSchema.schema` is `aiJsonSchemaSlot('structuredOutput.schema')`. Each describe gains one sentence stating the refusal, and each TSDoc names the shared factory. The TypeScript types are unchanged: a record of `string` keys to `unknown` values. - **Why this is not business logic (Prime Directive #2).** The walker is a pure structural predicate over the value the slot declares. It reads no service, registry or other metadata. It judges only whether the declared JSON Schema is one the slot's only reader can enforce, the same kind of check the spec's other refinements make. - **D3 entry** `18.ai-json-schema-untyped-subschema-refused.ts`, regenerated into `migrations/registry.ts` by `gen:migration-registry`. No `STEP18_RATIONALE` fragment was added, because the claim's file surface names the generated region only. - **`dropped-refinements.baseline.json` (hand-edited, as the ledger requires).** A refinement does not project into the published JSON Schema, so the build refused until the new sites were declared. There are 3 new entries (`ai/Agent`, `ai/StructuredOutputConfig`, `ui/ActionAi`) and 16 new sites on 12 existing entries that embed actions or agents. The totals move from 212 schemas / 616 sites to 215 / 635, which is exactly what the build printed. - **Regenerated:** `content/docs/references/ai/agent.mdx` and `content/docs/references/ui/action.mdx` (the two describes). ## Measured **The premise at the base** (`origin/main` `135daaa06b`): both slots were `z.record(z.string(), z.unknown()).optional()`. The card's probe on published `@objectstack/spec` 17.5.0 accepted `{ properties, required }` with no `type` on both. **Third slot (zone 2, A2): none.** I ran `git grep` over `packages/spec/src` (tests excluded) for `outputSchema`, `structuredOutput`, `jsonSchema`, `JSON Schema`, `json_schema` and `responseSchema`, and for schema-named keys in `ai/`, `ui/action*` and `integration/`. The JSON-Schema-typed slots it finds: - `tool.parameters` and `tool.outputSchema`: the latter is not read on a tool record; - connector `actions[].inputSchema` / `outputSchema`: a different key family; - `datasource.configSchema`, the NoSQL driver's `jsonSchema` and node-executor `configSchema`: not AI-runtime slots. None of them is compiled by the cloud guard, whose reading names exactly two readers. **The doors after the change,** on the built `dist`, with fixture `{ properties: { name: { type: 'string' } }, required: ['name'] }` and its typed control: | door | untyped | typed control | |:--|:--|:--| | `getMetadataTypeSchema('action')`, the schema the save door validates with | refused at `ai.outputSchema` | accepted | | `getMetadataTypeSchema('agent')` | refused at `structuredOutput.schema` | accepted | | `getMetadataTypeSchema('object')`, object-nested action | refused at `actions.0.ai.outputSchema` | accepted | | `defineStack`, stack actions | throws, naming `actions.0.ai.outputSchema` | accepted | | `defineStack`, stack agents | throws, naming `agents.0.structuredOutput.schema` | accepted | These were read from source and not run: the database loader (`rowToData`) replays conversions and parses nothing, so a stored row still loads, and its next save is refused until it declares a `type`. **Census (zone 2, A3), on `135daaa06b` plus `bcd68a29f3`:** | path | slot | status | |:--|:--|:--| | `packages/spec/src/ui/action.test.ts:1062` | `ai.outputSchema` | already typed, nested typed | | `packages/spec/src/ai/agent.test.ts:547` | `structuredOutput.schema` | already typed, nested typed | | `packages/spec/src/ai/agent.test.ts:596` | `StructuredOutputConfigSchema.schema` | already typed, nested typed | - **`examples/**`:** zero authorings of either slot. The one hit, `examples/app-showcase/src/system/connectors/index.ts:176`, is a connector action's `outputSchema`, a different key, and it is typed. - **`content/docs/**` and `skills/**`:** prose only, no authored schema. - **Other `packages/**` hits** are all other keys: the retired flow-node `outputSchema`, connector actions, `tool.outputSchema` and liveness rows. - **objectui at the pin `31971ff1e28f`:** no action or agent authors either slot. Its hits are the tool `outputSchema` (typed), connector actions (typed) and a form-field schema. - **Nothing needed a change.** ## Tests and gates They ran on `8136eb76d9`, the merge of `origin/main` `bcd68a29f3` through `os-regen-merge.sh`. HEAD is `56272f193b`, which adds one commit that corrects one changeset sentence and touches nothing else. The 11 derived families that read `.changeset/**` or prose re-ran green on `56272f193b`: `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, both `check-issue-citations` spellings, `check:doc-authoring`, `check:nul-bytes`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, `check:pm-prior-rulings` and `check:pm-widening-tells`. - **New pin file `packages/spec/src/shared/ai-json-schema-slot.test.ts`: 24 tests.** - The mirror pin enumerates the 22 keywords and the 5 + 14 descent keywords as data. Its comment cites the cloud file and sha. - The triage pins, on both slots: an untyped schema root, and an untyped child, are refused at their paths; the typed control is accepted on both; a nested untyped subschema is refused at `…properties.lines.items.properties.sku`. - The ruling's added pins: boolean subschemas and `{}` are accepted; an untyped node with only `enum` is accepted; an untyped node under `$defs` is refused at `…$defs.address`. - Walker coverage: each of the 22 keywords alone; presence, not value; first keyword in list order; each non-listed keyword alone accepted; `type` with any value (array, unknown string, `null`) counts as present, and `undefined` counts as absent; every map and slot keyword descended, as a single subschema and as an array; every offender reported; `$ref` not followed; the cycle guard. - **Ablation (one-shot).** It went through `scripts/ablation-replace.mjs`. The anchor was the `found.push` line, with `&& false` injected. The test imports `src` relatively, so no `dist` was involved. - Mutation landed: anchor 1 → 0, blob `4b796eb7668d` → `aadd54a5ff8b`. - **14 failed / 10 passed.** The 10 that stayed green are the list pins and the accept-side controls, as they should. - Restore: blob == HEAD `4b796eb7668d`, `git diff HEAD` empty. - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: **598 files passed, 17511 passed, 1 todo**. - `pnpm --filter @objectstack/spec typecheck`: exit 0. `check:test-typecheck` is OK (52 files / 246 errors / 135 pinned signatures, unchanged), so the new test file compiles clean under `tsconfig.test.json`. - `pnpm --filter @objectstack/spec check:generated`: **all 15 generated artifacts up to date**, measured against a `dist` built by that run. Before the merge, `--fix` regenerated the one it proved stale (`check:docs`). - **Consumer tests,** downstream of `@objectstack/spec`, limited to the two packages whose tests parse actions and agents through the doors: - `@objectstack/lint`: **119 files / 5575 tests passed**; - `@objectstack/metadata-protocol`: **201 passed + 3 skipped files / 2980 passed + 19 skipped tests**. - No fixture anywhere authors an untyped schema in either slot (census above), so no other consumer can flip. - **`dispatch-gates.mjs --commands`** (no paths) derived 112 commands, and all 112 exited 0. - Six first exited 3 with PREREQUISITE NOT MET, because `lint`, `formula`, `client`, `client-react`, `objectql` and others had no `dist`: `check:doc-formula-expressions`, `check:doc-security-posture`, `check:skill-examples`, `check:docs-transcript-drift`, `check:dual-build-cjs-loads` and `check:lean-entry-closure`. Each re-ran green once the dists existed. - `--ran` read: "112 derived, 112 run, 0 NOT-MEASURED, 0 UNRUN". - **Changeset gates.** - `check-adr-0087-registration --base origin/main`: `[BREAKING+bang+clause-②-narrowing] registered ai-json-schema-untyped-subschema-refused (new here: …)`. - `check-changeset-no-major` reads "no `major` bump". Its level axis, driven offline with `--event` on this body's declaration line: "LEVEL AXIS: this PR declares clause-② `yes`, and no package whose `packages/**/src/**` it moves is graded `patch`". - `check-empty-changeset`: green. - **NOT MEASURED, declared to CI:** the 6 path-scheduled CI jobs, the 11 wide-population families, the 6 workflow-valued families and the type-check lanes that the derivation lists outside its total. ## Acceptance notes - **The published JSON Schema stays wider than the slot.** A refinement has no JSON Schema projection, so `json-schema/**` still accepts an untyped subschema. It is recorded as `x-dropped-refinements` on the 15 affected schemas. Projecting the rule would mean teaching `refinement-projection.ts`'s closed list a recursive pattern, which that module calls a public-contract decision. It is not done here. - **A stale describe on the agent.** `agent.structuredOutput`'s own describe still reads "[EXPERIMENTAL — not enforced] … Parsed but no runtime consumer yet". The cloud reading names `agent-runtime.ts#compileStructuredOutput` as a reader of `structuredOutput.schema`. That key's status is #21277's question (retire or build its other members), so it is left untouched here. Carrier: #21277's PR. - **A boundary of the mirror.** A map keyword whose value is not an object (for example `properties: [...]`) is not walked. The reading says "every value of the map is walked" but does not say what the guard does with a non-object map. Such a schema is malformed anyway. --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 96b12b5 commit 23365ea

10 files changed

Lines changed: 712 additions & 9 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: `action.ai.outputSchema` and `agent.structuredOutput.schema` refuse an untyped subschema that carries a type-scoped keyword, at its path, as the AI runtime does (#21289)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: registered ai-json-schema-untyped-subschema-refused -->
10+
11+
**BREAKING** — an accept-set narrowing on two published authoring slots, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Every schema it refuses was already refused by the AI runtime before the action or agent ran, so nothing that worked stops working; what moves is where the refusal is reported — at authoring, at the subschema's path, instead of at the first invocation.
12+
13+
**`@objectstack/spec`**
14+
15+
- **`action.ai.outputSchema`** (stack actions and object-nested actions) and **`agent.structuredOutput.schema`** were open records. The cloud AI runtime compiles both through one guard whose schema reader does not check a type-scoped keyword on a subschema with no `type`, and refuses the whole schema. Both slots are now declared by one factory that mirrors that guard exactly:
16+
- **refused:** an object node whose `type` is absent and which carries any of the 22 type-scoped keywords (`properties`, `required`, `additionalProperties`, `patternProperties`, `propertyNames`, `minProperties`, `maxProperties`, `items`, `prefixItems`, `contains`, `minItems`, `maxItems`, `uniqueItems`, `minLength`, `maxLength`, `pattern`, `format`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`), with any value;
17+
- **where:** the schema root, every value of `properties`, `patternProperties`, `$defs`, `definitions` and `dependentSchemas`, and the subschema (or each array entry) of `items`, `additionalProperties`, `contains`, `propertyNames`, `not`, `if`, `then`, `else`, `unevaluatedProperties`, `unevaluatedItems`, `anyOf`, `oneOf`, `allOf` and `prefixItems` — under typed parents too; `$ref` is not followed;
18+
- **accepted:** boolean subschemas, `{}`, a node with any `type` value, and an untyped node carrying only keywords outside the list (`enum`, `const`, `$ref`, `anyOf`, `title`, `description`, `default`, …);
19+
- each offending subschema is its own issue, located at the slot path plus the subschema path (`ai.outputSchema.properties.customer`), and the message names the keyword and the `type` to declare.
20+
- The TypeScript types of both slots are unchanged (`Record<string, unknown>`). The published JSON Schema does not state the rule: it is a refinement, which the JSON Schema projection does not carry, so a JSON Schema validator still accepts such a schema in either slot. The affected published schemas name the slot in their `x-dropped-refinements` list.
21+
22+
## FROM → TO
23+
24+
| you wrote | write instead |
25+
|:--|:--|
26+
| `outputSchema: { properties: { id: { type: 'string' } }, required: ['id'] }` | `outputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }` |
27+
| `schema: { type: 'object', properties: { tags: { items: { type: 'string' } } } }` | `schema: { type: 'object', properties: { tags: { type: 'array', items: { type: 'string' } } } }` |
28+
| `{ properties: { code: { pattern: '^[A-Z]+$' } } }` anywhere in either slot | `{ type: 'object', properties: { code: { type: 'string', pattern: '^[A-Z]+$' } } }` |
29+
30+
The one-line fix: declare its `type` on every subschema that carries a type-scoped keyword — `"object"`, `"array"`, `"string"`, or `"number"` / `"integer"`, as the refusal names.
31+
32+
## Who is affected, measured
33+
34+
On `origin/main` `135daaa06b`: the package fixtures author either slot three times (one action `ai.outputSchema`, two `structuredOutput.schema`), every subschema typed; the examples, the documentation and the published skills author neither slot. No fixture needed a change. Deployed metadata was not measured. A stored action or agent carrying such a schema still loads; its next save is refused until the `type` is declared.

‎content/docs/references/ai/agent.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ const result = AIModelConfigSchema.parse(data);
117117
| Property | Type | Required | Description |
118118
| :--- | :--- | :--- | :--- |
119119
| **format** | `Enum<'json_object' \| 'json_schema' \| 'regex' \| 'grammar' \| 'xml'>` | ✅ | Expected output format |
120-
| **schema** | `Record<string, any>` | optional | JSON Schema definition for output |
120+
| **schema** | `Record<string, any>` | optional | JSON Schema definition for output. An untyped subschema that carries a type-scoped keyword (properties, items, pattern, minimum, …) is refused at its path, because the AI runtime's schema reader does not check it; declare its "type". |
121121
| **strict** | `boolean` | optional (default: `false`) | Enforce exact schema compliance |
122122
| **retryOnValidationFailure** | `boolean` | optional (default: `true`) | Retry generation when output fails validation |
123123
| **maxRetries** | `integer` | optional (default: `3`) | Maximum retries on validation failure |
@@ -144,7 +144,7 @@ Structured output configuration for agent responses
144144
| Property | Type | Required | Description |
145145
| :--- | :--- | :--- | :--- |
146146
| **format** | `Enum<'json_object' \| 'json_schema' \| 'regex' \| 'grammar' \| 'xml'>` | ✅ | Expected output format |
147-
| **schema** | `Record<string, any>` | optional | JSON Schema definition for output |
147+
| **schema** | `Record<string, any>` | optional | JSON Schema definition for output. An untyped subschema that carries a type-scoped keyword (properties, items, pattern, minimum, …) is refused at its path, because the AI runtime's schema reader does not check it; declare its "type". |
148148
| **strict** | `boolean` | optional (default: `false`) | Enforce exact schema compliance |
149149
| **retryOnValidationFailure** | `boolean` | optional (default: `true`) | Retry generation when output fails validation |
150150
| **maxRetries** | `integer` | optional (default: `3`) | Maximum retries on validation failure |

‎content/docs/references/ui/action.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
140140
| **description** | `string` | optional | LLM-facing description (≥40 chars). Required when exposed. |
141141
| **category** | `Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>` | optional | Tool category override (defaults to "action"). |
142142
| **paramHints** | `Record<string, { description?: string; enum?: (string \| number)[]; examples?: any[] }>` | optional | Per-parameter AI hints keyed by param name. |
143-
| **outputSchema** | `Record<string, any>` | optional | JSON Schema for the action return value. |
143+
| **outputSchema** | `Record<string, any>` | optional | JSON Schema for the action return value. An untyped subschema that carries a type-scoped keyword (properties, items, pattern, minimum, …) is refused at its path, because the AI runtime's schema reader does not check it; declare its "type". |
144144
| **requiresConfirmation** | `boolean` | optional | Override HITL confirmation for AI invocations. |
145145

146146
### Nested Shape: `Action.onSuccess`
@@ -163,7 +163,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
163163
| **description** | `string` | optional | LLM-facing description (≥40 chars). Required when exposed. |
164164
| **category** | `Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>` | optional | Tool category override (defaults to "action"). |
165165
| **paramHints** | `Record<string, { description?: string; enum?: (string \| number)[]; examples?: any[] }>` | optional | Per-parameter AI hints keyed by param name. |
166-
| **outputSchema** | `Record<string, any>` | optional | JSON Schema for the action return value. |
166+
| **outputSchema** | `Record<string, any>` | optional | JSON Schema for the action return value. An untyped subschema that carries a type-scoped keyword (properties, items, pattern, minimum, …) is refused at its path, because the AI runtime's schema reader does not check it; declare its "type". |
167167
| **requiresConfirmation** | `boolean` | optional | Override HITL confirmation for AI invocations. |
168168

169169

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22
"description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.",
33
"measured": {
44
"zod": "4.4.3",
5-
"publishedSchemasWithDroppedRefinements": 212,
6-
"droppedRefinementSites": 616,
5+
"publishedSchemasWithDroppedRefinements": 215,
6+
"droppedRefinementSites": 635,
77
"refinementSitesThatDidProject": 369,
88
"refinementSitesWithNoJsonFormToCompare": 0
99
},
1010
"entries": {
11+
"ai/Agent": {
12+
"sites": [
13+
"structuredOutput.schema"
14+
]
15+
},
1116
"ai/BlueprintField": {
1217
"sites": [
1318
"summaryOperations.filter.lazy"
@@ -38,6 +43,11 @@
3843
"objects.element.fields.element.summaryOperations.filter.lazy"
3944
]
4045
},
46+
"ai/StructuredOutputConfig": {
47+
"sites": [
48+
"schema"
49+
]
50+
},
4151
"api/AnalyticsQueryRequest": {
4252
"sites": [
4353
"where.lazy"
@@ -51,7 +61,9 @@
5161
"api/AssembledInstalledPackage": {
5262
"sites": [
5363
"manifest.actions.element.in",
64+
"manifest.actions.element.in.ai.outputSchema",
5465
"manifest.actions.element.in.params.element.in",
66+
"manifest.agents.element.structuredOutput.schema",
5567
"manifest.connectors.element.out",
5668
"manifest.dashboards.element.globalFilters.element",
5769
"manifest.dashboards.element.widgets.element",
@@ -154,7 +166,9 @@
154166
"sites": [
155167
"data.options[0].manifest.navigationContributions.element.items.element.lazy.options[0]",
156168
"data.options[1].manifest.actions.element.in",
169+
"data.options[1].manifest.actions.element.in.ai.outputSchema",
157170
"data.options[1].manifest.actions.element.in.params.element.in",
171+
"data.options[1].manifest.agents.element.structuredOutput.schema",
158172
"data.options[1].manifest.connectors.element.out",
159173
"data.options[1].manifest.dashboards.element.globalFilters.element",
160174
"data.options[1].manifest.dashboards.element.widgets.element",
@@ -242,7 +256,9 @@
242256
"sites": [
243257
"options[0].manifest.navigationContributions.element.items.element.lazy.options[0]",
244258
"options[1].manifest.actions.element.in",
259+
"options[1].manifest.actions.element.in.ai.outputSchema",
245260
"options[1].manifest.actions.element.in.params.element.in",
261+
"options[1].manifest.agents.element.structuredOutput.schema",
246262
"options[1].manifest.connectors.element.out",
247263
"options[1].manifest.dashboards.element.globalFilters.element",
248264
"options[1].manifest.dashboards.element.widgets.element",
@@ -290,7 +306,9 @@
290306
"sites": [
291307
"data.packages.element.options[0].manifest.navigationContributions.element.items.element.lazy.options[0]",
292308
"data.packages.element.options[1].manifest.actions.element.in",
309+
"data.packages.element.options[1].manifest.actions.element.in.ai.outputSchema",
293310
"data.packages.element.options[1].manifest.actions.element.in.params.element.in",
311+
"data.packages.element.options[1].manifest.agents.element.structuredOutput.schema",
294312
"data.packages.element.options[1].manifest.connectors.element.out",
295313
"data.packages.element.options[1].manifest.dashboards.element.globalFilters.element",
296314
"data.packages.element.options[1].manifest.dashboards.element.widgets.element",
@@ -340,13 +358,15 @@
340358
"api/MetadataTypeInfoResponse": {
341359
"sites": [
342360
"data.actions.element.in",
361+
"data.actions.element.in.ai.outputSchema",
343362
"data.actions.element.in.params.element.in"
344363
]
345364
},
346365
"api/ObjectDefinitionResponse": {
347366
"sites": [
348367
"data",
349368
"data.actions.element.in",
369+
"data.actions.element.in.ai.outputSchema",
350370
"data.actions.element.in.params.element.in",
351371
"data.fieldGroups",
352372
"data.fields.out.valueType",
@@ -740,6 +760,7 @@
740760
"sites": [
741761
"",
742762
"actions.element.in",
763+
"actions.element.in.ai.outputSchema",
743764
"actions.element.in.params.element.in",
744765
"fieldGroups",
745766
"fields.out.valueType",
@@ -918,6 +939,7 @@
918939
"sites": [
919940
"",
920941
"actions.element.in",
942+
"actions.element.in.ai.outputSchema",
921943
"actions.element.in.params.element.in"
922944
]
923945
},
@@ -1016,6 +1038,7 @@
10161038
"operations.element.options[0].field.relatedListFilter.lazy",
10171039
"operations.element.options[3].object",
10181040
"operations.element.options[3].object.actions.element.in",
1041+
"operations.element.options[3].object.actions.element.in.ai.outputSchema",
10191042
"operations.element.options[3].object.actions.element.in.params.element.in",
10201043
"operations.element.options[3].object.fieldGroups",
10211044
"operations.element.options[3].object.fields.out.valueType",
@@ -1033,6 +1056,7 @@
10331056
"sites": [
10341057
"object",
10351058
"object.actions.element.in",
1059+
"object.actions.element.in.ai.outputSchema",
10361060
"object.actions.element.in.params.element.in",
10371061
"object.fieldGroups",
10381062
"object.fields.out.valueType",
@@ -1075,6 +1099,7 @@
10751099
"options[0].field.relatedListFilter.lazy",
10761100
"options[3].object",
10771101
"options[3].object.actions.element.in",
1102+
"options[3].object.actions.element.in.ai.outputSchema",
10781103
"options[3].object.actions.element.in.params.element.in",
10791104
"options[3].object.fieldGroups",
10801105
"options[3].object.fields.out.valueType",
@@ -1136,9 +1161,15 @@
11361161
"ui/Action": {
11371162
"sites": [
11381163
"in",
1164+
"in.ai.outputSchema",
11391165
"in.params.element.in"
11401166
]
11411167
},
1168+
"ui/ActionAi": {
1169+
"sites": [
1170+
"outputSchema"
1171+
]
1172+
},
11421173
"ui/ActionParam": {
11431174
"sites": [
11441175
"in"

‎packages/spec/src/ai/agent.zod.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ProtectionSchema } from '../shared/protection.zod';
66
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
77
import { StateMachineSchema } from '../automation/state-machine.zod';
88
import { lazySchema } from '../shared/lazy-schema';
9+
import { aiJsonSchemaSlot } from '../shared/ai-json-schema-slot';
910
import { strictObject } from '../shared/strict-object';
1011

1112
/**
@@ -90,8 +91,19 @@ export const StructuredOutputConfigSchema = lazySchema(() => strictObject({
9091
/** Output format type */
9192
format: StructuredOutputFormatSchema.describe('Expected output format'),
9293

93-
/** JSON Schema definition for output validation */
94-
schema: z.record(z.string(), z.unknown()).optional().describe('JSON Schema definition for output'),
94+
/**
95+
* JSON Schema definition for output validation. The cloud AI runtime
96+
* compiles it, and its schema reader refuses an untyped subschema that
97+
* carries a type-scoped keyword (`properties`, `items`, `pattern`,
98+
* `minimum`, …). The slot refuses the same schemas here, at the subschema's
99+
* path, through the one factory `action.ai.outputSchema` shares
100+
* (`shared/ai-json-schema-slot.ts`).
101+
*/
102+
schema: aiJsonSchemaSlot('structuredOutput.schema').optional().describe(
103+
'JSON Schema definition for output. An untyped subschema that carries a type-scoped keyword '
104+
+ '(properties, items, pattern, minimum, …) is refused at its path, because the AI runtime\'s '
105+
+ 'schema reader does not check it; declare its "type".',
106+
),
95107

96108
/** Whether to enforce exact schema compliance */
97109
strict: z.boolean().default(false).describe('Enforce exact schema compliance'),

0 commit comments

Comments
 (0)