@nekostack/schema 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +202 -0
  3. package/README.md +656 -0
  4. package/dist/src/builders/array.d.ts +12 -0
  5. package/dist/src/builders/array.d.ts.map +1 -0
  6. package/dist/src/builders/array.js +29 -0
  7. package/dist/src/builders/array.js.map +1 -0
  8. package/dist/src/builders/object.d.ts +62 -0
  9. package/dist/src/builders/object.d.ts.map +1 -0
  10. package/dist/src/builders/object.js +263 -0
  11. package/dist/src/builders/object.js.map +1 -0
  12. package/dist/src/builders/primitives.d.ts +37 -0
  13. package/dist/src/builders/primitives.d.ts.map +1 -0
  14. package/dist/src/builders/primitives.js +125 -0
  15. package/dist/src/builders/primitives.js.map +1 -0
  16. package/dist/src/builders/s.d.ts +27 -0
  17. package/dist/src/builders/s.d.ts.map +1 -0
  18. package/dist/src/builders/s.js +39 -0
  19. package/dist/src/builders/s.js.map +1 -0
  20. package/dist/src/builders/schema.d.ts +70 -0
  21. package/dist/src/builders/schema.d.ts.map +1 -0
  22. package/dist/src/builders/schema.js +98 -0
  23. package/dist/src/builders/schema.js.map +1 -0
  24. package/dist/src/cli-integration.d.ts +43 -0
  25. package/dist/src/cli-integration.d.ts.map +1 -0
  26. package/dist/src/cli-integration.js +48 -0
  27. package/dist/src/cli-integration.js.map +1 -0
  28. package/dist/src/errors/issue.d.ts +34 -0
  29. package/dist/src/errors/issue.d.ts.map +1 -0
  30. package/dist/src/errors/issue.js +89 -0
  31. package/dist/src/errors/issue.js.map +1 -0
  32. package/dist/src/generators/errors.d.ts +31 -0
  33. package/dist/src/generators/errors.d.ts.map +1 -0
  34. package/dist/src/generators/errors.js +34 -0
  35. package/dist/src/generators/errors.js.map +1 -0
  36. package/dist/src/generators/header.d.ts +42 -0
  37. package/dist/src/generators/header.d.ts.map +1 -0
  38. package/dist/src/generators/header.js +43 -0
  39. package/dist/src/generators/header.js.map +1 -0
  40. package/dist/src/generators/json-schema-meta.d.ts +36 -0
  41. package/dist/src/generators/json-schema-meta.d.ts.map +1 -0
  42. package/dist/src/generators/json-schema-meta.js +35 -0
  43. package/dist/src/generators/json-schema-meta.js.map +1 -0
  44. package/dist/src/generators/json-schema.d.ts +26 -0
  45. package/dist/src/generators/json-schema.d.ts.map +1 -0
  46. package/dist/src/generators/json-schema.js +88 -0
  47. package/dist/src/generators/json-schema.js.map +1 -0
  48. package/dist/src/generators/openapi.d.ts +33 -0
  49. package/dist/src/generators/openapi.d.ts.map +1 -0
  50. package/dist/src/generators/openapi.js +61 -0
  51. package/dist/src/generators/openapi.js.map +1 -0
  52. package/dist/src/generators/schema-fragment.d.ts +55 -0
  53. package/dist/src/generators/schema-fragment.d.ts.map +1 -0
  54. package/dist/src/generators/schema-fragment.js +253 -0
  55. package/dist/src/generators/schema-fragment.js.map +1 -0
  56. package/dist/src/generators/ts.d.ts +19 -0
  57. package/dist/src/generators/ts.d.ts.map +1 -0
  58. package/dist/src/generators/ts.js +252 -0
  59. package/dist/src/generators/ts.js.map +1 -0
  60. package/dist/src/generators/types.d.ts +96 -0
  61. package/dist/src/generators/types.d.ts.map +1 -0
  62. package/dist/src/generators/types.js +10 -0
  63. package/dist/src/generators/types.js.map +1 -0
  64. package/dist/src/generators/version.d.ts +11 -0
  65. package/dist/src/generators/version.d.ts.map +1 -0
  66. package/dist/src/generators/version.js +11 -0
  67. package/dist/src/generators/version.js.map +1 -0
  68. package/dist/src/generators/zod-mapping.d.ts +90 -0
  69. package/dist/src/generators/zod-mapping.d.ts.map +1 -0
  70. package/dist/src/generators/zod-mapping.js +174 -0
  71. package/dist/src/generators/zod-mapping.js.map +1 -0
  72. package/dist/src/generators/zod.d.ts +17 -0
  73. package/dist/src/generators/zod.d.ts.map +1 -0
  74. package/dist/src/generators/zod.js +118 -0
  75. package/dist/src/generators/zod.js.map +1 -0
  76. package/dist/src/index.d.ts +21 -0
  77. package/dist/src/index.d.ts.map +1 -0
  78. package/dist/src/index.js +43 -0
  79. package/dist/src/index.js.map +1 -0
  80. package/dist/src/ir/hash.d.ts +19 -0
  81. package/dist/src/ir/hash.d.ts.map +1 -0
  82. package/dist/src/ir/hash.js +22 -0
  83. package/dist/src/ir/hash.js.map +1 -0
  84. package/dist/src/ir/nodes.d.ts +121 -0
  85. package/dist/src/ir/nodes.d.ts.map +1 -0
  86. package/dist/src/ir/nodes.js +14 -0
  87. package/dist/src/ir/nodes.js.map +1 -0
  88. package/dist/src/ir/serialize.d.ts +8 -0
  89. package/dist/src/ir/serialize.d.ts.map +1 -0
  90. package/dist/src/ir/serialize.js +23 -0
  91. package/dist/src/ir/serialize.js.map +1 -0
  92. package/dist/src/migrations/build-migration-registry.d.ts +46 -0
  93. package/dist/src/migrations/build-migration-registry.d.ts.map +1 -0
  94. package/dist/src/migrations/build-migration-registry.js +134 -0
  95. package/dist/src/migrations/build-migration-registry.js.map +1 -0
  96. package/dist/src/migrations/handlers/list.d.ts +35 -0
  97. package/dist/src/migrations/handlers/list.d.ts.map +1 -0
  98. package/dist/src/migrations/handlers/list.js +55 -0
  99. package/dist/src/migrations/handlers/list.js.map +1 -0
  100. package/dist/src/migrations/handlers/plan.d.ts +26 -0
  101. package/dist/src/migrations/handlers/plan.d.ts.map +1 -0
  102. package/dist/src/migrations/handlers/plan.js +28 -0
  103. package/dist/src/migrations/handlers/plan.js.map +1 -0
  104. package/dist/src/migrations/handlers/stub.d.ts +22 -0
  105. package/dist/src/migrations/handlers/stub.d.ts.map +1 -0
  106. package/dist/src/migrations/handlers/stub.js +24 -0
  107. package/dist/src/migrations/handlers/stub.js.map +1 -0
  108. package/dist/src/migrations/handlers/verify.d.ts +25 -0
  109. package/dist/src/migrations/handlers/verify.d.ts.map +1 -0
  110. package/dist/src/migrations/handlers/verify.js +27 -0
  111. package/dist/src/migrations/handlers/verify.js.map +1 -0
  112. package/dist/src/migrations/parse-provenance.d.ts +78 -0
  113. package/dist/src/migrations/parse-provenance.d.ts.map +1 -0
  114. package/dist/src/migrations/parse-provenance.js +157 -0
  115. package/dist/src/migrations/parse-provenance.js.map +1 -0
  116. package/dist/src/migrations/plan-migration.d.ts +50 -0
  117. package/dist/src/migrations/plan-migration.d.ts.map +1 -0
  118. package/dist/src/migrations/plan-migration.js +256 -0
  119. package/dist/src/migrations/plan-migration.js.map +1 -0
  120. package/dist/src/migrations/stub.d.ts +55 -0
  121. package/dist/src/migrations/stub.d.ts.map +1 -0
  122. package/dist/src/migrations/stub.js +201 -0
  123. package/dist/src/migrations/stub.js.map +1 -0
  124. package/dist/src/migrations/types.d.ts +297 -0
  125. package/dist/src/migrations/types.d.ts.map +1 -0
  126. package/dist/src/migrations/types.js +28 -0
  127. package/dist/src/migrations/types.js.map +1 -0
  128. package/dist/src/migrations/verify-provenance.d.ts +46 -0
  129. package/dist/src/migrations/verify-provenance.d.ts.map +1 -0
  130. package/dist/src/migrations/verify-provenance.js +158 -0
  131. package/dist/src/migrations/verify-provenance.js.map +1 -0
  132. package/dist/src/registry/build-registry.d.ts +65 -0
  133. package/dist/src/registry/build-registry.d.ts.map +1 -0
  134. package/dist/src/registry/build-registry.js +172 -0
  135. package/dist/src/registry/build-registry.js.map +1 -0
  136. package/dist/src/registry/diff.d.ts +25 -0
  137. package/dist/src/registry/diff.d.ts.map +1 -0
  138. package/dist/src/registry/diff.js +497 -0
  139. package/dist/src/registry/diff.js.map +1 -0
  140. package/dist/src/registry/handlers/check.d.ts +57 -0
  141. package/dist/src/registry/handlers/check.d.ts.map +1 -0
  142. package/dist/src/registry/handlers/check.js +181 -0
  143. package/dist/src/registry/handlers/check.js.map +1 -0
  144. package/dist/src/registry/handlers/diff.d.ts +33 -0
  145. package/dist/src/registry/handlers/diff.d.ts.map +1 -0
  146. package/dist/src/registry/handlers/diff.js +61 -0
  147. package/dist/src/registry/handlers/diff.js.map +1 -0
  148. package/dist/src/registry/handlers/generate.d.ts +87 -0
  149. package/dist/src/registry/handlers/generate.d.ts.map +1 -0
  150. package/dist/src/registry/handlers/generate.js +223 -0
  151. package/dist/src/registry/handlers/generate.js.map +1 -0
  152. package/dist/src/registry/handlers/list.d.ts +36 -0
  153. package/dist/src/registry/handlers/list.d.ts.map +1 -0
  154. package/dist/src/registry/handlers/list.js +84 -0
  155. package/dist/src/registry/handlers/list.js.map +1 -0
  156. package/dist/src/registry/parse-provenance.d.ts +63 -0
  157. package/dist/src/registry/parse-provenance.d.ts.map +1 -0
  158. package/dist/src/registry/parse-provenance.js +182 -0
  159. package/dist/src/registry/parse-provenance.js.map +1 -0
  160. package/dist/src/registry/source-hash.d.ts +28 -0
  161. package/dist/src/registry/source-hash.d.ts.map +1 -0
  162. package/dist/src/registry/source-hash.js +32 -0
  163. package/dist/src/registry/source-hash.js.map +1 -0
  164. package/dist/src/registry/types.d.ts +185 -0
  165. package/dist/src/registry/types.d.ts.map +1 -0
  166. package/dist/src/registry/types.js +22 -0
  167. package/dist/src/registry/types.js.map +1 -0
  168. package/dist/src/runtime/compile.d.ts +38 -0
  169. package/dist/src/runtime/compile.d.ts.map +1 -0
  170. package/dist/src/runtime/compile.js +45 -0
  171. package/dist/src/runtime/compile.js.map +1 -0
  172. package/dist/src/runtime/errors.d.ts +25 -0
  173. package/dist/src/runtime/errors.d.ts.map +1 -0
  174. package/dist/src/runtime/errors.js +43 -0
  175. package/dist/src/runtime/errors.js.map +1 -0
  176. package/dist/src/runtime/normalize-issues.d.ts +65 -0
  177. package/dist/src/runtime/normalize-issues.d.ts.map +1 -0
  178. package/dist/src/runtime/normalize-issues.js +208 -0
  179. package/dist/src/runtime/normalize-issues.js.map +1 -0
  180. package/dist/src/runtime/parse.d.ts +62 -0
  181. package/dist/src/runtime/parse.d.ts.map +1 -0
  182. package/dist/src/runtime/parse.js +107 -0
  183. package/dist/src/runtime/parse.js.map +1 -0
  184. package/dist/src/runtime/strip-defaults.d.ts +51 -0
  185. package/dist/src/runtime/strip-defaults.d.ts.map +1 -0
  186. package/dist/src/runtime/strip-defaults.js +81 -0
  187. package/dist/src/runtime/strip-defaults.js.map +1 -0
  188. package/dist/src/runtime/zod-compile.d.ts +27 -0
  189. package/dist/src/runtime/zod-compile.d.ts.map +1 -0
  190. package/dist/src/runtime/zod-compile.js +92 -0
  191. package/dist/src/runtime/zod-compile.js.map +1 -0
  192. package/dist/src/types.d.ts +116 -0
  193. package/dist/src/types.d.ts.map +1 -0
  194. package/dist/src/types.js +2 -0
  195. package/dist/src/types.js.map +1 -0
  196. package/docs/ABSENCE_SEMANTICS.md +37 -0
  197. package/docs/BENCHMARKS.md +64 -0
  198. package/docs/COMPOSITION.md +206 -0
  199. package/docs/DIFF_CLASSIFICATION.md +137 -0
  200. package/docs/EXAMPLES.md +221 -0
  201. package/docs/HEADER_FORMAT.md +66 -0
  202. package/docs/INVARIANTS.md +58 -0
  203. package/docs/IR_CONTRACT.md +67 -0
  204. package/docs/ISSUE_CODES.md +99 -0
  205. package/docs/JSON_SCHEMA_MAPPING.md +149 -0
  206. package/docs/MIGRATIONS.md +406 -0
  207. package/docs/MIGRATION_GUIDE.md +150 -0
  208. package/docs/OPENAPI_MAPPING.md +66 -0
  209. package/docs/REGISTRY.md +336 -0
  210. package/docs/RUNTIME.md +279 -0
  211. package/docs/SCOPE.md +119 -0
  212. package/docs/USAGE.md +376 -0
  213. package/docs/ZOD_MODIFIER_ORDERING.md +77 -0
  214. package/package.json +45 -0
@@ -0,0 +1,336 @@
1
+ # Registry Contract
2
+
3
+ > The v0.7 registry / freshness / generation surface lives inside `@nekostack/schema` as a set of pure primitives. The CLI (`@nekostack/cli`, sequencing Steps 21–34) owns every filesystem and process concern and calls into this surface for the logic. This file is the contract; the implementations live under [`../src/registry/`](../src/registry/) and the integration barrel is [`../src/cli-integration.ts`](../src/cli-integration.ts).
4
+
5
+ ## Ownership boundary (Master plan Decision #1)
6
+
7
+ The schema package is **pure**:
8
+
9
+ > No `fs.*`. No dynamic `import()`. No `process.*`. No `console.*`. Takes data in, returns data out.
10
+
11
+ The CLI is the **filesystem shell**:
12
+
13
+ | Responsibility | Owner |
14
+ |---|---|
15
+ | Discover `*.schema.{ts,js}` files | CLI |
16
+ | Read source bytes | CLI |
17
+ | Dynamic-import schema modules via `tsx` | CLI |
18
+ | Read committed artifacts from disk | CLI |
19
+ | Write regenerated artifacts to disk | CLI |
20
+ | stdout / stderr formatting | CLI |
21
+ | Exit codes | CLI |
22
+ | Build the in-memory `Registry` | schema |
23
+ | Classify diff severity | schema |
24
+ | Compute freshness verdicts | schema |
25
+ | Plan generation (emit-ready payloads) | schema |
26
+
27
+ The CLI calls `buildRegistry` once after discovery and passes the resulting `Registry` to every handler. The handlers never re-read files; the data they need is already in their `*Opts` argument. This separation is gated by [`../tests/registry/handler-purity.test.ts`](../tests/registry/handler-purity.test.ts).
28
+
29
+ ## Subpath visibility (Master plan Decision #10)
30
+
31
+ The registry surface is reachable through one — and only one — path:
32
+
33
+ ```text
34
+ @nekostack/schema public v0.6 consumer API (s, parse, safeParse, …)
35
+ @nekostack/schema/cli package-internal CLI integration API (this file)
36
+ ```
37
+
38
+ Root `@nekostack/schema` does NOT export any registry / handler / freshness / generation name. The Step 16 negative gate in [`../tests/public-surface.test.ts`](../tests/public-surface.test.ts) enforces this — adding a v0.7 name to the root barrel causes that suite to fail. The positive gate in [`../tests/registry-surface.test.ts`](../tests/registry-surface.test.ts) verifies the `/cli` subpath does expose the surface. Together the two suites lock the boundary.
39
+
40
+ If a future phase ships `@nekostack/registry` as its own package, the `/cli` barrel moves there with zero impact on root consumers.
41
+
42
+ ## Input shape — `RegistrySourceEntry`
43
+
44
+ One discovered schema source file after the CLI has read its bytes and dynamic-imported it:
45
+
46
+ ```ts
47
+ interface RegistrySourceEntry {
48
+ readonly sourcePath: string; // CLI-relative source location
49
+ readonly sourceText: string; // exact UTF-8 bytes the CLI read
50
+ readonly schemas: readonly AnySchema[]; // every Schema instance exported
51
+ }
52
+ ```
53
+
54
+ A file may legitimately declare more than one schema. The registry indexes each named one independently. See [`../src/registry/types.ts`](../src/registry/types.ts).
55
+
56
+ ## Indexed shape — `RegistryEntry`
57
+
58
+ One indexed schema. The registry stores these by `(schemaId, schemaVersion)`:
59
+
60
+ ```ts
61
+ interface RegistryEntry {
62
+ readonly schemaId: string;
63
+ readonly schemaVersion: string | undefined;
64
+ readonly irHash: `sha256:${string}`;
65
+ readonly sourceHash: `sha256:${string}`;
66
+ readonly sourcePath: string;
67
+ readonly schema: AnySchema;
68
+ }
69
+ ```
70
+
71
+ `schemaVersion` is `undefined` when the source schema omitted `.version(...)`. The internal map key for the unversioned case is the empty string `""` so the `Registry` lookup type stays uniform; the `schemaVersion` field on the entry remains `undefined`.
72
+
73
+ ## Registry map shape
74
+
75
+ ```ts
76
+ type Registry = ReadonlyMap<
77
+ string, // outer key: schemaId
78
+ ReadonlyMap<string, RegistryEntry> // inner key: schemaVersion or ""
79
+ >;
80
+ ```
81
+
82
+ `buildRegistry` is the only legitimate producer of `Registry` values. Downstream callers treat them as opaque and use `findSchema` for lookup or `listHandler` for enumeration.
83
+
84
+ ## Hash discipline (Master plan Decision #7 / #8)
85
+
86
+ Two distinct hashes — they capture different things and are both required for the freshness matrix.
87
+
88
+ ### `sourceHash`
89
+
90
+ `sha256` of the source file's **raw UTF-8 bytes** ([`../src/registry/source-hash.ts`](../src/registry/source-hash.ts)):
91
+
92
+ ```ts
93
+ sourceHashFromText(text): `sha256:${hex}`
94
+ ```
95
+
96
+ Captures source identity exactly — comments, whitespace, declaration order all contribute. Same source file → same `sourceHash`, even if the resulting IR is unchanged. `buildRegistry` computes this once per `RegistrySourceEntry`; every `RegistryEntry` produced from the same source file shares the same `sourceHash` value.
97
+
98
+ ### `irHash`
99
+
100
+ `sha256` of the canonical IR serialization ([`../src/ir/hash.ts`](../src/ir/hash.ts)):
101
+
102
+ ```ts
103
+ irHash(schema.node): hex // wrapped as `sha256:${hex}` on RegistryEntry
104
+ ```
105
+
106
+ Captures semantic identity. Refactoring the source — renaming a helper, reordering exports, changing comments — does not change `irHash`. The v0.2+ generator-header contract documents this hash in every artifact (see [`./HEADER_FORMAT.md`](./HEADER_FORMAT.md)); v0.7 extends the header to also include `sourceHash`.
107
+
108
+ ### Why both
109
+
110
+ A diff between *the registry's recorded hashes* and *the artifact's emitted hashes* tells the CLI exactly what kind of staleness it is looking at — semantic vs. cosmetic — without re-running generators. The two-hash freshness matrix below codifies that read.
111
+
112
+ ## `buildRegistry` — duplicates, anonymous, unversioned
113
+
114
+ ```ts
115
+ buildRegistry(entries): Result<Registry>
116
+ ```
117
+
118
+ Pure. No `fs.*`, no `import()`, no `path.*`. Implementation: [`../src/registry/build-registry.ts`](../src/registry/build-registry.ts).
119
+
120
+ Rules:
121
+
122
+ - **Anonymous schemas** (no `.id()`) are silently ignored by the indexer. They remain legal per v0.1 — they just don't participate in registry lookup. The CLI emits a stderr warning per anonymous schema it sees (Master plan Decision #5); this layer is silent.
123
+ - **Unversioned schemas** (`.id()` but no `.version()`) are indexed under the empty-string inner key. The on-entry `schemaVersion` field stays `undefined`.
124
+ - **Duplicate `(schemaId, versionKey)` pairs** across the entry list are collected into one `Issue` per duplicate with code `"duplicate_schema_id"`. The function returns `Result.failure` with the issue list; it never throws. The first-seen entry is kept in the partial map so downstream code in the same call doesn't read a torn state.
125
+
126
+ The `duplicate_schema_id` Issue carries `metadata.sourcePaths`, `metadata.schemaId`, and `metadata.schemaVersion` so the CLI can render a precise message ("`X` v1.0.0 is declared in both `a.schema.ts` and `b.schema.ts`").
127
+
128
+ ## `findSchema` — lookup rules
129
+
130
+ ```ts
131
+ findSchema(registry, schemaId, version?): RegistryEntry | undefined
132
+ ```
133
+
134
+ Implementation: [`../src/registry/build-registry.ts`](../src/registry/build-registry.ts).
135
+
136
+ | Inputs | Result |
137
+ |---|---|
138
+ | `findSchema(reg, "X", "1.0.0")` | The entry whose `schemaVersion === "1.0.0"`, or `undefined` if no such entry. |
139
+ | `findSchema(reg, "X", "")` | The unversioned entry for `X`, if one exists. The empty string is the intentional way to address an unversioned schema by exact lookup. |
140
+ | `findSchema(reg, "X")` (version omitted, both versioned and unversioned exist) | The **highest semver** entry. Versioned always wins over unversioned when at least one versioned entry exists. |
141
+ | `findSchema(reg, "X")` (version omitted, only unversioned exists) | The unversioned entry. |
142
+ | `findSchema(reg, "X", …)` with `X` not in the registry | `undefined`. |
143
+
144
+ Semver comparison is numeric on `major.minor.patch`. Non-conforming version strings fall back to `localeCompare` so the lookup never throws on a non-standard version.
145
+
146
+ ## `listHandler` — enumeration
147
+
148
+ ```ts
149
+ listHandler({ registry }): Result<{ entries: readonly RegistryEntry[] }>
150
+ ```
151
+
152
+ Implementation: [`../src/registry/handlers/list.ts`](../src/registry/handlers/list.ts).
153
+
154
+ Deterministic order:
155
+
156
+ 1. Across `schemaId`: alphabetical ascending.
157
+ 2. Within one `schemaId`: versioned entries first, ascending by numeric `major.minor.patch` semver; the unversioned entry (if any) comes **last**.
158
+
159
+ Returns `success: true` with an empty array for an empty registry. `listHandler` has no failure mode — `success: false` is unreachable.
160
+
161
+ ## `checkHandler` — two-hash freshness matrix
162
+
163
+ ```ts
164
+ checkHandler({ registry, committedArtifacts }): Result<{ verdicts }>
165
+ ```
166
+
167
+ Implementation: [`../src/registry/handlers/check.ts`](../src/registry/handlers/check.ts).
168
+
169
+ The CLI reads each artifact's bytes from disk and hands them in. The handler parses provenance ([`../src/registry/parse-provenance.ts`](../src/registry/parse-provenance.ts)), looks up the matching `RegistryEntry`, and emits one `FreshnessVerdict` per artifact.
170
+
171
+ ### Verdict matrix
172
+
173
+ | Artifact `irHash` vs registry | Artifact `sourceHash` vs registry | Verdict | Meaning |
174
+ |---|---|---|---|
175
+ | matches | matches | `clean` | Artifact is current; nothing to do. |
176
+ | matches | differs | `cosmetic_drift` | Source text edited without semantic effect. CLI prints a stderr warning; CI still passes. |
177
+ | differs | differs | `stale` | Regenerate required. CLI exits 1. |
178
+ | differs | matches | `integrity_error` | The impossible row — should never happen unless the artifact was hand-edited or the recorded `sourceHash` was tampered with. CLI exits 4 and refuses to auto-regenerate. |
179
+
180
+ ### v0.6 backward compatibility (Master plan Decision #8)
181
+
182
+ Artifacts emitted before Step 4 of v0.7 — `@nekostack/schema@0.6.0` and earlier — have no `sourceHash` field/line. `parseProvenanceFromText` returns `sourceHash: undefined` for those. The handler treats them as:
183
+
184
+ | Artifact `irHash` vs registry | Verdict (no `sourceHash` recorded) |
185
+ |---|---|
186
+ | matches | `clean` |
187
+ | differs | `stale` |
188
+
189
+ Absent `sourceHash` is **never** an `integrity_error` by itself — the artifact simply predates the two-hash discipline. Once the user regenerates at v0.7+, full matrix participation resumes.
190
+
191
+ ### Failure paths
192
+
193
+ `checkHandler` returns `Result.failure` (and emits no verdicts for that call) on any of:
194
+
195
+ - **Malformed provenance** — `parseProvenanceFromText` returns an `integrity_error` Issue with `metadata.reason` (`unknown_format`, `missing_provenance`, `missing_field`, `malformed_hash`, `json_parse_error`, `malformed_field`). The handler forwards each issue with the artifact path attached.
196
+ - **Anonymous artifact** (provenance `schemaId === null`) — `schema_not_found` with `metadata.reason = "anonymous_artifact"`. Anonymous schemas are not indexed, so there is nothing to validate against.
197
+ - **`schemaId` not in registry** — `schema_not_found`. The user likely deleted a schema source file but forgot to delete its artifacts.
198
+ - **`schemaId` present but version missing** — `version_not_found`. Distinct from `schema_not_found` so the CLI can format orphan-by-id vs. orphan-by-version differently.
199
+
200
+ Per-artifact verdicts are returned only when **every** artifact parses and resolves cleanly. A single failure causes the whole call to fail — this matches the CLI's exit semantics (a `check` run either passes or surfaces the full issue list).
201
+
202
+ ## `generateHandler` — artifact planning
203
+
204
+ ```ts
205
+ generateHandler({ entries }): Result<{ artifacts: readonly GeneratedArtifact[] }>
206
+ ```
207
+
208
+ Implementation: [`../src/registry/handlers/generate.ts`](../src/registry/handlers/generate.ts).
209
+
210
+ For every **named** schema in every `RegistrySourceEntry`, emits all four artifact kinds — `typescript`, `zod`, `jsonSchema`, `openApi` — as `GeneratedArtifact` payloads. The CLI is responsible for writing each payload to its `suggestedPath`; this handler never touches the filesystem.
211
+
212
+ ```ts
213
+ interface GeneratedArtifact {
214
+ readonly schemaId: string;
215
+ readonly kind: GeneratorKind;
216
+ readonly suggestedPath: string;
217
+ readonly content: string;
218
+ readonly irHash: `sha256:${string}`;
219
+ readonly sourceHash: `sha256:${string}`;
220
+ }
221
+ ```
222
+
223
+ - **Anonymous schemas** (no `.id()`) are silently skipped — they cannot participate in registry lookup downstream. The CLI warns; the handler is silent (Decision #5).
224
+ - **`sourceHash` is computed once per entry** via `sourceHashFromText(entry.sourceText)` and passed through to each generator's `ProvenanceOptions` so the emitted artifact bytes carry the source-side hash. `irHash` is computed per schema from `schema.node` and stamped on each `GeneratedArtifact`; it is **not** part of `ProvenanceOptions`.
225
+ - **Partial generation is not supported in v0.7** (Master plan Decision #6). `GenerateOpts` carries no `kinds` filter. Every named schema produces exactly four artifacts; the `check` verb expects all four to be present on disk.
226
+
227
+ ## Generated-artifact path convention (Master plan Decision #6)
228
+
229
+ Locked single-schema layout — given `schemas/user.schema.ts`:
230
+
231
+ ```text
232
+ schemas/user.schema.ts (source)
233
+
234
+ schemas/generated/user.types.ts (typescript)
235
+ schemas/generated/user.zod.ts (zod)
236
+ schemas/generated/user.json.schema.json (jsonSchema)
237
+ schemas/generated/user.openapi.json (openApi)
238
+ ```
239
+
240
+ The basename strips a `.schema.{ts,js,mts,cts}` suffix when present; falls back to "filename minus last extension" otherwise. Path manipulation is plain strings — no `node:path` import — so the handler stays trivially platform-agnostic. Forward slashes are the canonical separator; the CLI normalizes to platform separators on write.
241
+
242
+ `suggestedPathFor(sourcePath, kind, options?)` is exported from `@nekostack/schema/cli` so the CLI can advertise the same convention for `check`'s artifact-lookup without re-deriving the rule.
243
+
244
+ ### Multi-schema source-file disambiguation
245
+
246
+ When a single source file declares **two or more** named schemas, each schema's four artifacts gain a slugged discriminator so paths don't collide:
247
+
248
+ ```ts
249
+ // schemas/account.schema.ts
250
+ export const Tenant = s.object(...).id("com.x.Tenant");
251
+ export const Audit = s.object(...).id("com.x.AuditEvent");
252
+ ```
253
+
254
+ ```text
255
+ schemas/generated/account.com-x-tenant.types.ts
256
+ schemas/generated/account.com-x-tenant.zod.ts
257
+ schemas/generated/account.com-x-tenant.json.schema.json
258
+ schemas/generated/account.com-x-tenant.openapi.json
259
+ schemas/generated/account.com-x-auditevent.types.ts
260
+ ... (and so on)
261
+ ```
262
+
263
+ Discriminator slug rule:
264
+
265
+ ```text
266
+ lowercase → non-alphanumeric runs collapse to "-" → leading/trailing "-" trimmed
267
+ ```
268
+
269
+ If the same `schemaId` appears at multiple versions inside one file (rare; v0.7 doesn't endorse but does tolerate), the discriminator additionally embeds the slugged version (`com-x-tenant-1-0-0`, `com-x-tenant-2-0-0`) so per-schema paths stay unique. Single-schema files never get a discriminator — the path is exactly the simple form above.
270
+
271
+ ## Pure-handler gate
272
+
273
+ [`../tests/registry/handler-purity.test.ts`](../tests/registry/handler-purity.test.ts) gates the schema-side boundary. Each of the four handlers (`list`, `diff`, `check`, `generate`) plus their transitive reach is asserted to:
274
+
275
+ - Not import `node:fs` / `fs` / `fs/promises` (static file-level scan over each handler's module-graph reach).
276
+ - Not call `console.log` / `console.error` / `console.warn` / `console.info` / `console.debug` at module load or invocation time (runtime spy + static pattern).
277
+ - Not call `process.exit` / `process.abort` (runtime spy + static pattern).
278
+ - Not invoke dynamic `import()` (static pattern).
279
+
280
+ Sentinel tests verify the gate catches what it claims. Any future handler change that crosses the boundary fails this suite before it reaches review.
281
+
282
+ ## Integration surface — `@nekostack/schema/cli`
283
+
284
+ The barrel ([`../src/cli-integration.ts`](../src/cli-integration.ts)) re-exports exactly the names the CLI needs:
285
+
286
+ **Runtime exports**
287
+
288
+ ```text
289
+ sourceHashFromText parseProvenanceFromText
290
+ buildRegistry findSchema
291
+ diffNodes
292
+ listHandler diffHandler checkHandler generateHandler
293
+ suggestedPathFor GENERATOR_KINDS
294
+ ```
295
+
296
+ **Type exports**
297
+
298
+ ```text
299
+ RegistrySourceEntry RegistryEntry Registry
300
+ DiffSeverity DiffKind DiffChange
301
+ FreshnessVerdict
302
+ GeneratorKind GeneratedArtifact CommittedArtifact
303
+ GenerateOpts GenerateResult
304
+ CheckOpts CheckResult
305
+ DiffOpts DiffResult
306
+ ListOpts ListResult
307
+ ```
308
+
309
+ The `package.json` `exports` map ([`../package.json`](../package.json)) wires:
310
+
311
+ ```jsonc
312
+ {
313
+ ".": "./src/index.ts", // v0.6 public surface
314
+ "./cli": "./src/cli-integration.ts" // v0.7 CLI integration surface
315
+ }
316
+ ```
317
+
318
+ The `/cli` subpath is **package-internal**. External consumers of `@nekostack/schema` should not import from it; names exposed there are subject to internal change between minor versions and engine-swap-safety lives at the root, not at this subpath.
319
+
320
+ ## Implementation reference
321
+
322
+ | Surface | File |
323
+ |---|---|
324
+ | Types | [`../src/registry/types.ts`](../src/registry/types.ts) |
325
+ | `sourceHashFromText` | [`../src/registry/source-hash.ts`](../src/registry/source-hash.ts) |
326
+ | `parseProvenanceFromText` | [`../src/registry/parse-provenance.ts`](../src/registry/parse-provenance.ts) |
327
+ | `buildRegistry`, `findSchema` | [`../src/registry/build-registry.ts`](../src/registry/build-registry.ts) |
328
+ | `diffNodes` | [`../src/registry/diff.ts`](../src/registry/diff.ts) |
329
+ | `listHandler` | [`../src/registry/handlers/list.ts`](../src/registry/handlers/list.ts) |
330
+ | `diffHandler` | [`../src/registry/handlers/diff.ts`](../src/registry/handlers/diff.ts) |
331
+ | `checkHandler` | [`../src/registry/handlers/check.ts`](../src/registry/handlers/check.ts) |
332
+ | `generateHandler`, `suggestedPathFor`, `GENERATOR_KINDS` | [`../src/registry/handlers/generate.ts`](../src/registry/handlers/generate.ts) |
333
+ | Integration barrel | [`../src/cli-integration.ts`](../src/cli-integration.ts) |
334
+ | Master plan source of truth | [`./PHASE_PLAN_v0.7.md`](./PHASE_PLAN_v0.7.md) |
335
+ | Diff classification contract | [`./DIFF_CLASSIFICATION.md`](./DIFF_CLASSIFICATION.md) |
336
+ | Generator-header format | [`./HEADER_FORMAT.md`](./HEADER_FORMAT.md) |
@@ -0,0 +1,279 @@
1
+ # Runtime Validation
2
+
3
+ > The v0.6 runtime API for `@nekostack/schema`. Defines what `parse` / `safeParse` / `validate` do, how their output relates to a schema's input vs. output type, what the issue contract is, and where Zod fits inside the wrapper. For the v0.1 absence-semantics table this doc references, see [`ABSENCE_SEMANTICS.md`](./ABSENCE_SEMANTICS.md). For the locked phase plan, see [`PHASE_PLAN_v0.6.md`](./PHASE_PLAN_v0.6.md).
4
+
5
+ ## Purpose
6
+
7
+ v0.6 makes runtime validation a NekoStack-owned workflow. A consumer never imports Zod for validation; they import `@nekostack/schema`, define a schema once via `s.*`, and use `parse` / `safeParse` / `validate` against it. Zod executes behind the wrapper, but the consumer's vocabulary is `Schema<I, O>`, `Result<T>`, `Issue[]`, `ParseError` — not `ZodSchema`, `ZodError`, or a Zod issue code.
8
+
9
+ This is the [thesis-fit](../../../PRODUCT_THESIS.md) lens applied to validation: the package absorbs the workflow, not just adapts an external tool.
10
+
11
+ ## Public API
12
+
13
+ ```ts
14
+ import { s, parse, safeParse, validate, ParseError } from "@nekostack/schema";
15
+ import type { Result, Issue } from "@nekostack/schema";
16
+
17
+ declare const User: ReturnType<typeof s.object>;
18
+
19
+ parse(User, input); // s.output<typeof User> (throws ParseError)
20
+ safeParse(User, input); // Result<s.output<typeof User>>
21
+ validate(User, input); // Result<s.input<typeof User>>
22
+ ```
23
+
24
+ Three free functions, one error class. The schema-as-first-arg shape is deliberate — it keeps every entry point a plain function so it composes with any wiring layer (HTTP handlers, form validators, agent tool implementations) without becoming method-bound to a class.
25
+
26
+ ## `parse`
27
+
28
+ ```ts
29
+ function parse<S>(schema: S, input: unknown): s.output<S>
30
+ ```
31
+
32
+ - Throws [`ParseError`](#parseerror) on failure. The thrown error carries the full normalized `Issue` list.
33
+ - Uses **output semantics**: the return type matches `s.output<S>`. Default values are filled before the value is returned.
34
+ - Use when the caller doesn't have a meaningful "what to do on failure" branch — `parse` is the friction-causing default; failure is loud.
35
+
36
+ ```ts
37
+ const User = s.object({ name: s.string().default("anon") });
38
+ parse(User, {}); // → { name: "anon" }
39
+ parse(User, { name: 42 }); // throws ParseError([{ code: "invalid_type", path: ["name"], ... }])
40
+ ```
41
+
42
+ ## `safeParse`
43
+
44
+ ```ts
45
+ function safeParse<S>(schema: S, input: unknown): Result<s.output<S>>
46
+ ```
47
+
48
+ - Never throws. Returns `{ success: true, data }` or `{ success: false, issues }`.
49
+ - Same output semantics + default-fill behavior as `parse`. Differs only in the error mode.
50
+ - Use when the caller wants to inline-branch on success/failure.
51
+
52
+ ```ts
53
+ const r = safeParse(User, {});
54
+ if (r.success) {
55
+ r.data.name; // "anon"
56
+ } else {
57
+ r.issues.forEach((i) => console.log(i.code, i.path));
58
+ }
59
+ ```
60
+
61
+ ## `validate`
62
+
63
+ ```ts
64
+ function validate<S>(schema: S, input: unknown): Result<s.input<S>>
65
+ ```
66
+
67
+ - Structural check. Returns `Result<s.input<S>>`.
68
+ - Uses **input semantics**: the return type is the *input* shape — default-bearing fields are object-optional in Input but object-required in Output (see [`ABSENCE_SEMANTICS.md`](./ABSENCE_SEMANTICS.md)).
69
+ - Does **not** apply defaults. Does **not** run transforms.
70
+ - **Still runs portable refinements** — `min` / `max` / `regex` / `email` / `int` / etc. are all enforced.
71
+ - Use when the caller wants to know whether the input would pass a `parse` *without* paying for transforms or accepting a default-filled object.
72
+
73
+ ```ts
74
+ const User = s.object({ name: s.string().min(3).default("anon") });
75
+ validate(User, {}); // → { success: true, data: {} } (no fill; absent is valid)
76
+ validate(User, { name: "rin" });// → { success: true, data: { name: "rin" }} (passes through)
77
+ validate(User, { name: "ab" }); // → { success: false, issues: [{ code: "too_small", ... }] } (refinement still runs)
78
+ ```
79
+
80
+ The split between `parse` and `validate` is the v0.6-locked Decision #8 read of the v0.1 absence-semantics table — see [Validate-only IR variant](#validate-only-ir-variant) below for how it is realized internally.
81
+
82
+ ## Default semantics
83
+
84
+ A field declared with `.default(v)` is **input-optional, output-required** (v0.1 Invariant 4):
85
+
86
+ | Entry point | Missing default-bearing field | Explicit value |
87
+ |---|---|---|
88
+ | `parse(schema, input)` | fills `v` in output | passed through |
89
+ | `safeParse(schema, input)` | fills `v` in `data` on success | passed through |
90
+ | `validate(schema, input)` | accepted; **not filled**; absent in returned data | passed through |
91
+
92
+ `null` is **not** equivalent to "missing." `default(v)` does not imply `nullable`; `null` on a non-nullable default-bearing field is rejected by all three entry points. To accept both null and missing, chain `.nullable().default(v)`.
93
+
94
+ ## Unknown-key policies
95
+
96
+ Every object has an explicit policy. The compile path realizes the policy directly into the underlying Zod schema; behavior is identical across `parse` / `safeParse` / `validate`.
97
+
98
+ | Policy | `parse` / `safeParse` | `validate` |
99
+ |---|---|---|
100
+ | `strict` (default) | rejects with one `unknown_key` issue per offending key | rejects with `unknown_key` issues |
101
+ | `passthrough` | accepts; **preserves** unknown keys in returned data | accepts; preserves |
102
+ | `stripUnknown` | accepts; **drops** unknown keys from returned data | accepts; drops |
103
+
104
+ Zod batches all offending keys into a single `unrecognized_keys` issue; the issue-normalization layer splits them so one `unknown_key` lands per key. Per-emitted path is `[...originalPath, key]`.
105
+
106
+ Nested objects each carry their own policy. An outer-strict + inner-passthrough composition is legal; each level enforces its own rule independently.
107
+
108
+ ## Issue normalization
109
+
110
+ The runtime never surfaces a `ZodError`. Every failure path produces a `readonly Issue[]` using NekoStack's stable [`IssueCode`](../src/errors/issue.ts) vocabulary, per Decision #12:
111
+
112
+ | Zod issue code | NekoStack `IssueCode` | Notes |
113
+ |---|---|---|
114
+ | `invalid_type` (received `undefined`, expected ≠ undefined) | `missing_required` | "field is absent" beats "field has wrong type" |
115
+ | `invalid_type` (other) | `invalid_type` | |
116
+ | `unrecognized_keys` | `unknown_key` | one issue per key (Zod batches, the normalizer splits) |
117
+ | `invalid_literal` | `invalid_literal` | `expected` / `received` preserved |
118
+ | `invalid_enum_value` | `invalid_enum` | `expected` carries the option list |
119
+ | `invalid_union` / `invalid_union_discriminator` | `invalid_union` | discriminator folded — no v0.6 public surface |
120
+ | `invalid_arguments` / `invalid_return_type` | `invalid_type` | + `metadata.source = "zod"`, `metadata.zodCode = <original>` |
121
+ | `too_small` | `too_small` | + constraint metadata (`minimum`, `inclusive`, `exact`, `type`) |
122
+ | `too_big` | `too_big` | + constraint metadata (`maximum`, `inclusive`, `exact`, `type`) |
123
+ | `invalid_string` | `invalid_format` | + `metadata.validation` (`"email"` / `"url"` / `"uuid"` / `"regex"` / …) |
124
+ | `invalid_date` | `invalid_type` | DateNode has no v0.6 builder |
125
+ | `custom` | `custom_refinement_failed` | |
126
+ | **anything else** | `custom_refinement_failed` | + `metadata.source = "zod"`, `metadata.zodCode = <original>` |
127
+
128
+ The last row is the **fallback contract** (Decision #12 round-2): adding a new Zod code in a future Zod release must not crash the normalizer. `metadata.source` is the discriminator consumers key off; `metadata.zodCode` is what they use for triage.
129
+
130
+ Every emitted `Issue` also carries:
131
+
132
+ - `path` — Zod path, copied (not referenced — defensive against Zod array mutation).
133
+ - `message` — Zod's human message, verbatim.
134
+ - `expected` / `received` — verbatim where Zod provides them.
135
+ - `schemaId` / `schemaVersion` — copied from `schema.metadata` when present; omitted when absent.
136
+ - `severity: "error"` — always, in v0.6. The `"warning"` channel is reserved for later.
137
+
138
+ ## `ParseError`
139
+
140
+ ```ts
141
+ class ParseError extends Error {
142
+ readonly code: "parse_failed";
143
+ readonly issues: readonly Issue[];
144
+ }
145
+ ```
146
+
147
+ - Thrown only by `parse`. `safeParse` and `validate` return `Result` and never throw.
148
+ - The `issues` array is a defensive copy and frozen — mutating it post-construction has no effect.
149
+ - `name` is `"ParseError"` so `e.name === "ParseError"` works for non-`instanceof` discrimination.
150
+ - `code === "parse_failed"` is the stable literal for switching. **Don't** depend on `message` — it's human-readable but not part of the contract.
151
+
152
+ ```ts
153
+ try {
154
+ parse(User, input);
155
+ } catch (e) {
156
+ if (e instanceof ParseError) {
157
+ for (const i of e.issues) handle(i);
158
+ } else {
159
+ throw e;
160
+ }
161
+ }
162
+ ```
163
+
164
+ ## Internal engine
165
+
166
+ Zod is the **internal execution engine** for v0.6. It is not part of the user-facing surface:
167
+
168
+ - A consumer of `@nekostack/schema` does **not** import Zod.
169
+ - The runtime API accepts NekoStack `Schema` builders, not `ZodSchema` instances.
170
+ - `parse` / `safeParse` / `validate` return NekoStack types (`Result`, `Issue`, `ParseError`) — never a `ZodError`.
171
+ - A future runtime that replaces Zod with a pure IR-walker engine **must** be a no-op for consumers.
172
+
173
+ This is the thesis-fit boundary applied to engines: the wrapper has absorbed the workflow. If a consumer needs to know which engine is underneath to use the package correctly, the wrapper is leaking — that's a regression, not a feature.
174
+
175
+ Under the hood, two consumers share one **semantic mapping** (Decision #6, [`ZOD_MODIFIER_ORDERING.md`](./ZOD_MODIFIER_ORDERING.md)):
176
+
177
+ ```
178
+ src/generators/zod-mapping.ts # shared semantic mapping
179
+ │ (IR traversal + v0.2 modifier
180
+ │ ordering; ZodEmitter<T> + emit<T>())
181
+
182
+ ├─→ src/generators/zod.ts (ZodEmitter<string>)
183
+ │ (deterministic TS source —
184
+ │ used by the generator pipeline)
185
+
186
+ └─→ src/runtime/zod-compile.ts (ZodEmitter<ZodTypeAny>)
187
+ (live runtime value —
188
+ used by the cache below)
189
+ ```
190
+
191
+ No `eval` of generated source. No source-to-value parsing. No value-to-source serialization. Each consumer realizes the mapping independently in its own concrete output type.
192
+
193
+ ## Compile cache
194
+
195
+ ```
196
+ src/runtime/compile.ts:
197
+ const cache = new WeakMap<SchemaNode, ZodTypeAny>();
198
+ ```
199
+
200
+ - Cache key is **`SchemaNode` object identity**, not IR equality.
201
+ - First call per `SchemaNode`: build the live Zod schema via the value consumer, store in the cache.
202
+ - Subsequent calls with the same `SchemaNode`: return the cached `ZodTypeAny` reference.
203
+ - Two distinct `SchemaNode` instances with byte-identical IR do **not** share — explicit dedup via `irHash` (v0.2) is a v0.7 registry concern, not a runtime concern.
204
+ - Lazy: schemas defined but never validated never compile.
205
+ - The IR is deep-frozen by the builder, so storing the live Zod schema next to its key is safe — the IR cannot mutate out from under the cached value.
206
+ - `WeakMap` lets the entire `(node, compiled)` pair be garbage-collected when the consumer drops its reference to the schema.
207
+
208
+ **Concurrency.** Node is single-threaded; first-call wins by default and there's no race. A future worker-threads consumer that shares a `SchemaNode` across threads will get a per-thread cache. Documented here; no v0.6 mitigation.
209
+
210
+ ## Validate-only IR variant
211
+
212
+ `validate` cannot simply reuse the parse-time compiled Zod, because the parse-time schema fills defaults and the validate contract says it must not. Decision #8 is the resolution:
213
+
214
+ - For every default-bearing field, drop `modifiers.default` and set `modifiers.optional = true`.
215
+ - Keep `nullable` / `nullish`, refinements, metadata, and the `unknownKeys` policy.
216
+ - Recurse through object fields and array elements.
217
+
218
+ The transform lives in `src/runtime/strip-defaults.ts`. The variant is **not** the same `SchemaNode` as the original — the compile cache keys on identity, so the variant lands in its own cache slot automatically.
219
+
220
+ To keep `validate` cache-friendly, `src/runtime/parse.ts` adds a second `WeakMap`:
221
+
222
+ ```
223
+ src/runtime/parse.ts:
224
+ const validateNodeCache = new WeakMap<SchemaNode, SchemaNode>();
225
+ ```
226
+
227
+ - Cache key: the **original** `SchemaNode` (the one the consumer wrote).
228
+ - Cache value: the stripped variant tree.
229
+ - First `validate` call per schema: strip once, cache the variant. Repeated `validate(sameSchema, ...)` calls reuse the variant and therefore the same compiled Zod.
230
+
231
+ **Issue normalization for `validate` is passed the original `schema.node`**, never the variant — so `schemaId` / `schemaVersion` always come from the consumer-authored metadata regardless of which compile path produced the failure.
232
+
233
+ ## Semantic parity
234
+
235
+ The runtime is cross-checked against three independent engines (Decision #19), live in [`tests/semantic-parity.test.ts`](../tests/semantic-parity.test.ts):
236
+
237
+ 1. **NekoStack runtime** — `safeParse(schema, input).success`.
238
+ 2. **Generated-Zod execution** — emit source via `generateZod(schema.node)`, load + execute the emitted const expression with a real Zod runtime, call its `.safeParse(input)`. Explicitly **not** `compileZodSchema(...)` — the point is to cross-check the v0.2 source generator and the v0.6 runtime compiler.
239
+ 3. **Ajv 2020** — compile the output of `generateJsonSchema(schema.node)` and run the validator.
240
+ 4. **Small IR-walker oracle** — a direct interpreter over `SchemaNode` for the v0.6 supported subset. Knows nothing about Zod or JSON Schema.
241
+
242
+ Compare-only contract: **accept/reject**. Issue shapes are intentionally not compared across engines.
243
+
244
+ Redocly's role is separate (Decision #19a): [`tests/runtime-openapi-spec-validity.test.ts`](../tests/runtime-openapi-spec-validity.test.ts) verifies that every runtime-supported schema still emits an OpenAPI 3.1 component that passes structural validation. Redocly is **not** a runtime input oracle; treating it as one was the round-2 audit correction.
245
+
246
+ ## Unsupported behavior
247
+
248
+ The v0.6 runtime supports the same IR subset the v0.2 generators cover:
249
+
250
+ | Kind / feature | Status |
251
+ |---|---|
252
+ | `string` / `number` / `boolean` / `literal` / `enum` / `array` / `object` | supported |
253
+ | `optional` / `nullable` / `nullish` / `default` modifiers | supported |
254
+ | Portable refinements (`minLength`, `maxLength`, `length`, `regex`, `email`, `uuid`, `url`, `int`, `min`, `max`, `gt`, `lt`, `multipleOf`, `minItems`, `maxItems`) | supported |
255
+ | `date` IR | **throws `UnsupportedNodeKindError`** at compile time |
256
+ | `union` IR | **throws** at compile time |
257
+ | `recursiveRef` IR | **throws** at compile time |
258
+ | `transform` IR | **throws** at compile time |
259
+ | Runtime refinements (`{ kind: "runtime", ... }`) | **throws** at compile time (Invariant 7 — fail loudly, never silently drop) |
260
+ | Regex with non-empty flags in `generateJsonSchema` | throws (JSON Schema's `pattern` has no flag support); the runtime path itself supports flags |
261
+
262
+ The throws are intentional. Silently dropping a runtime refinement would compile a validator that accepts inputs the IR intends to reject — exactly the kind of subtle data-loss bug the package exists to prevent.
263
+
264
+ ## Non-goals
265
+
266
+ - **`ValidateError` to mirror `ParseError`.** `validate` returns `Result` only in v0.6. A `validateOrThrow` companion may land later if a consumer asks; the absence is intentional, not an oversight.
267
+ - **Method-style API (`schema.parse(input)`).** v0.6 ships the free functions. A `Schema.prototype.parse` alias could be added in a v0.6.x without breaking anything; the locked surface is the free-function form.
268
+ - **Schema-version negotiation.** Cross-version compatibility checks are a v0.7 registry concern.
269
+ - **Error message localization.** v0.6 surfaces Zod's English messages verbatim; `@nekostack/locale` integration is deferred.
270
+ - **`schemaId` / `schemaVersion` auto-population on every issue.** v0.6 populates these from `schema.metadata` when present; consumers that want them everywhere set the metadata. A registry could auto-fill in v0.7.
271
+ - **Worker-thread shared cache.** Per-thread caches are the v0.6 behavior; sharing across threads is a future concern.
272
+
273
+ ## See also
274
+
275
+ - [`ABSENCE_SEMANTICS.md`](./ABSENCE_SEMANTICS.md) — the v0.1 absence-semantics table that `default` / `optional` / `nullable` / `nullish` are realizations of.
276
+ - [`ZOD_MODIFIER_ORDERING.md`](./ZOD_MODIFIER_ORDERING.md) — the locked modifier-application order shared by the source generator and the runtime compiler.
277
+ - [`USAGE.md`](./USAGE.md) — v0.2 generator surface (TypeScript types, Zod source). The runtime API in this doc consumes the same schema definitions.
278
+ - [`PHASE_PLAN_v0.6.md`](./PHASE_PLAN_v0.6.md) — locked plan and the twenty-one decisions backing this contract.
279
+ - [`INVARIANTS.md`](./INVARIANTS.md) — cross-cutting invariants, including the engine-swap-safe and cache-invariance rules added in v0.6.