Repository navigation
Commit 23365ea
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
- .changeset
- content/docs/references
- ai
- ui
- packages/spec
- src
- ai
- migrations
- entries/semantic
- ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
117 | 117 | | |
118 | 118 | | |
119 | 119 | | |
120 | | - | |
| 120 | + | |
121 | 121 | | |
122 | 122 | | |
123 | 123 | | |
| |||
144 | 144 | | |
145 | 145 | | |
146 | 146 | | |
147 | | - | |
| 147 | + | |
148 | 148 | | |
149 | 149 | | |
150 | 150 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
140 | 140 | | |
141 | 141 | | |
142 | 142 | | |
143 | | - | |
| 143 | + | |
144 | 144 | | |
145 | 145 | | |
146 | 146 | | |
| |||
163 | 163 | | |
164 | 164 | | |
165 | 165 | | |
166 | | - | |
| 166 | + | |
167 | 167 | | |
168 | 168 | | |
169 | 169 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
6 | | - | |
| 5 | + | |
| 6 | + | |
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
11 | 16 | | |
12 | 17 | | |
13 | 18 | | |
| |||
38 | 43 | | |
39 | 44 | | |
40 | 45 | | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
41 | 51 | | |
42 | 52 | | |
43 | 53 | | |
| |||
51 | 61 | | |
52 | 62 | | |
53 | 63 | | |
| 64 | + | |
54 | 65 | | |
| 66 | + | |
55 | 67 | | |
56 | 68 | | |
57 | 69 | | |
| |||
154 | 166 | | |
155 | 167 | | |
156 | 168 | | |
| 169 | + | |
157 | 170 | | |
| 171 | + | |
158 | 172 | | |
159 | 173 | | |
160 | 174 | | |
| |||
242 | 256 | | |
243 | 257 | | |
244 | 258 | | |
| 259 | + | |
245 | 260 | | |
| 261 | + | |
246 | 262 | | |
247 | 263 | | |
248 | 264 | | |
| |||
290 | 306 | | |
291 | 307 | | |
292 | 308 | | |
| 309 | + | |
293 | 310 | | |
| 311 | + | |
294 | 312 | | |
295 | 313 | | |
296 | 314 | | |
| |||
340 | 358 | | |
341 | 359 | | |
342 | 360 | | |
| 361 | + | |
343 | 362 | | |
344 | 363 | | |
345 | 364 | | |
346 | 365 | | |
347 | 366 | | |
348 | 367 | | |
349 | 368 | | |
| 369 | + | |
350 | 370 | | |
351 | 371 | | |
352 | 372 | | |
| |||
740 | 760 | | |
741 | 761 | | |
742 | 762 | | |
| 763 | + | |
743 | 764 | | |
744 | 765 | | |
745 | 766 | | |
| |||
918 | 939 | | |
919 | 940 | | |
920 | 941 | | |
| 942 | + | |
921 | 943 | | |
922 | 944 | | |
923 | 945 | | |
| |||
1016 | 1038 | | |
1017 | 1039 | | |
1018 | 1040 | | |
| 1041 | + | |
1019 | 1042 | | |
1020 | 1043 | | |
1021 | 1044 | | |
| |||
1033 | 1056 | | |
1034 | 1057 | | |
1035 | 1058 | | |
| 1059 | + | |
1036 | 1060 | | |
1037 | 1061 | | |
1038 | 1062 | | |
| |||
1075 | 1099 | | |
1076 | 1100 | | |
1077 | 1101 | | |
| 1102 | + | |
1078 | 1103 | | |
1079 | 1104 | | |
1080 | 1105 | | |
| |||
1136 | 1161 | | |
1137 | 1162 | | |
1138 | 1163 | | |
| 1164 | + | |
1139 | 1165 | | |
1140 | 1166 | | |
1141 | 1167 | | |
| 1168 | + | |
| 1169 | + | |
| 1170 | + | |
| 1171 | + | |
| 1172 | + | |
1142 | 1173 | | |
1143 | 1174 | | |
1144 | 1175 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
| 9 | + | |
9 | 10 | | |
10 | 11 | | |
11 | 12 | | |
| |||
90 | 91 | | |
91 | 92 | | |
92 | 93 | | |
93 | | - | |
94 | | - | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
95 | 107 | | |
96 | 108 | | |
97 | 109 | | |
| |||
0 commit comments