11# Schema migration
22
3- Practical reference for migrating data types in ` packages/opencode ` from Zod-first definitions to Effect Schema with Zod compatibility shims.
3+ Practical reference for migrating data types in ` packages/opencode ` from
4+ Zod-first definitions to Effect Schema with Zod compatibility shims.
45
56## Goal
67
7- Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors.
8+ Use Effect Schema as the source of truth for domain models, IDs, inputs,
9+ outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
10+ compatibility boundaries by exposing a ` .zod ` static derived from the Effect
11+ schema via ` @/util/effect-zod ` .
812
9- Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a ` .zod ` field derived from the Effect schema.
13+ The long-term driver is ` specs/effect/http-api.md ` — once the HTTP server
14+ moves to ` @effect/platform ` , every Schema-first DTO can flow through
15+ ` HttpApi ` / ` HttpRouter ` without a zod translation layer, and the entire
16+ ` effect-zod ` walker plus every ` .zod ` static can be deleted.
1017
1118## Preferred shapes
1219
@@ -24,17 +31,14 @@ export class Info extends Schema.Class<Info>("Foo.Info")({
2431}
2532```
2633
27- If the class cannot reference itself cleanly during initialization, use the existing two-step pattern:
34+ If the class cannot reference itself cleanly during initialization, use the
35+ two-step ` withStatics ` pattern:
2836
2937``` ts
30- const _Info = Schema .Struct ({
38+ export const Info = Schema .Struct ({
3139 id: FooID ,
3240 name: Schema .String ,
33- })
34-
35- export const Info = Object .assign (_Info , {
36- zod: zod (_Info ),
37- })
41+ }).pipe (withStatics ((s ) => ({ zod: zod (s ) })))
3842```
3943
4044### Errors
@@ -49,27 +53,89 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Foo
4953
5054### IDs and branded leaf types
5155
52- Keep branded/schema-backed IDs as Effect schemas and expose ` static readonly zod ` for compatibility when callers still expect Zod.
56+ Keep branded/schema-backed IDs as Effect schemas and expose
57+ ` static readonly zod ` for compatibility when callers still expect Zod.
58+
59+ ### Refinements
60+
61+ Reuse named refinements instead of re-spelling ` z.number().int().positive() `
62+ in every schema. The ` effect-zod ` walker translates the Effect versions into
63+ the corresponding zod methods, so JSON Schema output (` type: integer ` ,
64+ ` exclusiveMinimum ` , ` pattern ` , ` format: uuid ` , …) is preserved.
65+
66+ ``` ts
67+ const PositiveInt = Schema .Number .check (Schema .isInt ()).check (Schema .isGreaterThan (0 ))
68+ const NonNegativeInt = Schema .Number .check (Schema .isInt ()).check (Schema .isGreaterThanOrEqualTo (0 ))
69+ const HexColor = Schema .String .check (Schema .isPattern (/ ^ #[0-9a-fA-F ] {6} $ / ))
70+ ```
71+
72+ See ` test/util/effect-zod.test.ts ` for the full set of translated checks.
5373
5474## Compatibility rule
5575
56- During migration, route validators, tool parameters, and any existing Zod-based boundary should consume the derived ` .zod ` schema instead of maintaining a second hand-written Zod schema.
76+ During migration, route validators, tool parameters, and any existing
77+ Zod-based boundary should consume the derived ` .zod ` schema instead of
78+ maintaining a second hand-written Zod schema.
5779
5880The default should be:
5981
6082- Effect Schema owns the type
6183- ` .zod ` exists only as a compatibility surface
62- - new domain models should not start Zod-first unless there is a concrete boundary-specific need
84+ - new domain models should not start Zod-first unless there is a concrete
85+ boundary-specific need
6386
6487## When Zod can stay
6588
6689It is fine to keep a Zod-native schema temporarily when:
6790
68- - the type is only used at an HTTP or tool boundary
91+ - the type is only used at an HTTP or tool boundary and is not reused elsewhere
6992- the validator depends on Zod-only transforms or behavior not yet covered by ` zod() `
7093- the migration would force unrelated churn across a large call graph
7194
72- When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth.
95+ When this happens, prefer leaving a short note or TODO rather than silently
96+ creating a parallel schema source of truth.
97+
98+ ## Escape hatches
99+
100+ The walker in ` @/util/effect-zod ` exposes three explicit escape hatches for
101+ cases the pure-Schema path cannot express. Each one stays in the codebase
102+ only as long as its upstream or local dependency requires it — inline
103+ comments document when each can be deleted.
104+
105+ ### ` ZodOverride ` annotation
106+
107+ Replaces the entire derivation with a hand-crafted zod schema. Used when:
108+
109+ - the target carries external ` $ref ` metadata (e.g.
110+ ` config/model-id.ts ` points at ` https://models.dev/... ` )
111+ - the target is a zod-only schema that cannot yet be expressed as Schema
112+ (e.g. ` ConfigAgent.Info ` , ` ConfigPermission.Info ` , ` Log.Level ` )
113+
114+ ### ` ZodPreprocess ` annotation
115+
116+ Wraps the derived zod schema with ` z.preprocess(fn, inner) ` . Used by
117+ ` config/permission.ts ` to inject ` __originalKeys ` before parsing, because
118+ ` Schema.StructWithRest ` canonicalises output (known fields first, catchall
119+ after) and destroys the user's original property order — which permission
120+ rule precedence depends on.
121+
122+ Tracked upstream as ` effect:core/wlh553 ` : "Schema: add preserveInputOrder
123+ (or pre-parse hook) for open structs." Once that lands, ` ZodPreprocess ` and
124+ the ` __originalKeys ` hack can both be deleted.
125+
126+ ### Local ` DeepMutable<T> ` in ` config/config.ts `
127+
128+ ` Schema.Struct ` produces ` readonly ` types. Some consumer code (notably the
129+ ` Config ` service) mutates ` Info ` objects directly, so a readonly-stripping
130+ utility is needed when casting the derived zod schema's output type.
131+
132+ ` Types.DeepMutable ` from effect-smol would be a drop-in, but it widens
133+ ` unknown ` to ` {} ` in the fallback branch — a bug that affects any schema
134+ using ` Schema.Record(String, Schema.Unknown) ` .
135+
136+ Tracked upstream as ` effect:core/x228my ` : "Types.DeepMutable widens unknown
137+ to ` {} ` ." Once that lands, the local ` DeepMutable ` copy can be deleted and
138+ ` Types.DeepMutable ` used directly.
73139
74140## Ordering
75141
@@ -81,19 +147,179 @@ Migrate in this order:
811474 . Service-local internal models
821485 . Route and tool boundary validators that can switch to ` .zod `
83149
84- This keeps shared types canonical first and makes boundary updates mostly mechanical.
85-
86- ## Checklist
87-
88- - [ ] Shared ` schema.ts ` leaf models are Effect Schema-first
89- - [ ] Exported ` Info ` / ` Input ` / ` Output ` types use ` Schema.Class ` where appropriate
90- - [ ] Domain errors use ` Schema.TaggedErrorClass `
91- - [ ] Migrated types expose ` .zod ` for back compatibility
92- - [ ] Route and tool validators consume derived ` .zod ` instead of duplicate Zod definitions
93- - [ ] New domain models default to Effect Schema first
150+ This keeps shared types canonical first and makes boundary updates mostly
151+ mechanical.
152+
153+ ## Progress tracker
154+
155+ ### ` src/config/ ` ✅ complete
156+
157+ All of ` packages/opencode/src/config/ ` has been migrated. Files that still
158+ import ` z ` do so only for local ` ZodOverride ` bridges or for ` z.ZodType `
159+ type annotations — the ` export const <Info|Spec> ` values are all Effect
160+ Schema at source.
161+
162+ - [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
163+ - [x] server, layout
164+ - [x] keybinds
165+ - [x] permission#Info
166+ - [x] agent
167+ - [x] config.ts root
168+
169+ ### ` src/*/schema.ts ` leaf modules
170+
171+ These are the highest-priority next targets. Each is a small, self-contained
172+ schema module with a clear domain.
173+
174+ - [ ] ` src/control-plane/schema.ts `
175+ - [ ] ` src/permission/schema.ts `
176+ - [ ] ` src/project/schema.ts `
177+ - [ ] ` src/provider/schema.ts `
178+ - [ ] ` src/pty/schema.ts `
179+ - [ ] ` src/question/schema.ts `
180+ - [ ] ` src/session/schema.ts `
181+ - [ ] ` src/sync/schema.ts `
182+ - [ ] ` src/tool/schema.ts `
183+
184+ ### Session domain
185+
186+ Major cluster. Message + event types flow through the SSE API and every SDK
187+ output, so byte-identical SDK surface is critical.
188+
189+ - [ ] ` src/session/compaction.ts `
190+ - [ ] ` src/session/message-v2.ts `
191+ - [ ] ` src/session/message.ts `
192+ - [ ] ` src/session/prompt.ts `
193+ - [ ] ` src/session/revert.ts `
194+ - [ ] ` src/session/session.ts `
195+ - [ ] ` src/session/status.ts `
196+ - [ ] ` src/session/summary.ts `
197+ - [ ] ` src/session/todo.ts `
198+
199+ ### Provider domain
200+
201+ - [ ] ` src/provider/auth.ts `
202+ - [ ] ` src/provider/models.ts `
203+ - [ ] ` src/provider/provider.ts `
204+
205+ ### Tool schemas
206+
207+ Each tool declares its parameters via a zod schema. Tools are consumed by
208+ both the in-process runtime and the AI SDK's tool-calling layer, so the
209+ emitted JSON Schema must stay byte-identical.
210+
211+ - [ ] ` src/tool/apply_patch.ts `
212+ - [ ] ` src/tool/bash.ts `
213+ - [ ] ` src/tool/codesearch.ts `
214+ - [ ] ` src/tool/edit.ts `
215+ - [ ] ` src/tool/glob.ts `
216+ - [ ] ` src/tool/grep.ts `
217+ - [ ] ` src/tool/invalid.ts `
218+ - [ ] ` src/tool/lsp.ts `
219+ - [ ] ` src/tool/multiedit.ts `
220+ - [ ] ` src/tool/plan.ts `
221+ - [ ] ` src/tool/question.ts `
222+ - [ ] ` src/tool/read.ts `
223+ - [ ] ` src/tool/registry.ts `
224+ - [ ] ` src/tool/skill.ts `
225+ - [ ] ` src/tool/task.ts `
226+ - [ ] ` src/tool/todo.ts `
227+ - [ ] ` src/tool/tool.ts `
228+ - [ ] ` src/tool/webfetch.ts `
229+ - [ ] ` src/tool/websearch.ts `
230+ - [ ] ` src/tool/write.ts `
231+
232+ ### HTTP route boundaries
233+
234+ Every file in ` src/server/routes/ ` uses hono-openapi with zod validators for
235+ route inputs/outputs. Migrating these individually is the last step; most
236+ will switch to ` .zod ` derived from the Schema-migrated domain types above,
237+ which means touching them is largely mechanical once the domain side is
238+ done.
239+
240+ - [ ] ` src/server/error.ts `
241+ - [ ] ` src/server/event.ts `
242+ - [ ] ` src/server/projectors.ts `
243+ - [ ] ` src/server/routes/control/index.ts `
244+ - [ ] ` src/server/routes/control/workspace.ts `
245+ - [ ] ` src/server/routes/global.ts `
246+ - [ ] ` src/server/routes/instance/index.ts `
247+ - [ ] ` src/server/routes/instance/config.ts `
248+ - [ ] ` src/server/routes/instance/event.ts `
249+ - [ ] ` src/server/routes/instance/experimental.ts `
250+ - [ ] ` src/server/routes/instance/file.ts `
251+ - [ ] ` src/server/routes/instance/mcp.ts `
252+ - [ ] ` src/server/routes/instance/permission.ts `
253+ - [ ] ` src/server/routes/instance/project.ts `
254+ - [ ] ` src/server/routes/instance/provider.ts `
255+ - [ ] ` src/server/routes/instance/pty.ts `
256+ - [ ] ` src/server/routes/instance/question.ts `
257+ - [ ] ` src/server/routes/instance/session.ts `
258+ - [ ] ` src/server/routes/instance/sync.ts `
259+ - [ ] ` src/server/routes/instance/tui.ts `
260+
261+ The bigger prize for this group is the ` @effect/platform ` HTTP migration
262+ described in ` specs/effect/http-api.md ` . Once that lands, every one of
263+ these files changes shape entirely (` HttpApi.endpoint(...) ` and friends),
264+ so the Schema-first domain types become a prerequisite rather than a
265+ sibling task.
266+
267+ ### Everything else
268+
269+ Small / shared / control-plane / CLI. Mostly independent; can be done
270+ piecewise.
271+
272+ - [ ] ` src/acp/agent.ts `
273+ - [ ] ` src/agent/agent.ts `
274+ - [ ] ` src/bus/bus-event.ts `
275+ - [ ] ` src/bus/index.ts `
276+ - [ ] ` src/cli/cmd/tui/config/tui-migrate.ts `
277+ - [ ] ` src/cli/cmd/tui/config/tui-schema.ts `
278+ - [ ] ` src/cli/cmd/tui/config/tui.ts `
279+ - [ ] ` src/cli/cmd/tui/event.ts `
280+ - [ ] ` src/cli/ui.ts `
281+ - [ ] ` src/command/index.ts `
282+ - [ ] ` src/control-plane/adaptors/worktree.ts `
283+ - [ ] ` src/control-plane/types.ts `
284+ - [ ] ` src/control-plane/workspace.ts `
285+ - [ ] ` src/file/index.ts `
286+ - [ ] ` src/file/ripgrep.ts `
287+ - [ ] ` src/file/watcher.ts `
288+ - [ ] ` src/format/index.ts `
289+ - [ ] ` src/id/id.ts `
290+ - [ ] ` src/ide/index.ts `
291+ - [ ] ` src/installation/index.ts `
292+ - [ ] ` src/lsp/client.ts `
293+ - [ ] ` src/lsp/lsp.ts `
294+ - [ ] ` src/mcp/auth.ts `
295+ - [ ] ` src/patch/index.ts `
296+ - [ ] ` src/plugin/github-copilot/models.ts `
297+ - [ ] ` src/project/project.ts `
298+ - [ ] ` src/project/vcs.ts `
299+ - [ ] ` src/pty/index.ts `
300+ - [ ] ` src/skill/index.ts `
301+ - [ ] ` src/snapshot/index.ts `
302+ - [ ] ` src/storage/db.ts `
303+ - [ ] ` src/storage/storage.ts `
304+ - [ ] ` src/sync/index.ts `
305+ - [ ] ` src/util/fn.ts `
306+ - [ ] ` src/util/log.ts `
307+ - [ ] ` src/util/update-schema.ts `
308+ - [ ] ` src/worktree/index.ts `
309+
310+ ### Do-not-migrate
311+
312+ - ` src/util/effect-zod.ts ` — the walker itself. Stays zod-importing forever
313+ (it's what emits zod from Schema). Goes away only when the ` .zod `
314+ compatibility layer is no longer needed anywhere.
94315
95316## Notes
96317
97- - Use ` @/util/effect-zod ` for all Schema -> Zod conversion.
98- - Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type.
99- - Keep the migration incremental. Converting the domain model first is more valuable than converting every boundary in the same change.
318+ - Use ` @/util/effect-zod ` for all Schema → Zod conversion.
319+ - Prefer one canonical schema definition. Avoid maintaining parallel Zod and
320+ Effect definitions for the same domain type.
321+ - Keep the migration incremental. Converting the domain model first is more
322+ valuable than converting every boundary in the same change.
323+ - Every migrated file should leave the generated SDK output (`packages/sdk/
324+ openapi.json` and ` packages/sdk/js/src/v2/gen/types.gen.ts`) byte-identical
325+ unless the change is deliberately user-visible.
0 commit comments