RFD 081: Decompose tool enable into state and allow_toggle
- Status: Discussion
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-05-11
- Extends: RFD 008
- Required by: RFD 083
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 `true`.
#[default]
Always,
/// No directive may flip `state`. Serialized as `false`.
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 = false }
# 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 = false }"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
Deserialize is implemented on EnableConfig and PartialEnableConfig, not on resolved Enable — Enable is produced by the resolver, not deserialized directly. Both deserializers accept a bool, a string, or a map. Within the map form, the allow_toggle field accepts true (= Always), false (= Never), or the strings "if_named" / "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 EnableConfig 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 emits the bool shorthand when allow_toggle == Always, and the map form otherwise. Round-trip is exact for inputs already in canonical form; legacy strings and explicit-Always maps are one-way-normalized to canonical on the first write-back.
PartialEnableConfig serializes only the fields that are Some. This matters because jp config set and config_delta events write partial deltas that may set just one half — e.g. jp config set conversation.tools.foo.enable.allow_toggle if_named produces a partial with state: None, which the bool shorthand cannot express.
| Partial input | Serialized output |
|---|---|
{ state: Some(true), allow_toggle: Some(Always) } | true (bool shorthand) |
{ state: Some(false), allow_toggle: Some(Always) } | false (bool shorthand) |
{ state: Some(_), allow_toggle: Some(non-Always) } | { state, allow_toggle } |
{ state: Some(_), allow_toggle: None } | { state } |
{ state: None, allow_toggle: Some(_) } | { allow_toggle } |
{ state: None, allow_toggle: None } | omitted from the parent map |
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 DirectiveScope {
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: DirectiveScope) -> bool {
match (self.allow_toggle, scope) {
(AllowToggle::Always, _) => true,
(AllowToggle::Never, _) => false,
(AllowToggle::IfNamed, DirectiveScope::Named) => true,
(AllowToggle::IfNamedOrGroup,
DirectiveScope::Named | DirectiveScope::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 sent to the LLM. 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.
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 at config-resolution time whenfooresolves to a locked-off tool. This mirrors the existing--tool-use NAMEvalidation against the enabled set: surface the conflict where the user wrote it rather than silently coerce, or pass through to a provider that will reject the request anyway (Google/Gemini does).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 EnableConfig {
// Stored-form constants used by builtin registrations and other
// code that needs a compile-time-known configuration.
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: DirectiveScope) -> bool { /* as above */ }
}describe_tools's builtin registration becomes enable: Some(EnableConfig::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 never accepts the string "always" — freely toggleable is spelled allow_toggle = true, locked is spelled allow_toggle = false. The legacy string form enable = "always" therefore cannot be confused with any value of the new field.
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), legacy strings normalize to bool shorthand or the map form.
In-code builtin registrations (currently Enable::Always for describe_tools) are updated to EnableConfig::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 theDirectiveScope::NamedGroupparser.
Drawbacks
One-way serde normalization. Legacy string inputs ("on", "off", "always", "explicit") serialize back as their canonical form. The shape is stable from the first write-back onward. Round-trip is exact only for inputs already in canonical form — consistent with existing Enable behavior (today's enable = "on" already collapses to enable = true).
Slightly more TOML for non-default cases. Non-default allow_toggle values require the map form: enable = { state = true, allow_toggle = false } 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 DirectiveScope::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 DirectiveScope::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 = false } round-trips without mutation. Verify that jp config set against a TOML containing legacy enable = "always" produces enable = { state = true, allow_toggle = false } on the first write-back and then stays stable.
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 lets tools write to paths under conversation.tools.* via the access.config grant model. Tools previously granted write on conversation.tools.*.enable were writing a bool or string; under this RFD they write the map form ({ state, allow_toggle }). Verify that grant payloads and any built-in config-mutating tools are updated for the new shape.
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 andDirectiveScopeenum.DirectiveScopelives injp_configalongsideEnablebecauseEnable::acceptsconsumes it; the CLI directive parser injp_cliproduces values of this type rather than defining its own. - Implement
Serialize/Deserializewith the bool-or-map shape forEnableConfigandPartialEnableConfig.Enableonly needsSerialize— it is produced by the resolver, not deserialized directly. - Implement
Schematic. - Add the
EnableConfig::ON/OFF/LOCKED_ON/LOCKED_OFFconstants 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.
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_toolstoEnableConfig::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. DirectiveScope::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 a config-resolution-time check that rejects
assistant.tool_choice = Function(name)whennameresolves to a locked-off tool. The error surfaces where the user wrote the conflict, 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_serversagainstEnableConfig::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.
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().