opencode/plans/config-normalization.md

7.3 KiB

Mixed V1/V2 Config Normalization Plan

Status: Implemented and verified

Goal

Replace whole-document V1/V2 detection with one config-domain compatibility pipeline. Supported V1 fields, native V2 fields, and practical mixtures of both should load without an unrelated legacy key changing how the rest of the document is decoded.

Decision

Normalize recognized fields independently into the encoded side of the V2 Config.Info schema, then perform one final complete-document V2 decode:

JSON/JSONC encoded input
    -> parse and retain source-property presence
    -> validate each recognized field or collection entry
    -> migrate supported V1 candidates to V2 encoded values
    -> decode and re-encode native V2 candidates
    -> merge with native V2 precedence
    -> decode Config.Info once
    -> log redacted diagnostics

There is no whole-document version classification and no independent whole-document V1 and V2 decode.

The encoded boundary matters because schemas such as warming durations transform strings into runtime values. Decoded values must not be fed back into the encoded side of Config.Info.

Behavior

Situation Result
Supported V1-only field Migrate it to its canonical V2 destination.
Native V2 field Preserve it after schema decode and encode.
Disjoint V1 and V2 map entries Preserve both.
Same canonical scalar, map entry, or nested leaf Valid native V2 wins regardless of JSON key order.
Malformed native value with valid legacy fallback Skip native value, log it, and retain legacy value.
Malformed collection entry Skip only the explicitly supported recovery unit.
Unsupported accepted V1 setting Omit it and log a redacted warning.
Unknown field Continue ignoring it for forward compatibility.

Valid supported V1 syntax does not warn merely because it is legacy.

Field Precedence

Destination Lowest to highest precedence
snapshots snapshot < snapshots
share autoshare < share
references[name] reference[name] < references[name]
agents[name] agent[name] < mode[name] < agents[name]
commands[name] command[name] < commands[name]
providers[name] provider[name] < providers[name]
permissions tools rules < permission rules < native permissions
plugins migrated plugin items < native plugins items
media attachment < media
experimental.policies enabled-provider policies < disabled-provider policies < native policies
mcp.servers[name] direct legacy server < native servers[name]
mcp.timeout.* experimental.mcp_timeout < native timeout leaf
compaction.keep.tokens preserve_recent_tokens < keep.tokens
compaction.buffer reserved < buffer

Ordered rules and plugin directives retain both forms, with migrated V1 entries first and native V2 entries last.

Shared Shapes

Skills

  • A V2 array retains each valid string item.
  • A V1 object combines valid paths followed by valid urls.
  • Empty and unknown-only V1 objects normalize to an empty array under permissive excess-property handling.

MCP

  • Direct entries under mcp are V1 servers.
  • Entries under mcp.servers are native V2 servers.
  • Both sets are merged by server name, with a complete native server replacing a duplicate legacy server.
  • A malformed native duplicate is skipped so a valid legacy server remains.
  • Native global timeout leaves override only matching values migrated from experimental.mcp_timeout.
  • Raw type and enabled discriminators preserve legacy servers that happen to be named servers or timeout.

Compaction

  • preserve_recent_tokens becomes keep.tokens.
  • reserved becomes buffer.
  • Native leaves win conflicts.
  • tail_turns and prune remain unsupported and produce warnings.

Experimental

  • subagent_depth is shared.
  • Legacy provider lists generate ordered canonical policies.
  • Native policies follow generated policies.
  • An explicit empty enabled_providers keeps deny-all behavior.
  • A non-empty list with no valid items contributes no policy, avoiding accidental deny-all from malformed input.

Recovery Units

Named commands, agents, providers, MCP servers, formatters, language servers, and references recover independently. Plugin, permission, skill, instruction, provider-ID, and policy arrays recover by item. Top-level legacy permissions recover by action/resource rule. Complex interiors of one agent, provider, command, or MCP server remain atomic rather than being recursively salvaged.

Every decoder preserves propertyOrder: "original" because V1 permission precedence depends on user order. Excess properties remain ignored except for the explicit unsupported inventory.

Provider IDs

Provider ID compatibility remains a config migration concern only. Existing V1 agent, command, provider, and provider-policy adapters continue using the migration helper's retired-ID mapping.

The shared top-level model field remains exact because its string and object forms are valid native V2 syntax and provider declarations may come from a different config layer. It is never reinterpreted based on unrelated legacy fields.

This change does not add runtime provider aliases or modify provider policy evaluation, catalog state, model resolution, Sessions, plugins, Server behavior, or generation.

Diagnostics

Diagnostics contain only source, JSON path, category, and action. They never include raw values because config may contain credentials after substitution.

Malformed JSON, empty content, and valid non-object roots reject one document with a source-aware warning. Malformed recognized fields and entries are skipped at their recovery boundary while unrelated valid configuration continues loading.

Implementation

  • Add a pure ConfigNormalize.normalize module under packages/core/src/config/.
  • Reuse field migration primitives from packages/core/src/v1/config/migrate.ts.
  • Replace ConfigMigrateV1.isV1 in packages/core/src/config.ts with normalization and one final V2 decode.
  • Log diagnostics uniformly for files, OPENCODE_CONFIG_CONTENT, and well-known virtual config.
  • Add property and table-driven config normalization tests.
  • Update migration and compaction documentation.

Verification

The implementation must establish:

  1. Valid native V2 config preserves decoded meaning after encoded normalization.
  2. Supported V1 fields preserve existing behavior.
  3. Adding a legacy field cannot change unrelated native field interpretation.
  4. Native V2 wins canonical conflicts independent of key order.
  5. One malformed entry does not remove valid siblings.
  6. Mixed MCP, compaction, and experimental values normalize deterministically.
  7. Diagnostics are precise and value-redacted.
  8. False, zero, empty, and absent values retain distinct presence semantics.

Run from packages/core:

bun test test/config
bun typecheck

Run from packages/www after documentation changes:

bun typecheck
bun validate
bun run build

Non-Goals

  • Runtime provider alias resolution.
  • Provider policy or catalog changes.
  • Model resolver or Session changes.
  • Plugin API changes.
  • Server or Protocol changes.
  • Generation lifecycle changes.
  • Recursive V1/V2 inference inside one agent, provider, command, or model.
  • Restoring removed V1 functionality.
  • Rewriting user files on disk.