| Field | Value |
|---|---|
| Status | Draft — revised after design-review round 3 |
| Date | 2026-04-18 |
| Author(s) | Julian, Claude |
| Issue | — |
| Supersedes | — |
Design for first-class, opt-in inheritance between provider definitions in
pack.toml / city.toml, replacing today's silent name-match and
command-match auto-inheritance.
internal/config/resolve.go currently
has two implicit rules that merge a city-level provider over a built-in:
- Name match —
[providers.codex]at the city level auto-merges with the built-in namedcodex. - Command match — a custom provider whose
commandequals a built-in name (e.g.command = "claude") auto-merges with that built-in.
Both rules exist to give custom provider definitions sensible defaults for
fields like PromptMode, ReadyDelayMs, PermissionModes,
OptionsSchema, and the pool-worker safety flags. The rules work for
simple aliases but fail silently for any provider that wraps a binary
through an intermediary launcher. The canonical failure mode — and the
one that motivated this design — is aimux-wrapped providers:
[providers.codex-mini]
command = "aimux"
args = ["run", "codex", "--", "-m", "gpt-5.3-codex-spark",
"-c", "model_reasoning_effort=\"medium\""]Neither rule matches. codex-mini is not a built-in name; aimux is not
a built-in command. The provider loads without the built-in's defaults,
so:
- codex boots in its default
suggestpermission mode instead ofunrestricted→ every agent run prompts for approval on the first sandboxed command and hangs forever. ReadyDelayMsis unset → pool workers marked ready before TUI bootstraps; first prompt races the UI.ResumeFlag/ResumeStyle/SessionIDFlagunset → crash recovery fails.SupportsHooks,SupportsACP,PrintArgs,InstructionsFileempty → hooks don't install, headless mode is broken, the agent can't find its instructions file.
The code flags this as deferred
(resolve.go:273-278):
"wrapper aliases that use an intermediary launcher [...] Fixing this
requires a deeper design decision [...] and is deferred."
- Give users a way to opt a custom provider into inheriting from any other provider — built-in or custom — via a single explicit field.
- Allow chaining so users can build shared intermediate ancestors.
- Remove the silent auto-inheritance rules without reintroducing the same silent-failure mode at a different trigger (explicit deprecation window; hard error in phase B).
- Surface inheritance misconfigurations at config load, not session spawn.
- Make inherited ancestry a first-class resolved property used consistently across every runtime surface that branches on provider family.
- Inheriting anything about an agent (
[[agent]]entries). - Multiple inheritance / mixins.
- Outer-wrapper composition. A child cannot insert tokens before
its inherited
Command. Cases liketimeout 300s ...,env VAR=x ...,nice -n 10 ...around an inherited invocation require mechanics this design does not supply. Users who need that MUST setcommandandargsexplicitly in the child and may usebasesolely to inherit non-argv fields. (Round-2 reviewers correctly observed that a naiveargs_prependdesign would land tokens between the child's inheritedCommandand inheritedArgs, producing silently wrong command lines. The cleanest resolution is to forbid outer wrapping in v1. See "Deferred: outer-wrapper composition" at the bottom.)
Three new fields on [providers.X] blocks:
[providers.codex-max]
base = "builtin:codex"
args_append = ["-m", "gpt-5.4",
"-c", "model_reasoning_effort=\"xhigh\""]
supports_hooks = false # optional tri-state override| Field | Type | Required | Semantics |
|---|---|---|---|
base |
*string (presence-aware) |
no | Name of the parent provider. Stored as a pointer so parse/compose/patch can distinguish omitted from explicit empty. Absent = no declaration (inherits any pack-level base during compose; triggers Phase A warning if legacy auto-inheritance matches). "" (explicit empty) = standalone opt-out — no inheritance at all, silences Phase A warning, bypasses Phase A legacy-merge synthesis. "<name>" looks up custom first, then built-in (self-exclusion applies). "builtin:<name>" forces built-in lookup (recommended form). "provider:<name>" forces custom lookup. |
args_append |
[]string |
no | String list appended to the effective args of the resolved chain. Applied after that layer's args replacement. Inner-argv composition only — cannot wrap Command. |
capability-bool overrides (supports_hooks, supports_acp, emits_permission_warning) |
*bool |
no | Tri-state: absent = inherit; true = enable; false = explicitly disable. Serialized as optional TOML bool; internal representation is *bool. |
Plus one changed field:
| Field | Change |
|---|---|
options_schema |
Merge mode controlled by new options_schema_merge field (see below). Defaults to replace (unchanged from today) for backward compat. |
New opt-in:
| Field | Type | Required | Semantics |
|---|---|---|---|
options_schema_merge |
string |
no | "replace" (default, today's semantics) or "by_key". When "by_key", child entries with matching Key replace parent entries; new keys append; omit = true removes inherited entries. |
resume_command is an existing field (provider.go:73-77). This design does not change its syntax. Existing {{.SessionKey}} template variable is preserved — no new placeholder is introduced. What changes: wrapper descendants of subcommand-style resume providers become required to declare resume_command (previously optional; silently broken for wrappers).
Resolving base = "X" for a provider named P:
- Namespaced built-in (
base = "builtin:X"): look upXinBuiltinProviders()only. Miss → errorunknown builtin "X" for provider "P". - Namespaced custom (
base = "provider:X"): look upXin custom providers.X == P→ self-cycle error. Miss → errorunknown custom provider "X" for provider "P". - Bare name (
base = "X"):- Look up
Xin custom providers, excludingPitself. - If not found, look up
XinBuiltinProviders(). - Both miss → error
unknown base "X" for provider "P" (no custom provider or built-in with that name).
- Look up
- Presence-aware empty / absent:
- Absent (
basefield omitted in this layer): defer to parent layer during compose. If no layer setsbase, the provider has no declared parent → Phase A legacy-merge synthesis applies when name/command matches a built-in; warning is emitted. - Explicit empty (
base = ""): standalone opt-out. No inheritance. Phase A legacy-merge synthesis is bypassed. Phase A warning is silenced.base = ""set at ANY layer (pack fragment, city override, patch) sticks — subsequent absent layers do not re-enable legacy merge because the explicit empty is an explicit declaration, not "no declaration." Baseis internally*stringso compose/patch can distinguish the two.base = ""on a descendant layer overrides a pack-declared non-emptybasefrom an earlier layer (consistent with "explicit wins").
- Absent (
Self-exclusion scopes to the declaring hop only, not the whole walk.
Colons (:) are reserved in base values: a custom provider name
containing : is rejected at parse time. Built-in provider names
cannot contain :. The builtin: and provider: prefixes are
reserved.
Ambiguity warning on bare names. At config load, when a bare
base = "X" (no builtin: / provider: prefix) resolves via
self-exclusion fallthrough (i.e., a custom X existed but resolution
skipped to the built-in because X is the declarer itself), no warning
fires — that's the intended self-exclusion idiom. But when a bare
base = "X" on a non-shadowing provider could resolve either way —
because there exists both a custom X AND a built-in X — the loader
emits a collision warning:
config warning: provider "P" uses bare `base = "X"`, which currently
resolves to <custom|builtin> X because a <custom|builtin> provider
with that name is defined. If a <builtin|custom> X appears later
(via pack import, CRUD, etc.), this provider's ancestry will
silently retarget. Use `base = "builtin:X"` or `base = "provider:X"`
to pin the resolution.
Warning, not error — users who deliberately want "whichever X is in scope" behavior can ignore it. Most authors should pin the form.
base = "P" inside [providers.P] when no built-in named P exists
is a self-cycle error.
Resolution happens eagerly, post-compose, post-patch. The full chain
is walked once; the fully merged ResolvedProvider is cached on the
City struct alongside provenance metadata. Subsequent lookups return
a deep-copied ResolvedProvider — all slice and map fields are
cloned on return so caller mutation cannot corrupt the cache. Mutation-
isolation tests are required per reference field.
Walk base links leaf → root. At each hop, record:
- Identity kind:
builtinorcustom(determined by which lookup path found the hop —builtin:prefix / fallthrough-to-builtin →builtin;provider:prefix / bare-name-match-in-custom →custom). - Identity name: the canonical name (with prefix stripped).
Cycle detection uses this identity tuple (kind, name) as the
visited-set key — not the bare string base value. This prevents
false positives between a custom codex and built-in codex with the
same bare name.
Chain terminates when a provider has no base set.
ResolvedProvider.BuiltinAncestor is computed during the walk: the
first hop whose identity kind is builtin. Not name-matching — a
fully custom chain that happens to contain a hop named codex but
which resolved through provider: or bare-name-matched-a-custom does
not set BuiltinAncestor. If no hop in the chain is a built-in,
BuiltinAncestor = "".
Test (required): alias → custom-codex → provider:wrapper chain, where
the middle hop is literally named codex but is a custom provider.
Assertion: resolved.BuiltinAncestor == "".
Merge root first. Starting with an empty ProviderSpec, apply each
ancestor root→leaf through the same merge function.
- Compose (pack fragments + city overrides in
compose.go):Base,ArgsAppend, tri-state capability bools,ResumeCommand,OptionsSchemaMergeparticipate indeepMergeProvider. - Patch (
patch.go): all new fields added toProviderPatch,applyProviderPatch, deep-copy. - Resolve: walk chains, build merged specs + provenance, cache on
City. - Lookup:
lookupProvider(name)returns a deep-copiedResolvedProvider.
On reload, the full table is rebuilt atomically. Old cache retained until new one materializes (or reload fails). Reload rejection leaves old cache intact.
Quick-parse paths that pre-compose
(cmd_config.go:77-85) must NOT run
chain resolution and must NOT expose their output to runtime spawn
paths. A separate Go type (RawProviderSpec) is introduced for
pre-compose representations — runtime code paths only accept
*ResolvedProvider, enforced at the type level. A test enumerates
every caller of the quick-parse path and asserts none feed reconciler
spawn, crash recovery, readiness probes, or session creation.
During Phase A (warning window — legacy auto-inheritance still fires),
the resolved cache must produce the same merged spec it would have
produced under the legacy rules. Concretely: when materializing a
provider whose base is unset, if its name or command matches a
built-in, the cache layer synthesizes the equivalent base = "builtin:<name>" merge. The warning is emitted separately on the
config-load channel; the resolution result is unchanged.
Phase B removes the synthesis. Any previously-quiet provider now fails loudly.
| Field | Merge rule | Change? |
|---|---|---|
| Scalar strings | Non-zero child replaces parent. | Unchanged |
Scalar integers (ReadyDelayMs) |
Non-zero child replaces parent. | Unchanged |
| Tri-state capability booleans | *bool: nil = inherit; non-nil replaces. |
Changed (new *bool) |
Args |
Non-nil child replaces parent. [] = clear. Absent inherits. |
Nil-vs-empty pinned |
ArgsAppend |
Accumulated across chain: each layer's args_append extends the running list, applied after that layer's args replace. [] = append nothing (not a clear). |
New |
ProcessNames, PrintArgs |
Non-nil child replaces. [] clears. Absent inherits. |
Nil-vs-empty pinned |
Env, PermissionModes, OptionDefaults |
Additive map merge; child keys win on collision. | Unchanged |
OptionsSchema |
Merge mode per options_schema_merge: "replace" (default) = current slice-replace; "by_key" = merge by Key with omit = true removal. |
New opt-in |
ResumeCommand |
Non-zero child replaces. Inherited by default. | Unchanged (field semantic new) |
Schema-managed flags in args or args_append are normalized at the
provider layer that declares them before the layer is merged. For a single
layer, explicit args / args_append choices override that same layer's
option_defaults. Across inheritance, child option_defaults still beat
parent defaults inferred from parent args. Effective precedence is:
agent option_defaults >
child provider args / args_append >
child provider option_defaults >
parent provider args / args_append >
parent provider option_defaults >
schema defaults
Migration note: this intentionally changes redundant same-layer configs
where option_defaults and schema-managed args set different values for
the same key. The args value now wins inside that layer, so operators
should remove the stale duplicate or update option_defaults before relying
on the new precedence.
Same-layer order: args ++ args_append. Per-layer accumulation across
the chain:
- If layer declares
args: accumulated = layer.args (replace). - If layer declares
args_append: accumulated ++= layer.args_append.
No mutual-exclusion rejection. Both on the same layer resolve as
args ++ args_append in declared order.
Worked example:
builtin codex: args = nil, args_append = nil → []
[providers.codex]: args = ["run","codex","--"] → ["run","codex","--"]
args_append = nil
[providers.codex-max]: args = nil, args_append = ["-m","gpt-5.4"]
→ ["run","codex","--","-m","gpt-5.4"]
Default: options_schema_merge = "replace" — today's behavior. Setting
a child's options_schema replaces the parent's entirely. No migration
required for any existing config.
Opt-in: options_schema_merge = "by_key". Each
[[providers.X.options_schema]] entry is identified by its non-empty
Key. Rules:
- Child entry with matching
Keyreplaces parent entry entirely. - Child entry with new
Keyappends. - Child entry with
omit = trueand matchingKeyremoves parent entry.OptionDefaults[Key]is also pruned. - Child entry with
omit = trueand no matching parent entry: no-op (not an error — permits forward-compatible config where a parent might or might not declare the key). - Child entry with
omit = truealongside any other non-Keyfields: load error (omit is key-only). - Empty
Keyor duplicateKeywithin one layer: load error. options_schema = []underby_keymode: clear inherited schema AND prune inheritedOptionDefaultsentries for every cleared key. (Consistent with per-keyomit = true, which also prunes.)
Opt-in model avoids the round-2 "silent semantic drift" blocker — no
existing config's resolution changes unless the user explicitly sets
options_schema_merge = "by_key".
TOML form:
supports_hooks = false # explicit disable
supports_hooks = true # explicit enable (or inherit-if-parent-enabled)
# omitted # inherit from parentInternal representation: *bool (nil = inherit). The existing
non-pointer form in older configs must continue to work — true and
false decode into *bool identically. Regression test required:
pre-existing supports_hooks = false config continues to disable hooks
after the *bool migration.
Compose-order test required: fragment sets supports_hooks = false,
override omits the field → final *bool == &false.
Built-in codex uses ResumeStyle = "subcommand", which today inserts
resume <id> after the first token of the invocation. For a bare
codex invocation this works; for the aimux-wrapped form
(aimux run codex -- ...) it produces aimux resume <id> run codex -- ..., which is not a valid resume command.
Solution: use the existing ResumeCommand string field on
ProviderSpec (provider.go:73-77)
with its existing {{.SessionKey}} template variable. When set,
it overrides ResumeFlag/ResumeStyle/SessionIDFlag heuristics.
This design does not introduce new template syntax.
Required for wrapper descendants: a provider whose inherited
ResumeStyle == "subcommand" and whose command differs from its
inherited command (i.e., a wrapper) MUST declare resume_command.
If not, config load fails with:
config error: provider "codex-mini" wraps a subcommand-style resume
provider (codex) but does not declare `resume_command`. Wrapper
providers must specify their own resume invocation.
For the aimux-codex case:
[providers.codex-mini]
base = "builtin:codex"
command = "aimux"
args = ["run", "codex", "--",
"--dangerously-bypass-approvals-and-sandbox",
"-m", "gpt-5.3-codex-spark",
"-c", "model_reasoning_effort=\"medium\""]
resume_command = "aimux run codex -- --dangerously-bypass-approvals-and-sandbox -m gpt-5.3-codex-spark resume {{.SessionKey}}"
process_names = ["aimux", "codex"]If a wrapper's explicit resume_command omits a schema-managed default that
startup inferred from args, the resolver inserts the missing default into the
subcommand resume invocation before {{.SessionKey}}. An explicit
schema-managed flag already present in resume_command wins, so wrappers can
intentionally use a different resume-time value.
resume_command supports only the {{.SessionKey}} template variable. When
the resolver inserts missing schema-managed defaults, it tokenizes and re-emits
the command. For subcommand-style providers with repeated resume tokens, the
insertion point is the resume token that precedes {{.SessionKey}}.
End-to-end test required: spawn wrapped codex → kill → reconcile → assert actual executed resume command matches the declared template.
The wrapper-resume check is data-driven, not predicated on the
literal string "subcommand" — any ResumeStyle value that depends on
the leaf's Command being an invocation of the inherited binary
(today: "subcommand"; future styles may require the same) triggers
the requirement for descendants whose command differs.
When a provider's resolved Command differs from its inherited
Command (wrapper descendant) AND process_names was not
overridden, config load emits a warning (not error — in some cases
the inherited process names are still valid):
config warning: provider "codex-mini" wraps a different command
("aimux") than its inherited parent ("codex") but does not override
`process_names`. Supervision and PID tracking may fail. Set
`process_names = ["aimux", ...]` explicitly to include wrapper
processes.
Warning becomes a hard error in a future release if experience shows
it is always wrong; starts as warning because there are legitimate
cases where the wrapper exec-replaces itself with the wrapped binary.
An integration test asserts the maintainer-city aimux config overrides
process_names and the reconciler can supervise the wrapped process.
Every site that branches on provider name/kind MUST consume
ResolvedProvider.BuiltinAncestor, not the raw name. Phase 4 audits
and updates every listed call site; Phase 4 tests cover each.
resolveProviderKind(resolve.go:269-291)- Hook install/enable (
build_desired_state.go:1061-1063,hooks.go:32-90) - Claude
--settingsinjection (cmd_start.go:699) - Skill materialization (
skills.go:57) - Session submit/interrupt (
submit.go:192) - Named session creation (
session_template_start.go:292) - API session creation (
session_resolution.go:215) - Session message handlers
(
handler_session_interaction.go,huma_handlers_sessions_command.go) - Provider readiness init (
init_provider_readiness.go:338) - Template resolve (
template_resolve.go:251) - Skill integration (
skill_integration.go:172) - Reconciler session bead creation / backfill
(
session_beads.go:617-665,session_lifecycle_parallel.go:406-409) - Crash adoption and
GC_PROVIDERenv propagation (manager.go:373-376) - Idle-safe nudge path
(
cmd_nudge.go:641) install_agent_hooksmatching: match against the resolvedBuiltinAncestor, not the raw provider name (hooks.go)/v0/agentsdisplay name + availability (handler_agents.go:109,118,533,552)/v0/sessionslist derivation (handler_sessions.go:71)
Capability-disable precedence. Family-derived behavior from
BuiltinAncestor is gated by the resolved capability flags. Explicit
supports_hooks = false (or equivalent for ACP / permission warning)
suppresses family behavior at every site — hook install, --settings
injection, ACP wiring, permission-warning surfacing, etc. No
family-derived code path fires when the corresponding capability is
explicitly disabled. This rule is normative, not advisory.
Per-site regression tests: Claude --settings injection for
claude-max base="builtin:claude"; skill materialization for same;
session submit/interrupt; readiness probe; hook install; named-session
creation. Each test asserts the behavior matches what the built-in
would have gotten, not what a raw-name match would give.
Session beads stamp provider_kind = BuiltinAncestor at creation;
downstream consumers read it from the bead, not re-derive.
All provider-aware HTTP / API / CRUD paths must consume the same resolved cache:
/v0/providers?view=public(handler_providers.go:91-100)/v0/config/explain(handler_config.go:124)- Provider CRUD
(
huma_handlers_providers.go:131,configedit.go:647) /v0/config/explainper-provider form (new:--provider <name>query parameter)
CRUD validation relaxed: a provider with base set is authorable
without command / args — those may be inherited. CRUD round-trip
test for base-only descendants is required.
CRUD JSON authoring DTO — explicit contract for the new fields:
{
"name": "codex-max",
"base": "builtin:codex",
"args_append": ["-m", "gpt-5.4", "-c", "model_reasoning_effort=\"xhigh\""],
"supports_hooks": true,
"options_schema_merge": "by_key",
"options_schema": [
{"key": "permission_mode", "omit": true},
{"key": "detail", "label": "Detail", "type": "select", "choices": [...]}
],
"resume_command": "..."
}base(null/omitted |""|"<name>"|"builtin:<name>"|"provider:<name>"): null/omitted = no declaration;""= explicit standalone opt-out; other values are lookup forms.supports_hooks/supports_acp/emits_permission_warning:null/omitted = inherit;true/false= explicit.options_schema_merge:"replace"(default) or"by_key". Unknown values → HTTP 400.omit = trueIS authorable via CRUD (the round-2 decision to strip it from public DTOs is reversed — users must be able to author the removal sentinel). The public read DTO still rendersomitentries as resolved absences rather than raw structs; the CRUD round trip preserves the user's raw input.
PATCH semantics for presence-sensitive fields (base, capability
*bool overrides, options_schema_merge):
| PATCH body | Effect |
|---|---|
| Field omitted from body | no-op (keep current value) |
Field present, value null |
clear the explicit declaration, restore inherit-from-parent behavior (equivalent to removing the TOML key) |
Field present, value "" |
set to explicit empty (distinct from null; e.g., base = "" = standalone opt-out) |
| Field present, concrete value | set to that value |
null vs omitted distinction is load-bearing — this is why raw DTOs
use JSON null rather than dropping keys.
Response DTO key naming: /v0/config/explain --json maps
provenance keys to TOML/API names, not Go struct identifiers.
Command → command, ReadyDelayMs → ready_delay_ms,
OptionDefaults → option_defaults, etc. A test asserts no
Go-identifier leakage in any serialized response.
A custom provider meeting ANY of these without explicit base set
(including base = "" opt-out):
- Provider name equals a built-in name.
- Provider
commandequals a built-in name.
emits a load-time warning. Resolution behavior unchanged (cache synthesizes legacy merge per the Phase A cache rule above).
Warning text primarily recommends the unambiguous builtin: form:
config warning: provider "codex" in pack.toml is relying on legacy
name-match auto-inheritance (matches built-in "codex"). This becomes
a hard error in the next release.
Fix: add `base = "builtin:codex"` to the provider block.
If this provider should NOT inherit from the built-in, add
`base = ""` to explicitly opt out.
base = "<name>" (bare, resolving via self-exclusion) is a valid but
secondary recommendation — the builtin: form is preferred because it
reads unambiguously without knowing the self-exclusion rule.
base = "" is the documented opt-out path for standalone
providers that happen to collide with a built-in name. Silences the
warning; cache does not synthesize legacy merge; the provider stands
alone with only its declared fields.
Warnings surface on four channels so users see them during normal operation, not only diagnostic commands:
- Config load returns a structured warnings list alongside errors.
- Standard CLI paths that run
config.Load(everygcinvocation that reads config —gc session start,gc convoy,gc sling,gc config show, etc.) render the warnings once to stderr at startup. gc doctorrenders them for operator-initiated checks.gc config explain <provider>includes them in its output.
Rendering is de-duplicated per config-load (multiple CLI invocations
each show warnings once; a single gc session start does not repeat
the same warning for each provider in scope).
Legacy auto-inheritance deleted. Phase A warnings become hard errors with the same text. Cache synthesis of legacy merge is also removed (since there's nothing to preserve).
config error: provider "codex-max" has inheritance cycle:
codex-max → codex-mid → codex-max
config error: provider "codex-mini" has unknown base: "codex-foo"
(no custom provider or built-in with that name)
config error: provider "codex-mini" base "builtin:aimux": no built-in
with that name exists
config error: provider "codex-mini" wraps a subcommand-style resume
provider (codex) but does not declare `resume_command`. Wrapper
providers must specify their own resume invocation.
config error: provider "codex-max" options_schema entry 2 has empty Key
config error: provider "codex-max" options_schema entry 2 duplicates
Key "permission_mode" (also at entry 0)
config error: provider "codex-max" options_schema entry 2 declares both
`omit = true` and other fields; omit entries must be key-only
config error: provider "codex-max" has `omit = true` on entry 0 but
options_schema_merge is "replace" (or unset, defaults to "replace");
omit sentinel requires options_schema_merge = "by_key"
config error: provider "codex-max" options_schema_merge = "layer"; valid
values are "replace" or "by_key"
config error: custom provider name "codex:foo" contains reserved
character ":" — reserved for namespace prefixes
gc config show renders, as a comment above each [providers.X]
block:
# inherited chain: codex-max → codex → builtin:codex (via self-exclusion)
[providers.codex-max]
...
# no inheritance (stands alone)
[providers.my-standalone]
...
# inherited chain: my-alias → my-base (no built-in ancestor)
[providers.my-alias]
...
The annotation is produced by a dedicated annotated renderer
(cfg.MarshalShow()) — cfg.Marshal() (plain TOML encoding) cannot
produce comments.
gc config explain (and /v0/config/explain):
- Default view: per-agent resolved view including provider chain.
--provider <name>: focused view on one provider's resolved spec and full provenance.--json: structured output. Provenance includes:chain: ordered hop list with identity kind + name.fields: per-field source layer.option_defaults/permission_modes/env: per-map-key source layer (MapKeyLayer).options_schema: per-entry{key, action, layer}whereaction∈{inherited, replaced, appended, omitted, cleared}.args_effective+args_segments: half-open[start, end)ranges tagged with{layer, origin}whereorigin∈{args, args_append}.
- Phase A warnings surface on
gc config explain, not justgc doctor.
type ResolvedProvider struct {
ProviderSpec
BuiltinAncestor string
Provenance ProviderProvenance
}
type ProviderProvenance struct {
Chain []HopIdentity // ordered, root → leaf
FieldLayer map[string]FieldProv // "command" → {"layer": "providers.codex", "action": "set"}
MapKeyLayer map[string]map[string]string
// "option_defaults" → {"permission_mode": "builtin:codex", ...}
SchemaEntryLayer []SchemaProvenance
ArgsSegments []ArgsSegment
Warnings []string // Phase A warnings
ResumeSuperseded bool // true when resume_command
// set and inherited
// ResumeFlag/ResumeStyle/
// SessionIDFlag are
// shadowed
}
type FieldProv struct {
Layer string // logical layer name: "providers.codex-max", "builtin:codex", etc.
Source string // originating file + key: "pack.toml[providers.codex-max]",
// "city.toml[providers.codex-max]",
// "patches[2].target=providers.codex-max"
Action string // "set" | "inherited" | "cleared" (for [] clear of slice fields)
// | "legacy_synthesized" (Phase A auto-inheritance synthesis)
}
type HopIdentity struct {
Kind string // "builtin" | "custom"
Name string // canonical name
}
type SchemaProvenance struct {
Key string
Action string // "inherited" | "replaced" | "appended" | "omitted" | "cleared"
Layer string
}
type ArgsSegment struct {
Layer string // e.g. "providers.codex"
Origin string // "args" | "args_append"
Start int // half-open [Start, End)
End int
}MapKeyLayer covers Env, PermissionModes, OptionDefaults.
SchemaProvenance.Action = "cleared" applies when a layer set
options_schema = [] under by_key mode.
Dropped. Round-2 review flagged this as underspecified scope creep.
This design does not introduce a new schema discriminator. The existing
[pack].schema contract
(config.go:551,
pack.go:22) is unchanged; if a
future breaking change needs a discriminator, that's a separate design.
Add ResumeFlag, ResumeStyle, SessionIDFlag to the built-in codex
spec (provider.go:286) so
that base = "builtin:codex" restores the resume capability for
non-wrapper descendants. Wrapper descendants still must declare
resume_command per the Resume section.
Inserting tokens before the inherited Command is deliberately
out of scope for v1. Users who need outer wrapping (timeout 300s aimux run codex ..., env FOO=bar ..., nice -n 10 ...) must
declare their own command and args. They can still use base to
inherit non-argv fields (permission modes, ready delay, hooks,
settings).
This restriction exists because the runtime's sh -c line concatenates
Command + Args. A naive "args_prepend" would insert tokens between
the child's inherited Command and inherited Args, producing
silently-wrong invocations. The correct model is to treat the wrapper
itself as a new provider identity (its own command + args + base
for field inheritance) — which is what users already write for such
cases and what this design leaves unchanged.
Future extension (not in this design): a command_wrap field or
placeholder-based args (args = ["timeout", "300s", "@inherit"]) that
substitutes the resolved parent argv. Requires design work on
ergonomics and runtime-layer changes beyond the config package.
Phases 1–7 ship in the same release. Phase 8 (hard cutover) ships in the next release. Phase 9 docs update ships alongside Phase 1–7.
- Add to
ProviderSpecinprovider.go:Base *string(presence-aware),ArgsAppend []string,OptionsSchemaMerge string, capability*booloverrides, TOML tags.ResumeCommandalready exists — not added in this phase, just gains a new normative use via the wrapper-resume validator. - Simultaneously add all new fields to
ProviderPatch(patch.go:160),applyProviderPatch, deep-copy paths. Patch-side presence-awareness for[]preservation. TestProviderFieldSyncanalogous toTestAgentFieldSync.- Add
ResumeFlag,ResumeStyle,SessionIDFlagto built-in codex. - Introduce
RawProviderSpectype for pre-compose quick-parse paths; refactor quick-parse callers. - Parser rejects
:in custom provider names and rejectsbuiltin:/provider:reserved-prefix misuse. - Unit tests: parse each new field; nil-vs-empty contract per slice
field; tri-state bool round-trip with old-form back-compat
(
supports_hooks = falseon old schema still disables hooks);RawProviderSpec/ResolvedProvidertype isolation.
- Add
resolveProviderChain(name, allProviders) (ResolvedProvider, error)toresolve.go. - Implement namespaced prefixes (
builtin:,provider:) + self-exclusion bare-name lookup. - Cycle detection with walk-scoped visited set keyed on
(kind, name). - Populate
BuiltinAncestorfrom hop identity. - Emit all error messages in Errors.
- Wrapper-resume check: detect subcommand-style inherited
ResumeStylewith differingcommandand demandresume_command. - Unit tests (per Test inventory below).
- Legacy auto-inheritance blocks at
resolve.go:131-138stay; now emit warnings throughconfig.Warningsreturn channel. - Cache materialization synthesizes
base = "builtin:<name>"merge for each same-name / command-match provider lackingbase. gc doctorruns the same check.
- Rename
MergeProviderOverBuiltin→mergeChainLayer; extend forArgsAppend, tri-state capabilities,options_schemaby-key +omit. - Audit every site branching on provider name/kind; route through
BuiltinAncestor. Sites listed in Kind/provider-family section. - Per-site regression tests.
- Post-compose + post-patch resolution; cache on
City. lookupProviderreturns deep-copiedResolvedProvider. Mutation- isolation tests per reference field.- Atomic reload; failed reload preserves old cache.
- Quick-parse path test: enumerate callers, assert none feed runtime.
- Route
/v0/providers,/v0/config/explain, provider CRUD handlers through the cache. - Relax CRUD validation for
base-only descendants. /v0/config/explain--provider <name>form.omitIS accepted on authoring DTOs (PUT/POST/PATCH); tagomit,omitempty. Public resolved read DTOs suppress omitted entries by dropping them from the flattened slice during resolution — they never appear as raw structs withomit = truein resolved output.
gc config showannotated renderer (cfg.MarshalShow()) with comment line per provider. Cover no-base / custom-rooted / deep chains as explicit test cases.gc config explainprovenance annotation, per-map-key resolution,--jsonoutput with fullProviderProvenance.- Phase A warnings surface on explain path.
- Golden-file tests for both text and JSON outputs.
- Delete legacy auto-inheritance blocks.
- Delete cache legacy-merge synthesis.
- Promote warnings to errors.
- Removal-assertion tests: explicit coverage that every surface
rejects an unmigrated provider. For each of: config load,
lookupProvider,/v0/providers,/v0/config/explain, provider CRUD create+read,gc doctor, assert that a provider matching legacy name-match or command-match withoutbase(or withbase = ""explicitly opting out) is handled with the correct Phase B behavior (hard error vs clean standalone).
- User-facing doc under
engdocs/for the TOML schema. - Pair
args_appendwrapper guidance withprocess_namesoverride guidance (a wrapper provider needs to overrideprocess_namesfor supervision / PID tracking). - Changelog entry covering this release's detector window + next release's cutover.
options_schemamerge mode is documented as opt-in; no migration needed unless users explicitly enable.
Organized by phase; every check asserts specific field values, not category coverage.
- Built-in only lookup: behavior unchanged.
- Shadowing custom with
base = "<same-name>"(self-exclusion → built-in). - Shadowing custom with
base = "builtin:<same-name>"— same result. - Shadowing custom with
base = "provider:<same-name>"→ self-cycle error. - Self-cycle without shadow (
base = "foo"in[providers.foo], no built-infoo) → cycle error. - Transitive 3-node cycle
A → B → C → Awith error message naming the full chain. - Unknown base; transitive unknown base (A → B → missing) with error naming both.
- Shared-ancestor DAG (
A → C,B → C) — both walks independent.
- Direct chain to built-in →
BuiltinAncestor = <builtin>. - Two-layer chain with built-in root →
BuiltinAncestor = <builtin>. - Fully-custom chain whose hop happens to be named
codexbut resolves throughprovider:→BuiltinAncestor = "". - Bare-name-matched-custom with same name as a built-in, chain
continues to built-in →
BuiltinAncestoris the built-in (because the chain reaches it). - Chain passing through
custom codex (base="builtin:codex")→ leafBuiltinAncestor = "codex".
- Scalars: 3-layer override (root sets, mid inherits, leaf overrides).
argsreplace +args_appendaccumulate across 3 layers.- Same-layer
args+args_append:args ++ args_append. args = []on leaf clears inherited.args_append = []on leaf: appends nothing (distinct fromargs = []).- Tri-state
*bool:- absent → inherits parent
true. false→ explicit disable (parenttrueoverridden).true→ explicit enable.- Pre-existing old-schema
supports_hooks = falseconfig decodes correctly into*bool. - Compose-order: fragment
false, override absent → finalfalse.
- absent → inherits parent
options_schemareplace mode: child slice replaces.options_schemaby-key mode: child replace by key / append new / omit existing.options_schemaby-key + omit-nonexistent: no-op.options_schemaby-key +omitwith siblings: load error.options_schemaby-key + empty/duplicate Key: load error.options_schema = [](by-key mode): clears inherited; schema entry provenance markedcleared.OptionDefaults[omitted_key]is pruned afteromit.
- Non-wrapper
base = "builtin:codex": resume uses inherited subcommand style. - Wrapper (
command = "aimux", inherits subcommand-style resume) withoutresume_command→ load error. - Wrapper with
resume_command→ end-to-end resume succeeds (integration test: spawn → kill → reconcile → actual executed command matches template).
- Deep-copy: mutate returned
ResolvedProvider.Args→ subsequentlookupProviderunaffected (same for each reference field). - Atomic reload: second load with broken chain keeps first cache.
- Level 0 ("no agents, no providers") loads unchanged.
- Quick-parse path: enumerate callers; assert none feed reconciler spawn, crash recovery, readiness, or session creation.
- Name-match without
base: warning fires; resolution unchanged. - Command-match without
base: warning fires; resolution unchanged. base = ""on same provider: warning silenced; resolution bypasses legacy merge.base = "builtin:<name>": warning silenced; explicit merge.base = "<name>"(bare self-exclusion): warning silenced; equivalent to builtin form.- Cache during Phase A: for a name-matching provider without
base, resolved spec equals the Phase Bbase = "builtin:<name>"spec.
/v0/providersreturns the sameResolvedProviderthe runtime uses./v0/config/explain --provider <name>returns full provenance./v0/config/explain --jsonround-trips provenance.- CRUD accepts
base-only provider, round-trip reads it back. - Public DTOs do not expose
omitsentinel.
gc config showannotation for: no-base, custom-rooted, 4+ layer chain. Round-trip as valid TOML (comments stripped on re-parse OK).gc config explaingolden-file text and JSON outputs.- Phase A warning shows up in explain output.
- Aimux-wrapped codex regression (golden fixture): checked-in
testdata/pack_aimux_codex.tomlmatching the maintainer-city config → resolvedcodex-minihasPermissionModes["unrestricted"],ReadyDelayMs = 3000,ResumeCommandwith{{.SessionKey}},SupportsHooks = true,BuiltinAncestor = "codex",ProcessNames = ["aimux", "codex"](if overridden) or triggers wrapperprocess_nameswarning (if not). Snapshot the full resolvedResolvedProviderstruct to a golden file; changes must be intentional and visible in diff. Spawn an agent; assert it does not hang on first sandboxed command, hooks install,--settingsinjection works for wrapper-derived Claude, resume round-trip substitutes{{.SessionKey}}correctly. - Aimux-wrapped claude regression (golden fixture): similar
snapshot for
claude-max base="builtin:claude"— covers claude wrapper-resume (--resumeflag style, not subcommand) and claude settings injection.
TestProviderFieldSync— new fields present inProviderSpecalso present inProviderPatch,applyProviderPatch, and deep-copy paths.
- Custom provider name containing
:→ parse error. base = "builtin:"(empty suffix) → error.base = "provider:"(empty suffix) → error.
None blocking implementation. Surfaces to revisit if demand emerges:
- Multi-inheritance / mixins.
_appendvariants forProcessNames,PrintArgs.command_wrap/ placeholder-based args for outer-wrapper composition.- Schema discriminator for future provider-schema migrations.
_appendfor other keyed collections beyondoptions_schema.