RFD 081: Decompose tool enable into state and allow_toggle
- Status: Implemented
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-05-11
- Extends: RFD 008
Summary
Replace the flat Enable enum on tool configuration with a struct that separates the tool's current state (bool) from allow_toggle (an enum describing which CLI directives may flip that state). This eliminates two latent bugs in the existing model, removes the need for a Sticky variant originally proposed in RFD 083 (which now adopts this RFD's shape directly), and absorbs RFD 055's ExplicitOrGroup variant without schema growth.
Motivation
The current Enable enum carries two orthogonal concerns in a single mutable field: the tool's enabled state, and the policy that controls how that state may change. Two bugs follow from this conflation, and a third variant proliferation problem follows from the same root cause.
Bug 1: Enable::Always is filtered out of the LLM-visible tool list
ToolConfigWithDefaults::enable() returns self.tool.enable.or(self.defaults.enable).is_none_or(Enable::is_on). Enable::is_on matches only Enable::On, so Enable::Always resolves to false. tool_definitions() filters on this predicate, which means the only builtin currently registered with Enable::Always — describe_tools — has never been sent to the LLM in practice. Its executor is registered but unreachable. The same Enable::is_on assumption also drives Ctx::configure_active_mcp_servers and the --tool-use NAME validation in apply_tool_use — the latter applied directly on the raw partial config, which compounds the problem. Any future MCP tool or user config using Enable::Always would silently break in the same way.
Bug 2: bare directives erase tool classifications
RFD 008 made CLI directives state-mutating: apply_enable_tools rewrites the enable field to On / Off in place. The filter in EnableAll skips Explicit; the filter in DisableAll skips Always. The asymmetry means -t -T on an Always tool runs EnableAll first, which rewrites Always to On (Always is not Explicit), after which DisableAll rewrites On to Off (the value is no longer Always). Net result: a bare -t -T disables describe_tools, contradicting its documented "cannot be disabled" contract.
The equivalent erasure for Explicit (-T -t flips it to On) is the behavior the existing test_interleaved_disable_all_then_enable_all documents as intentional. Both arise from the same conflation of state and policy in a single mutable field.
Variant proliferation for future features
RFD 083 originally proposed a Sticky variant: "the disable-side mirror of Explicit" — on by default, requires a named directive to disable. Under the flat-enum shape this would require a new variant, new is_sticky() predicate, new filter arms in the directive engine, and another conditional branch in every match Enable { ... } site.
RFD 055 proposes ExplicitOrGroup: "off by default; enabled by named tool or named group." Same pattern — another variant, more predicates, more match arms.
A new variant per (default state × directive sensitivity) combination scales poorly. The orthogonal axes — "what state does the tool start in?" and "which directives may flip that state?" — are not naturally expressible as a single flat enum.
Design
Replace Enable (currently a flat enum) with a pair of types that splits the two concerns into two roles Enable plays today — stored configuration vs. value consumers reason about:
/// Stored form. Lives in `ToolConfig.enable` and
/// `ToolsDefaultsConfig.enable` as `Option<EnableConfig>`. Per-field
/// `Option`s preserve which subfield(s) the user actually wrote, which is
/// what lets the cross-key defaults-into-tool merge (see
/// [Defaults and merge](#defaults-and-merge)) compose per-field rather
/// than overwriting whole.
pub struct EnableConfig {
pub state: Option<bool>,
pub allow_toggle: Option<AllowToggle>,
}
/// Resolved form. What consumers see after the effective-enable resolver
/// fills `EnableConfig` from per-tool config, then defaults, then the
/// hardcoded fallback (see
/// [Effective enable resolution](#effective-enable-resolution)). Not
/// stored directly — produced on demand.
pub struct Enable {
pub state: bool,
pub allow_toggle: AllowToggle,
}
pub enum AllowToggle {
/// Any directive may flip `state`. Serialized as `"any"`.
#[default]
Always,
/// No directive may flip `state`. Serialized as `"never"`.
Never,
/// Only named-tool directives may flip `state`. Serialized as `"if_named"`.
IfNamed,
/// Named-tool or named-group directives may flip `state`. Serialized as
/// `"if_named_or_group"`.
IfNamedOrGroup,
}Directives only ever mutate state. allow_toggle is the user's persistent assertion about which directives may do so, and is never rewritten by the directive engine.
For most (state, allow_toggle) combinations allow_toggle governs config-time directive behavior only — once state is resolved, the runtime does not consult allow_toggle again. The one exception is the locked-off case (state = false, allow_toggle = Never), which is enforced at runtime so the terminology stays honest; see Locked-off means hidden.
TOML surface
The common case keeps a bool shorthand. Tools that need a non-default toggle policy use the explicit struct form:
# Bool shorthand — common case (allow_toggle defaults to Always).
[conversation.tools.fs_read_file]
enable = true
# Today's Enable::Always (describe_tools): on, can never be toggled off.
[conversation.tools.describe_tools]
enable = { state = true, allow_toggle = "never" }
# Today's Enable::Explicit: off, only enabled when named.
[conversation.tools.dangerous_tool]
enable = { state = false, allow_toggle = "if_named" }
# RFD 083's Sticky (ask_user): on, only disabled when named.
[conversation.tools.ask_user]
enable = { state = true, allow_toggle = "if_named" }
# RFD 055's ExplicitOrGroup: off, enabled by name or group.
[conversation.tools.write_tool]
enable = { state = false, allow_toggle = "if_named_or_group" }
# New capability: locked off (no directive can toggle this on at config time).
[conversation.tools.network_tool]
enable = { state = false, allow_toggle = "never" }"if_named_or_group" is accepted today and behaves identically to "if_named" until RFD 055 lands the -t GROUP / -T GROUP parser. The schema accepts the value now to avoid a later additive change.
Serde
EnableConfig is a plain #[derive(Config)] struct with #[config(no_deserialize_derive)], exactly like ModelIdConfig: only Deserialize is hand-written (on the generated PartialEnableConfig, the form config files / base_config.json / config_delta events load into); the Serialize, Schematic, per-field merge, and delta machinery are the standard macro-generated implementations. This keeps enable uniform with every other nested config field and requires no schematic macro changes. Enable is the resolved form, produced by the resolver, not deserialized directly. The deserializer accepts a bool, a string, or a map. Within the map form, the allow_toggle field accepts the strings "any" (= Always), "never" (= Never), "if_named", or "if_named_or_group". Omitted map fields stay None so they participate in per-field merging — see Defaults and merge.
The table below describes the form a TOML input produces when deserialized into PartialEnableConfig and then passed through the resolver with no defaults layer (so any None field falls through to the hardcoded fallback):
| Input | Resolver output, no defaults layer |
|---|---|
true | { state: true, allow_toggle: Always } |
false | { state: false, allow_toggle: Always } |
"on" | { state: true, allow_toggle: Always } |
"off" | { state: false, allow_toggle: Always } |
"always" | { state: true, allow_toggle: Never } |
"explicit" | { state: false, allow_toggle: IfNamed } |
{ state, allow_toggle } | as written |
{ state } (allow_toggle omitted) | allow_toggle fills to Always |
In PartialEnableConfig, the same { state } map leaves allow_toggle as None, preserving any value inherited from a defaults layer.
The string forms ("on", "off", "always", "explicit") are the legacy flat-enum variants. They are preserved for backward compatibility — see Backward compatibility.
Serialize is the macro-generated implementation: output is always the table form, never a bool shorthand — mirroring how ModelIdConfig accepts a provider/name string on input but writes a { provider, name } table. Each partial field carries skip_serializing_if = "Option::is_none", so a partial that sets just one half writes just that half, preserving inheritance from lower layers. This matters because jp config set and config_delta events write partial deltas that may set only one field — e.g. jp config set conversation.tools.foo.enable.allow_toggle if_named produces { allow_toggle = "if_named" }, leaving state to inherit. Serialization operates on the stored optional fields, never on effective_enable().
| Partial input | Serialized output |
|---|---|
{ state: Some(true), allow_toggle: Some(Always) } | { state = true, allow_toggle = "any" } |
{ state: Some(false), allow_toggle: Some(Always) } | { state = false, allow_toggle = "any" } |
{ state: Some(_), allow_toggle: Some(_) } | { state, allow_toggle } |
{ state: Some(_), allow_toggle: None } | { state } |
{ state: None, allow_toggle: Some(_) } | { allow_toggle } |
{ state: None, allow_toggle: None } | {} (empty; omitted by the parent map) |
Round-trip is exact for the table form. The bool / legacy-string shorthands are one-way-normalized to the table form on the first write-back — enable = true becomes enable = { state = true, allow_toggle = "any" }, the same shorthand normalization model.id = "anthropic/claude" already undergoes.
On the deserialize side, bool and legacy-string inputs to a PartialEnableConfig fill both fields (so enable = true in a delta overrides both halves of any underlying value). The map form preserves omission: { state = true } deserializes to PartialEnableConfig { state: Some(true), allow_toggle: None }, which is what lets per-field merge (see Defaults and merge) inherit allow_toggle from a lower layer.
For partial overrides at the layered-config level (set state and inherit allow_toggle from a defaults layer), use the explicit map form with only the field you want to set: enable = { state = true }. The bool and string shorthand forms fully specify both fields. See Defaults and merge.
If no layer sets enable at all, the implicit default is { state: true, allow_toggle: Always } — enabled, freely toggleable. This matches today's "absence means On" behavior.
Defaults and merge
ToolsDefaultsConfig.enable is the same Enable field as a per-tool entry — same TOML shape (bool / string / map), same compat deserializer, same serialization rules. There is no separate defaults schema; the same value type appears at every layer.
[conversation.tools.'*']
enable = { state = false, allow_toggle = "if_named" } # Defaults are Explicit.
[conversation.tools.foo]
enable = { state = true } # foo overrides only state, inherits allow_toggle.
# Effective: state=true, allow_toggle=if_named.
# (Same shape as today's Sticky.)Two distinct merges produce this result, at different layers of the config pipeline:
Cross-layer merge (between config files setting the same path — e.g. user-level config layered onto project-level config, both writing
conversation.tools.foo.enable). Happens in the partial layer.PartialEnableConfigexposesstate: Option<bool>andallow_toggle: Option<AllowToggle>, so a partial that mentions onlystatedoes not eraseallow_toggleset in a lower layer. This is the standardPartialConfig::mergepath that composes layered config inload_partial(crates/jp_config/src/fs.rs); see RFD 079 for the source/precedence model.PartialEnableConfigmust therefore be a nested partial (derived through schematic somergerecurses into its fields), not a leafOption<EnableConfig>— otherwise the higher-priority layer would replace the entire value and eraseallow_togglefrom below.FillDefaultsis unrelated to this path; it only seeds schema defaults at finalization.Cross-key merge (from
conversation.tools.*.enabledefaults into per-tool entries likeconversation.tools.foo.enable). Happens at runtime, against the final config —PartialToolsConfig::fill_fromdoes not field-merge defaults into individual tool entries, and there is no plan to add that. This is why finalToolConfig.enableandToolsDefaultsConfig.enablestoreOption<EnableConfig>(the optional-field form) rather than the filledEnable: ifenablewere filled at finalization time, "the user wrote onlystate" would be indistinguishable from "the user wrote both fields withallow_toggle = Always", and the cross-key merge would silently shadow the defaults'allow_toggle. The runtime resolver inToolConfigWithDefaults(see Effective enable resolution) reads the storedEnableConfigfield by field and falls through to defaults exactly when a field isNone.
Users never write state / allow_toggle as top-level fields — the field-optional split is an internal mechanism for cross-layer overrides and cross-key defaults inheritance.
Effective enable resolution
apply_tool_use, apply_enable_tools, tool_definitions, and Ctx::configure_active_mcp_servers all consume the same effective enable value, resolved per-field from per-tool config, then defaults, then the hardcoded fallback:
effective.state = tool.enable.state ?? defaults.enable.state ?? true
effective.allow_toggle = tool.enable.allow_toggle ?? defaults.enable.allow_toggle ?? AlwaysTwo seams expose this fallback, one per consumer type. Both produce the same filled Enable for the same (tool, defaults) pair.
Final-config seam — for consumers operating on a built
AppConfig(tool_definitions(),Ctx::configure_active_mcp_servers):rustimpl ToolConfigWithDefaults { pub fn effective_enable(&self) -> Enable; pub fn is_enabled(&self) -> bool; }Both run the per-field fallback above against the stored
EnableConfigs inself.toolandself.defaults.is_enabled()is the convenience wrapper foreffective_enable().stateand replaces today'senable()at every call site.Partial-config seam — for CLI directive consumers operating on
PartialAppConfigbeforefrom_partial_with_defaultsruns (apply_enable_tools,apply_tool_use):rustimpl PartialEnableConfig { pub fn effective(&self, defaults: &PartialEnableConfig) -> Enable; }The CLI path reads the per-tool partial at
partial.conversation.tools.tools.<name>.enableand the defaults partial atpartial.conversation.tools.defaults.enable, then callseffectiveto obtain the same filledEnablethe final-config seam would produce. Resolving at the partial layer avoids building a temporary final config purely for the directive check.
Today this is inconsistent: tool_definitions() goes through ToolConfigWithDefaults::enable() and sees defaults-merged values, but apply_tool_use filters partial config directly with cfg.enable.is_some_and(Enable::is_on), which fails for tools that rely on the default-on fallback. Under this RFD every consumer routes through the same resolver, so --tool-use NAME works for builtins (e.g. describe_tools) and for user tools that leave enable unset.
If RFD 056 / RFD 057 land, the same per-field resolution extends across their group-default and group-override layers in the order those RFDs define — this RFD does not constrain that ordering. RFD 057 also separately commits to "CLI flags always win over group overrides." A group override that sets allow_toggle = Never would, under this RFD's directive engine, block a named CLI directive — which conflicts with that commitment. This RFD takes no position on the resolution; RFD 057 must decide whether group-sourced allow_toggle blocks CLI directives or whether CLI directives bypass it.
Directive engine
Directives are classified by scope:
pub enum ToggleScope {
Bulk, // -t / -T with no argument
Named, // -t NAME / -T NAME
NamedGroup, // -t GROUP / -T GROUP — reserved for RFD 055
}The directive engine asks two questions per (directive, tool) pair:
- Does
allow_togglepermit this directive scope? (Enable::accepts(scope)) - Would applying the directive flip
state?
accepts(scope) | state already matches intent | state would flip |
|---|---|---|
true | trivially OK — no work | apply: flip state |
false | trivially OK — no work | error (named) or skip (bulk/group) |
The accepts predicate:
impl Enable {
pub fn accepts(&self, scope: ToggleScope) -> bool {
match (self.allow_toggle, scope) {
(AllowToggle::Always, _) => true,
(AllowToggle::Never, _) => false,
(AllowToggle::IfNamed, ToggleScope::Named) => true,
(AllowToggle::IfNamedOrGroup,
ToggleScope::Named | ToggleScope::NamedGroup) => true,
_ => false,
}
}
}Under this rule, -t -T and -T -t preserve the policy of any tool with allow_toggle ≠ Always. The two bugs in Motivation become unrepresentable.
--tool-use NAME validation
apply_tool_use validates that the named target is configured and effectively enabled (state == true). The filter switches from "only Enable::On" to "state == true," regardless of allow_toggle. jp -u describe_tools becomes valid because describe_tools.state is true.
This is a config-eligibility check, not a delivery guarantee: it runs on the partial config before MCP servers start, so it cannot promise the tool reaches the LLM. A tool backed by an optional MCP server that fails to start is still dropped later by tool_definitions(). Reconciling that runtime mismatch — a forced tool absent from the resolved list — is a pre-existing concern orthogonal to this RFD and is left to the runtime access-control track.
Locked-off means hidden
A tool with state = false, allow_toggle = Never (the canonical locked-off case) is treated as truly off, not just immune to CLI directives. Three rules implement this:
tool_definitions()always drops locked-off tools, regardless of theforced_toolexemption that today protects anassistant.tool_choicematch. The current short-circuit incrates/jp_llm/src/tool.rsincludes a forced tool even when its enable check returnsfalse; under this RFD that exemption no longer applies when the tool is locked-off.assistant.tool_choice = "foo"is rejected during finalAppConfigvalidation whenfooresolves to a locked-off tool. The check lives at theAppConfiglevel (the only validator that sees bothassistant.tool_choiceandconversation.tools), not inToolsConfig::validate. The error names both config paths —assistant.tool_choiceandconversation.tools.<name>.enable— rather than silently coercing, or passing through to a provider that will reject the request anyway (Google/Gemini does). This mirrors the existing--tool-use NAMEvalidation against the enabled set.Ctx::configure_active_mcp_serversalready drops locked-off MCP tools, since it filters onis_enabled()before consulting any forced name. No additional change is needed there beyond theis_enabled()rewrite covered in Phase 2.
The asymmetry is intentional: only (state = false, allow_toggle = Never) gets the stronger treatment. (state = true, allow_toggle = Never) is locked-on — the tool is always present, no semantic conflict. The other locked combinations don't exist: Never only pairs meaningfully with these two state values.
This pulls one specific case out of the runtime access-control track (RFD 075 / RFD 076 / RFD 077). The justification is honesty: if the terminology says "locked off" and the schema lets users declare it, the runtime must back it up. Broader runtime enforcement — argument-level policy, tool-call sandboxing, plugin trust — remains out of scope and stays with the access-policy track.
Behavior matrix
Behavior under bulk and named directives (group parsing lands in RFD 055). Until then, IfNamedOrGroup behaves identically to IfNamed for both -t and -T:
| Tool config | -t name | -T name | -t bulk | -T bulk |
|---|---|---|---|---|
state=true, Always | no-op | flips off | no-op | flips off |
state=true, Never | no-op | error | skip | skip |
state=true, IfNamed | no-op | flips off | skip | skip |
state=false, Always | flips on | no-op | flips on | no-op |
state=false, Never | error | no-op | skip | skip |
state=false, IfNamed | flips on | no-op | skip | skip |
IfNamedOrGroup rows are intentionally omitted: until RFD 055 introduces -t GROUP parsing, they collapse to the IfNamed rows above.
Errors carry an allow_toggle-aware message ("cannot disable describe_tools: this tool is configured as locked-on") rather than the legacy "system tool cannot be disabled" framing.
Predicates and helpers
impl PartialEnableConfig {
// Partial-form constants used by builtin registrations and the directive
// engine, which work on partials.
pub const ON: Self = Self {
state: Some(true),
allow_toggle: Some(AllowToggle::Always),
};
pub const OFF: Self = Self {
state: Some(false),
allow_toggle: Some(AllowToggle::Always),
};
pub const LOCKED_ON: Self = Self {
state: Some(true),
allow_toggle: Some(AllowToggle::Never),
};
pub const LOCKED_OFF: Self = Self {
state: Some(false),
allow_toggle: Some(AllowToggle::Never),
};
}
impl Enable {
// Predicates on the resolved form, used by directive and consumer code.
pub const fn is_enabled(&self) -> bool { self.state }
pub const fn is_locked(&self) -> bool {
matches!(self.allow_toggle, AllowToggle::Never)
}
pub fn accepts(&self, scope: ToggleScope) -> bool { /* as above */ }
}describe_tools's builtin registration becomes enable: Some(PartialEnableConfig::LOCKED_ON).
Backward compatibility
The deserializer accepts the legacy string forms — "on", "off", "always", "explicit" — and rewrites each to its canonical struct form at parse time:
| Legacy input | Canonical form |
|---|---|
enable = "on" | { state: true, allow_toggle: Always } |
enable = "off" | { state: false, allow_toggle: Always } |
enable = "always" | { state: true, allow_toggle: Never } |
enable = "explicit" | { state: false, allow_toggle: IfNamed } |
The new allow_toggle field is a string enum: "any" (freely toggleable, the default), "never" (locked), "if_named", or "if_named_or_group". The freely-toggleable variant is deliberately spelled "any" rather than "always", so it cannot be confused with the legacy enable = "always" shorthand — which means the opposite (locked-on). The legacy string forms stay on the outer enable value only; allow_toggle never accepts them.
This is required for conversation persistence. The compat deserializer (jp_conversation::compat::deserialize_partial_config) is consumed by both the base config snapshot (base_config.json) and config_delta events in the event stream, so any conversation created before this RFD landed may carry legacy enable values on either surface. Without compat-aware parsing, the affected stored config layer (base snapshot or config_delta event) would fail typed deserialization and be replaced with an empty partial after a warning (see jp_conversation::compat::deserialize_partial_config). The conversation would still open, but legacy enable values would be silently lost, changing tool availability for old conversations.
Output is always canonical. The compat path is read-only — no new code emits legacy strings. On the first re-serialization after this RFD lands (e.g. a subsequent config_delta write, or a jp config set against the user's config file), the bool and legacy-string shorthands normalize to the table form.
In-code builtin registrations (currently Enable::Always for describe_tools) are updated to PartialEnableConfig::LOCKED_ON as part of the same change.
RFD updates accompany the code change:
- RFD 008 gets a TIP under "Design" noting that directive state-mutation is now gated by
allow_toggle. The existingtest_interleaved_disable_all_then_enable_allis replaced by tests asserting policy preservation. - RFD 055 is amended to remove
Enable::ExplicitOrGroupfrom its Implementation Plan, theexplicit_or_grouprow from the "Interaction with ToolenableField" table, the related Drawbacks entry ("NewEnablevariant"), and the Risks/Open Questions entry onEnableenum growth. Tool groups land withAllowToggle::IfNamedOrGroupalready in the schema and just need theToggleScope::NamedGroupparser. - RFD 056 is updated to refer to the stored
EnableConfigshape and per-field resolution, replacing theEnablefield type and theenable()/enable_mode()accessor names withis_enabled()/effective_enable(). - RFD 057's
apply_enable_toolswording is updated to route through the partial-config resolver seam (PartialEnableConfig::effective). The existing note that RFD 057 must decide whether group-sourcedallow_toggle = Neverblocks CLI directives is preserved. - RFD 083 is updated to fix stale
allow_togglewording: the bool-shorthand note that "resetsallow_toggletoalways" becomesany, and the builtin registration examples change from the resolvedEnable { ... }to the storedEnableConfig { ... }form. TheRequires: [RFD 081]relationship is unchanged.
Drawbacks
One-way serde normalization. The bool and legacy-string inputs (true, false, "on", "off", "always", "explicit") serialize back as the table form (e.g. enable = true becomes enable = { state = true, allow_toggle = "any" }). The shape is stable from the first write-back onward. This is the same shorthand-to-table normalization ModelIdConfig already performs (model.id = "anthropic/claude" writes back as a { provider, name } table), so enable stays uniform with the rest of the config rather than carrying a bespoke serializer.
Slightly more TOML for non-default cases. Non-default allow_toggle values require the map form: enable = { state = true, allow_toggle = "never" } in new configs is more verbose than the legacy enable = "always". The map form is more discoverable in exchange — each field states exactly one fact.
AllowToggle::IfNamedOrGroup is unreachable until RFD 055 lands. The variant is in the schema and the accepts predicate handles it, but no directive parser produces ToggleScope::NamedGroup until tool groups ship. A user who writes allow_toggle = "if_named_or_group" today gets if_named behavior until group parsing arrives, matching the user-facing note in TOML surface. The unreachable code is a deliberate forward-compat investment to avoid a schema change when RFD 055 lands.
Alternatives
Four alternatives were considered before this shape was chosen. Each is rejected for the reason given.
Patch the predicate, leave the directive engine alone. Update enable() to recognize Enable::Always as enabled. Fixes Bug 1, leaves Bug 2 and the variant proliferation problem in place. Rejected as a half-fix.
Two top-level fields, enabled: Option<bool> + policy: Option<Policy>. Clean per-field separation. Rejected for two reasons. First, two top-level fields flatten the (state, policy) pair into the tool's namespace where they mingle with unrelated fields like run, result, and command, and lose the visual grouping that signals these two values jointly define activation behavior. Bool shorthand is also harder to preserve when the policy field is a sibling rather than a sub-field — enable = true has no natural counterpart for the policy half. Second, the field name policy clashes with RFD 075 / RFD 076 / RFD 077 (AccessPolicy, RunPolicy, TrustPolicy).
Single field with nested Enable::State | Enable::Policy variants. Same architectural properties as the chosen shape, but introduces a deep nested type (Enable::State(EnableState::On)) that propagates through every match site. The chosen Enable { state, allow_toggle } struct expresses the same distinction with less syntactic weight.
Flat enum with exhaustive is_enabled() / is_policy() methods. The minimal-invasive alternative: keep Enable as a flat enum, fix the predicates to enumerate variants exhaustively. Smallest diff, but each new policy concept (Sticky, ExplicitOrGroup) still requires a new variant, new match arm, and new test surface. Doesn't address the variant proliferation problem.
Non-Goals
Tool group directive parsing. RFD 055 introduces -t GROUP / -T GROUP directive parsing. This RFD reserves ToggleScope::NamedGroup and includes AllowToggle::IfNamedOrGroup so that group parsing becomes a parser-only change, but does not implement group parsing itself.
Runtime access control. RFD 075 / RFD 076 / RFD 077 govern what a tool may do at runtime (filesystem, network, subprocess). allow_toggle governs what may change the tool's enable state at config time. The two are orthogonal concepts that share no fields.
Runtime tool-choice enforcement beyond the locked-off case. allow_toggle does not validate assistant.tool_choice in general. A persisted tool_choice = "foo" where foo has state = true, allow_toggle = Always is still forced into the request payload even after a CLI -T foo directive flipped state to false, because that flip happens on a freely-toggleable tool. The narrow case of a locked-off tool (state = false, allow_toggle = Never) is enforced — see Locked-off means hidden — to keep the terminology honest. Broader runtime enforcement (argument-level policy, tool-call sandboxing, plugin trust) remains with the access-policy track (RFD 075 / RFD 076 / RFD 077).
Risks and Open Questions
Persona-layer composition. Per-field defaults inheritance is the right composition rule for layered config, but the existing persona system has not been exercised against this kind of split field. Verify during implementation that the partial-config delta machinery handles PartialEnableConfig correctly when only one of state / allow_toggle is set in a layer.
Round-trip fidelity in jp config set. Verify that jp config set against a TOML containing enable = { state = true, allow_toggle = "never" } round-trips without mutation. Verify that jp config set against a TOML containing legacy enable = "always" produces enable = { state = true, allow_toggle = "never" } on the first write-back and then stays stable. This depends on the inline-table merge fix in Phase 1: the format-preserving TOML merge (deep_merge_toml in crates/jp_config/src/fs.rs) recurses only through standard tables today, so without the fix a nested jp config set conversation.tools.foo.enable.state ... against an inline-table enable would replace the whole value and drop allow_toggle.
Legacy strings in persisted conversation data. Conversations created before this RFD landed may contain legacy enable = "..." strings in both their base_config.json snapshot and their stored config_delta events. The compat deserializer must accept these on load on both surfaces; re-serialization (e.g. via a subsequent config_delta write) normalizes them. Add regression tests that load a conversation with legacy enable values in its base config and in its event stream, and assert the merged config exposes the correct state / allow_toggle.
Impact on tool config mutation grants. RFD 078 (Accepted, not yet implemented) lets tools write to paths under conversation.tools.* via the access.config grant model. Existing payloads that write a bool or legacy string (true, "on", "always", …) to conversation.tools.*.enable remain valid — the deserializer accepts them unchanged, so no migration is forced. A tool only needs the map form ({ state, allow_toggle }) when it wants to set one subfield while preserving the other from a lower layer. When RFD 078 is implemented, confirm grant payloads and any built-in config-mutating tools handle the map form; whole-value writes need no grant-path change.
Implementation Plan
Phase 1: type and serde
- Replace
Enable(currently a flat enum) with the stored/resolved pair injp_config/src/conversation/tool.rs:EnableConfigfor the stored form (withOption<bool>andOption<AllowToggle>fields; lives inToolConfig.enableandToolsDefaultsConfig.enableasOption<EnableConfig>), andEnablefor the resolved form returned by the effective-enable resolver. - Add
AllowToggleenum andToggleScopeenum.ToggleScopelives injp_configalongsideEnablebecauseEnable::acceptsconsumes it; the CLI directive parser injp_cliproduces values of this type rather than defining its own. - Hand-write
DeserializeonPartialEnableConfigto accept the bool / legacy-string / table inputs (#[config(no_deserialize_derive)]); let the macro auto-deriveSerialize(table output) andSchematic, the same wayModelIdConfigdoes. Noschematicmacro change is needed. - Add the
PartialEnableConfig::ON/OFF/LOCKED_ON/LOCKED_OFFconstants (builtins and the directive engine work on partials) and theEnable::is_enabled,Enable::is_locked, andEnable::acceptspredicates. - Update the partial-config infrastructure (
PartialEnableConfig,ToPartial,PartialConfigDelta,AssignKeyValue) for the new struct shape. - Implement compat-aware deserialization: accept
true/false/"on"/"off"/"always"/"explicit"as input and rewrite each to the canonical struct form at parse time. Output is always canonical — the compat path is read-only. - Make the format-preserving TOML merge recurse into inline tables.
deep_merge_tomlincrates/jp_config/src/fs.rsrecurses only through standard tables today (as_table_mut/as_table), so a nestedjp config set conversation.tools.foo.enable.state ...against anenable = { state = true, allow_toggle = "never" }inline table would replace the whole value and dropallow_toggle. Switch the recursion to table-likes (as_table_like_mut/as_table_like) so inline and standard tables both deep-merge.
Phase 2: predicates and ctx
- Add
ToolConfigWithDefaults::effective_enable() -> EnableandToolConfigWithDefaults::is_enabled() -> bool, both performing the per-field fallback against the storedEnableConfigs. Replace today'sToolConfigWithDefaults::enable()withis_enabled()at consumer sites. - Add
PartialEnableConfig::effective(&self, defaults: &PartialEnableConfig) -> Enablefor the partial-config consumers exercised in Phase 3. - Verify
Ctx::configure_active_mcp_serversworks againstis_enabled(). - Update
tool_definitions()to useis_enabled(), and remove theforced_toolexemption for locked-off tools (state == false && allow_toggle == Never). Locked-off tools are filtered out regardless ofassistant.tool_choice. See Locked-off means hidden. - Update the builtin registration of
describe_toolstoPartialEnableConfig::LOCKED_ON.
Phase 3: directive engine
- Rewrite
apply_enable_toolsto use the scope-vs-policy model. The bulk-only filters (is_explicit,is_always) are removed. The named-disable guard is generalized to the named directive case. ToggleScope::NamedGroupis added but no parser produces it (parking spot for RFD 055).- Route
apply_tool_useandapply_enable_toolsthrough the partial-config resolver seam (PartialEnableConfig::effective; see Effective enable resolution) instead of inspecting raw partial fields. - Add an
AppConfig-level validation check (inAppConfig::validate, which sees bothassistantandconversation.tools) that rejectsassistant.tool_choice = Function(name)whennameresolves to a locked-off tool. The error names bothassistant.tool_choiceandconversation.tools.<name>.enable, matching how--tool-use NAMEvalidates against the enabled set today.
Phase 4: tests and RFD updates
- Replace
test_interleaved_disable_all_then_enable_allwith tests asserting (a)allow_toggleis preserved across all interleaved directive sequences for every policy, and (b)statechanges only whenaccepts(scope)is true and the directive intent differs from the current state. - Add tests for the named-directive error paths and silent no-op paths.
- Add tests for
tool_definitions()andCtx::configure_active_mcp_serversagainstPartialEnableConfig::LOCKED_ON. - Add layered-merge tests:
enable = { state = true }on a tool inheritsallow_togglefrom[conversation.tools.'*'].enable = true(bool shorthand) fully specifies both fields, overriding any inheritedallow_toggle.- omitted
enableresolves to the default-on, freely-toggleable pair.
- Add
--tool-use NAMEtests:- accepts a tool whose effective
stateis true via defaults only (no per-toolenableset). - accepts
describe_tools(locked-on builtin).
- accepts a tool whose effective
- Add compat tests for legacy
"always"/"explicit"strings appearing in bothbase_config.jsonsnapshots andconfig_deltaevents. - Add
jp config setround-trip tests forconversation.tools.foo.enable.stateandconversation.tools.foo.enable.allow_toggle. - Add locked-off enforcement tests:
tool_definitions()excludes a locked-off tool even whenassistant.tool_choicenames it.assistant.tool_choice = "foo"is rejected at config resolution whenfoois locked-off.Ctx::configure_active_mcp_serversdoes not start the MCP server backing a locked-off MCP tool.
- Update RFD 008 with a TIP describing the new gating.
- Update RFD 055 to drop
Enable::ExplicitOrGroupand the surrounding interaction table row, drawbacks, and risks entries. - Update RFD 056 to refer to the stored
EnableConfigshape and per-field resolution, replacing theEnablefield type andenable()/enable_mode()accessor names withis_enabled()/effective_enable(). - Update RFD 057's
apply_enable_toolswording to route throughPartialEnableConfig::effective, preserving the existing CLI-vs-groupallow_toggle = Neveropen question. - Update RFD 083 to fix the stale "resets
allow_toggletoalways" wording (nowany) and convert its builtin registration examples fromEnable { ... }to the storedEnableConfig { ... }form.
Phases 1–3 are interdependent and ship as one change. Phase 4 (tests and RFD updates) lands in the same PR.
References
- RFD 008 — Ordered tool directives (directive evaluation semantics).
- RFD 055 — Tool groups (consumer of
AllowToggle::IfNamedOrGroup). - RFD 056 — Group configuration defaults (group-level
enableinheritance). - RFD 057 — Group configuration overrides (group-level
enableenforcement). - RFD 075 — Tool sandbox and access policy (
AccessPolicy, naming context). - RFD 076 — Tool access grants (
AccessPolicy). - RFD 077 — Plugin configuration and trust policy (
RunPolicy,TrustPolicy). - RFD 078 — Tool config mutation (writes to
conversation.tools.*.enable). - RFD 079 — Config sources and load order (the source/precedence model this RFD's cross-layer merge participates in).
- RFD 083 —
ask_usertool (originally introducedEnable::Sticky). crates/jp_config/src/conversation/tool.rs—Enable,ToolConfig,ToolConfigWithDefaults.crates/jp_cli/src/cmd/query.rs—apply_enable_tools,apply_tool_use.crates/jp_cli/src/cmd/query/tool/builtins.rs— builtin tool registration.crates/jp_llm/src/tool.rs—tool_definitions().