Facts are on by default; disabling is opt-out, env- and flag-controllable, and skips resolution

Status: proposed. This records the decided model; the --disable/FACTS_DISABLE surface, the resolver gating, and the reserved control key are implemented by a dedicated OpenSpec change.

Facts today has only an opt-out blocklist that filters already-resolved facts out of output, configured by the Facter-compatible blocklist key. This change makes disabling a first-class facts-native control and gives it teeth. Every fact is on by default — the voluminous packages fact (ADR-0014) included — and a fact is removed only by disabling it. The disabled set is the union of three inputs: the CLI --disable a,b,c (comma-separated), the FACTS_DISABLE=a,b,c environment variable, and the facts-native facts.conf disable key. disable is the native term across all three surfaces; the Facter blocklist key keeps working as its compatibility alias — facter.conf is part of the binding input contract, so blocklist is superseded in name by disable, never removed, with native disable winning on collision. A disable target is a fact name or a fact group, reusing the existing group expansion (BlocklistedFactsWithGroups), so --disable networking drops the whole group. There is no opt-in, allowlist, or default-off tier; the model is purely all-on with subtractive disabling.

Disabling aims to skip work, not just output — but the gating is per-resolver and per-probe, not a blanket per-name skip, because the resolver-to-top-level-fact map is many-to-one. Three classes follow:

Gating is therefore defined per probe, not per category: a shared memoized probe — cachedIdentity, which feeds both the identity fact and the SSH privilege gate; or DMI, which feeds gce and virtualization — is skipped only when every one of its consumers is disabled. Where a kept fact still needs a disabled fact’s probe, that probe runs and the disabled fact is prune-only (its work is still paid). packages shares no probe, so disabling it fully skips its work — the case the design optimizes for. This gating is new machinery: today every core fact builds eagerly (buildCoreFacts takes no disabled-set parameter) and the blocklist filters afterward. Implementing it requires a fact/group-to-resolver mapping and plumbing the disabled set into buildCoreFacts; what is reused from the existing blocklist is the disabled-set expansion (names and groups), not a resolver-skip mechanism, which does not exist today. A disabled sub-fact (os.release) likewise cannot skip its resolver without dropping its siblings, so it is resolve-then-prune through the existing descendant-pruning filter; sub-facts are cheap, so no meaningful work is paid.

Three semantics fix the surface. The inputs union — disabling is additive and order-independent. --no-block is the master override: it clears the entire disabled set for that run, including --disable and FACTS_DISABLE, and resolves everything. Disable beats query — a disabled fact named in a query returns nothing, matching Facter’s blocklist-beats-query and the engine’s existing order (the disabled set is applied before query projection). Because the disabled set can come from the ambient environment (FACTS_DISABLE exported in a shell rc) or from config, a query for a fact disabled by a source other than the same command line emits a one-line stderr diagnostic (e.g. fact "packages" is disabled by FACTS_DISABLE) while keeping stdout empty, so a silently-empty result is diagnosable. A cache-ordering invariant must hold: the disabled set is subtracted before the cache is consulted and before any cache write, and a pruned sub-fact is never persisted into a cached group — so a disabled fact is never served stale from cache (this preserves today’s order, where FilterBlockedFacts precedes cache resolution).

FACTS_DISABLE is a reserved control key, reserved by its resolved fact name, not its literal spelling. The FACTS_*/FACTER_* prefixes are the external-fact environment namespace, and environmentFactName lowercases the variable and strips any of facts_, facts, facter_, or facter — so FACTS_DISABLE, FACTSDISABLE, FACTER_DISABLE, and FACTERDISABLE all resolve to an external fact named disable. The environment-fact loader therefore drops any variable whose resolved fact name is disable (and the other reserved control names) rather than reserving one literal string, covering every prefix spelling uniformly. The documented cost is that an external environment fact literally named disable cannot be defined.

Adding --disable/FACTS_DISABLE and any facts-native fact groups (such as the packages group) diverges the Facter-mirrored --list-block-groups/--list-cache-groups output from Ruby Facter. This is accepted: facts-native ergonomics over the same disabled set, consistent with the facts-native-names-with-facter-compat-inputs pattern.

Considered Options