Forthcoming capability
Preview targeted at v0.1.1 Proposed RFC

System Facts

A manifest can ask whether cargo-nextest is installed. It cannot ask which operating system it is running on. RFC 0029 proposes an opt-in, immutable host object — collected once per invocation, from the running Netsuke binary rather than the environment — so that a platform branch becomes a fact a manifest reads instead of a guess it makes from uname or OS=Windows_NT.

Status on 19 October 2026

  • Not in v0.1.0-beta4. No template variable, global, command, or configuration key exposes host facts today. Nothing on this page is implemented.
  • A proposed RFC. RFC 0029, “First-class host facts”, created on 25 September 2026, reached main in PR #802 with status Proposed. Merging it did not accept it: accepting it, and recording an ADR, is itself roadmap task 30.1.1. It replaces the maintainer’s sketch this page was first written from.
  • Planned as roadmap phase 30. “Opt-in host facts without hidden discovery” has five steps and fifteen tasks, none ticked.
  • Intended near the front of v0.1.1. The RFC’s release disposition is “post-v0.1.0” and leaves release assignment to review; phase 30 names no release and adds no v0.1.0 gate. The v0.1.1 aim is the maintainers’ stated intent, not a scheduled commitment.

Why first-class host facts

Replacing a Makefile currently leaves platform decisions in shell probes, environment conventions, or repeated Jinja branches with repository-specific names. Netsuke’s own Makefile picks its Linux linker flag with BUILD_HOST_OS := $(shell uname -s). The RFC’s answer fits in one sentence:

“Collect once through explicit providers, normalize once, then render against an immutable snapshot. Facts describe observations; they neither grant capabilities nor select execution policy.” — RFC 0029 §1

Ansible, with dispositions

Ansible’s gathered facts are the prior art: one namespace, readable in templates and conditions. The RFC keeps that and the split between choosing what to collect and filtering what to show, and rejects the rest — see roads not taken.

No hidden I/O

“No fact collector launches a shell, performs a network request, reads the process environment, or executes local fact files.” The default group reads nothing at all: its values come from the running binary.

Small on purpose

An unrestricted inventory would bring “hidden I/O, identifying data, stale observations, graph changes caused by load, and incorrect cross-compilation decisions”. Three groups, fifteen fields, and a closed vocabulary.

Opting in, and who may grant what

A manifest opts in with a root host_facts mapping. Without it, nothing is collected and a variable a manifest already calls host stays an ordinary variable. With it, and no gather list, only the inexpensive platform group is requested.

Proposed syntax, RFC 0029 §1

host_facts:
  schema: 1

vars:
  test_program: "fixture-test{{ host.exe_suffix }}"

# Excerpt: the schema/version declaration and other manifest sections are omitted.
actions:
  - name: check-posix
    when: "{{ host.family == 'unix' }}"
    command: cargo test --all-targets
  - name: check-windows
    when: "{{ host.os == 'windows' }}"
    command: cargo test --all-targets

Proposed syntax, RFC 0029 §5.1

host_facts:
  schema: 1
  gather:
    - platform
    - distribution
  • Two keys only. schema is required; gather is a non-empty, duplicate-free list of literal group names that must include platform.
  • Closed vocabulary. No wildcard, negation, Jinja, or environment interpolation, and no all, so an upgrade cannot silently broaden collection. Unknown keys, versions, and groups fail before any provider runs.
  • A new format version. The opt-in needs an additive manifest-format version, allocated at implementation time; the examples deliberately omit a netsuke_version. Older Netsuke must reject the field, not ignore it.

The authority ceiling (§5.2)

“The project requests groups; it cannot grant collection authority.” Trusted operator configuration and explicit command-line choices set the allow-list, through the existing OrthoConfig provenance path under the proposed key host_fact_policy.allowed_groups. The default grant is platform alone. Asking for distribution or kernel without a grant fails before any I/O, naming the denied group and the fix. Operators may deny platform too, denials outrank requests, and no environment variable is assigned that could spoof a value.

An immutable namespace (§5.3)

Once opted in, host is reserved throughout evaluation. Root and per-entry variables, loop bindings, macro parameters, imports, and local Jinja assignments may not shadow it — validation covers every binding site, not only vars.host — and item or attribute mutation is rejected. Without the opt-in there is no warning and no migration, and the unchanged quickstart is an acceptance fixture.

The fact schema

“Host” means the Netsuke planner process: not the machine that built Netsuke, not a cross-compilation output, not a container a recipe enters, and not a remote executor. An x86-64 Netsuke running under translation on an ARM machine reports x86_64; a Linux Netsuke in WSL reports linux; a Windows Netsuke launched from Git Bash reports windows. Passing a Cargo --target never changes host.

Platform: the default group (§6.1)

No filesystem, registry, environment, subprocess, or network access: the values come from std::env::consts and target configuration. The spellings are Rust’s, so macos, not Darwin, and aarch64, not arm64. A platform that cannot supply a field fails with unsupported_host_platform rather than being guessed as linux or x86_64.

Field Type Contract (RFC 0029 Table 2)
host.osstringRust-style planner OS identifier, for example linux, windows, macos, or freebsd.
host.familystringwindows, unix, or other, with no distribution-family meaning.
host.archstringPlanner process ABI architecture, for example x86, x86_64, arm, or aarch64.
host.pointer_widthintegerPointer width in bits for the planner process ABI.
host.endiannessstringlittle or big.
host.exe_suffixstringExecutable suffix including its dot, or empty. Does not enumerate PATHEXT or pick a cross-target’s suffix.
host.path_separatorstringNative path-component separator, not a shell escape. Proves nothing about case sensitivity or symlinks.
host.path_list_separatorstringPath-list delimiter: ; on Windows, : on Unix.

Distribution: opt-in, granted (§6.2)

host.distribution.id, .id_like, .version_id, and .name, each nullable, from Linux os-release: /etc/os-release first, /usr/lib/os-release only if that is missing, never merged, never sourced in a shell.

Bounded throughout: 64 KiB read, 256-byte strings, 16 id_like entries, at most eight symlink hops. The file is opened non-blocking and type-checked on the open handle, so a FIFO fails as host_fact_source_invalid instead of stalling. Off Linux the group is unsupported: no registry crawl, no lsb_release.

Kernel: opt-in, granted (§6.3)

host.kernel.name, .release, and .native_arch, each a string or null, read through OS-native APIs without executing uname, ver, or PowerShell.

native_arch is filled only when the adapter can establish it under translation, and is never a copy of host.arch. Release text is opaque: no SemVer ordering is implied. Platform, kernel, and distribution may legitimately disagree inside a container; they are never merged into one identity.

Missing, denied, and invalid are different (§6.4)

  • Missing is a value. Once a group is selected every field exists; an observation without it is null, and an optional field is tested with is not none. A group’s outcome is collected, partial, absent, or unsupported.
  • Denied and invalid are errors. A denied group, malformed data, an I/O failure, or an exceeded bound is never reported as absent, false, zero, or an empty mapping.
  • Typos are errors too. Reading an unselected group raises host_fact_not_collected without collecting; an unknown field raises unknown_host_fact, even inside a default expression, “so a typo does not become a silent platform decision”.

Using facts: what they replace, and one snapshot per run

Today: ambient probing

actions:
  - name: sign-installer
    # Fails to load where OS is unset:
    # env() has no default on main.
    when: env('OS') == 'Windows_NT'
    command: "Set-AuthenticodeSignature ..."

Proposed: the when form from RFC 0029 §1

host_facts:
  schema: 1

actions:
  - name: sign-installer
    when: "{{ host.os == 'windows' }}"
    command: "Set-AuthenticodeSignature ..."

The right-hand panel applies the RFC’s when pattern to the left-hand action; the action itself is this page’s, not the RFC’s. Where the question is a suffix rather than a branch, the RFC’s own "fixture-test{{ host.exe_suffix }}" folds two per-platform names into one.

Collect once (§7.1)

  1. Parse the literal opt-in and request.
  2. Validate names, schema, bindings, and the operator ceiling.
  3. Construct the selected providers.
  4. Collect in canonical order: platform, distribution, kernel.
  5. Validate and freeze one HostFactsSnapshot.
  6. Inject a read-only projection.
  7. Reuse it for expansion, validation, planning, and output.

Field access does no I/O. There is no mid-build refresh and no fact task in the Ninja graph.

Data, not trusted source (§7.2)

Facts may appear in value templates and when expressions, but not in an include path, bundle URL, Git ref, or any field whose own RFC requires a literal. A fact is “never pre-quoted shell source or a filesystem capability”: values from system files stay untrusted through rendering, and legacy shell recipes keep their quoting protections. Changing cwd, capturing output, or overlaying a child’s environment cannot alter the frozen facts; there is no set_fact channel back.

Includes and bundles (§7.3)

Every local fragment shares the importing root’s snapshot, and an included file may not request collection. A bundle may declare that it needs an already selected group, but cannot widen the request or the grant; missing required facts fail before the bundle renders. A bundle sees the importing planner, not its publisher. Both depend on the modules work.

Discovery, inspection, and privacy

Target discovery stays non-disclosing (§8.1)

netsuke help targets collects no facts by default. If discovery meets a host reference in metadata or a when, it returns host_facts_disabled_in_query: it must not guess a platform, hide targets, or present an incomplete list as complete. Consent is per invocation, and a project setting cannot give it:

Proposed command, RFC 0029 §8.1

$ netsuke help targets --host-facts

With consent it gathers exactly the requested groups within the ceiling, and still renders no recipes and permits no env(), which(), command, or network helper. This differs from env() today, whose guarded entries are kept and marked conditional; the RFC accepts an actionable error instead of symbolic evaluation.

Standalone inspection (§8.2)

A read-only facts command needs no Netsukefile and reads only trusted operator configuration. --gather chooses what is collected; repeated --filter chooses only what is shown.

Proposed commands, RFC 0029 §8.2

$ netsuke facts
$ netsuke --json facts --gather platform --filter os --filter arch

The JSON envelope carries schema_version, scope: planner_process, collected_groups, facts, group outcomes, and bounded provider identifiers. A schema-description option returns the field catalogue without collecting anything. The command and flag spellings are still open for review (§14.6).

Data minimization (§8.3). The catalogue excludes hostnames, addresses, network interfaces, usernames, home directories, machine IDs, serial numbers, environment dumps, SSH keys, package inventories, timestamps, uptime, and filesystem inventories. Raw provider documents never appear in diagnostics, and telemetry labels use only registered group, provider, and outcome categories.

“Observation provenance is not attestation.” A container image or a compromised host can report misleading data, and a hash proves equality, not truth.

Reproducibility, planning, and caching

Opted-in facts are environmental planning inputs. The same manifest, snapshot, and declared inputs must compile identically; different planners need not produce the same graph. Collecting without a shell does not make a build hermetic.

A semantic digest (§9.1)

Versioned, over the schema, selected groups, normalized values, and group outcomes; keys sorted, sequence order kept, null distinct from empty. Timestamps, durations, source paths, localization, and diagnostic text are excluded. Every selected group is fingerprinted, used or not: conservative, and not dependent on a textual scan for host.

No stale Ninja work (§9.2)

The digest enters the generated plan’s identity and each edge’s command signature, not a Ninja comment, so a changed fact invalidates affected work even when the command text is unchanged. No persistent fact cache ships; serialized snapshots are provenance or test fixtures only.

Planning versus execution host (§9.3)

An exported or reused plan records its facts scope and digest, and Netsuke checks it before running; a mismatch is host_plan_context_mismatch and requires regeneration, never a silent re-plan midway. Running an exported file under Ninja directly bypasses that check, and the RFC says so rather than claiming a guarantee.

What a manifest knows today

Nothing in the shipped standard library describes the host directly. A manifest asks specific questions through specific helpers, each with its own gate, and none runs under netsuke help targets. RFC 0029 is explicit that facts must not become a bypass for env() or command_available().

Question Helper How it is gated
Is a tool installed, and where?command_available(name)
which(name)
Reads an injectable PATH and, on Windows, PATHEXT. The workspace-recursive search mode is opt-in, because it can resolve a checkout-controlled executable.
An environment variableenv(name)Required: an absent variable is an error. Read through an injectable EnvReader and checked against env_allow_var and env_block_var. A project file cannot grant itself access.
What kind of file a path isis file, is dir, …Through a capability-scoped directory handle. A missing path is false.
The home directorypath | expanduserA platform-specific chain of variables; fails rather than passing ~ through.
The timenow()An injected clock, which tests pin to a fixed instant.

Separately, roadmap step 6.7 plans query-gated probes — exists, link_exists, same_file, mount, and expandvars — covered on the standard library preview. Those ask about files and variables; host facts ask about the planner, and depend on none of them.

Where facts stop

Resource and capability facts, deferred (§10)

No host.jobs, free memory, or load. “Neither a processor count nor a memory total establishes available build budget.” Nothing changes -j, Ninja pools, or RFC 0024’s contention classes. Filesystem features, libc compatibility, executable presence, virtualization, and network reachability need their own probes; none is inferred from host.os. Roadmap 30.5.1 decides later whether any earns an RFC.

Not the recipe interpreter (§4.2)

“host.family == 'unix' does not prove that Bash exists; host.os == 'windows' does not prove that PowerShell or Git Bash is configured.” Shell choice stays with RFC 0011 and the structured commands work; tool presence stays with resolvers or the managed states and probes proposal.

Not the target (§4.3)

“Observations never select a target triple automatically.” Artefact names and compiler targets come from declared inputs — see typed inputs — unless an action deliberately builds for the planner’s own platform.

Not remote

Scope is local planning. A command that invokes SSH, a container engine, an emulator, or a cross-compiler receives no implicit remote facts; a future remote executor needs its own platform contract, “not a fake override of local host”.

Roads not taken

Alternative Why not (RFC 0029 §3, §14)
Shell or environment probes everywhereDuplicates policy, complicates tests, and couples graph evaluation to ambient state. Kept as a legacy escape hatch.
Copy Ansible’s full inventoryBrings identifiers, mutable observations, executable plugins, and dependencies unrelated to platform selection. Borrow the namespace and subset model, not an inventory service or set_fact.
Inject facts into every templateCollides with existing host variables and weakens metadata privacy.
Treat process facts as physical-host or target factsConcise, and wrong under cross-compilation and translation. Review may rename host.arch to process_arch, but must keep the distinction.
Gather lazily, or cache globallyLazy I/O makes cost and authority depend on control flow; global caches leak between embedders and go stale.
Prefixed top-level aliases, last-merged conflictsTwo spellings per field invite shadowing; instead each field has exactly one owning collector, and a duplicate contribution is an internal error.

Still open (§14.6). The final spelling of the inspection command and the consent flag, platform names for less common targets, and the safe native-adapter dependencies. The RFC holds that none of these alters the privacy default, the immutable snapshot, the scope definitions, or the operator ceiling.

From the shallow end

A manifest that never opts in is untouched: RFC 0025 keeps unannotated manifests a supported use, and the quickstart must stay byte-for-byte identical. The route deeper, in order:

  1. Today: select tools, not platforms. command_available() in complementary when guards is shipped and tested. Where the real question is “is this tool here?”, it remains the right answer after facts land.
  2. Planned: opt in with two lines. host_facts: {schema: 1} requests platform only, which the default grant already allows. No operator configuration is needed.
  3. Planned: replace one probe. Swap an OS or uname check for host.os or host.family, or fold duplicated targets with host.exe_suffix.
  4. Planned: look before branching. netsuke facts shows what the planner would see, without a manifest.
  5. Planned: ask for more, with permission. Add distribution or kernel to gather, and have the operator grant it in host_fact_policy.allowed_groups.

Delivery slices (§13.1)

  1. Accept the field meanings, opt-in, privacy contract, and schema allocation; define the snapshot, grant, and diagnostic interfaces (roadmap 30.1).
  2. Ship the platform-only slice with the immutable projection, namespace checks, inspection, query consent, and fingerprinting together (30.2).
  3. Add separately authorized distribution and kernel providers (30.3).
  4. Integrate typed fixtures with the testing framework and publish Linux, macOS, and Windows migration examples (30.4).

Confidence

Existing

  • The command_available example in the user’s guide is an executed documentation test, run against stub commands and a controlled PATH.
  • env() and now() read injected seams, so tests control them without mutating the process environment or the clock — the pattern ADR-008 requires and the RFC’s provider seam follows.
  • Target discovery already refuses host-inspecting helpers; the RFC keeps that boundary rather than loosening it.

Planned (§11, §13.2)

  • A FixedHostFactsProvider with no fallback to real host APIs: fixed Linux, macOS, Windows, and other-platform snapshots render identically on every runner, with no set_var and no uname.
  • Counting providers prove one collection per group; denied and unselected groups perform zero native or filesystem operations.
  • A parser corpus for os-release — quotes, escapes, duplicates, invalid UTF-8, NUL, oversize, symlink cycles, a FIFO — plus bounded fuzzing from a finite seed corpus.
  • Property tests for normalization, deterministic digests, request-and-grant intersection, and null versus empty; a bounded model check of the collect-once and consent state machine against production validation code.
  • Real Ninja runs proving no-op reuse, rebuilds, removal of deselected edges, and rejection of mismatched reused plans.
  • Native smoke jobs on Linux, macOS, and Windows, including Git Bash and WSL. “A simulated Windows snapshot on Linux is not a Windows smoke test.”

Stable diagnostics (§12). unsupported_host_fact_schema, unknown_host_fact_group, host_fact_collection_denied, host_namespace_shadowed, unknown_host_fact, host_fact_not_collected, host_facts_disabled_in_query, host_fact_source_invalid, host_fact_limit_exceeded, and host_plan_context_mismatch. Each reports the group, field, request origin, and manifest span without printing system-file values. The downstream canaries are Repovec Appliance, MXD, and OrthoConfig.

Accessibility and localization

Every human-readable message Netsuke emits is localized by default through Fluent, and host facts are no exception. The facts themselves are machine values — linux, aarch64, unsupported — and stay untranslated, as do the diagnostic category identifiers and JSON field names.

  • Localized diagnostics. Roadmap 30.1.2 defines the stable diagnostic categories “with localized messages”; the category is the part that survives translation.
  • Localized inspection. netsuke facts and its schema description use “the existing CLI metadata, localization, and JSON conventions, not hand-built parallel output”.
  • Locale cannot change a build. The facts digest excludes localization and diagnostic text, so running under another locale does not invalidate a plan.
  • Legible, unambiguous output. Human output puts one field per line and escapes control characters; empty values, nulls, and unsupported groups remain visibly distinct. --json gives tools a structured alternative.
  • Redaction by omission. Identifying data is never collected, raw system files never reach diagnostics, and telemetry labels are bounded categories.

Where the RFC is silent. It says nothing about screen readers, the ASCII or emoji output themes, or how “visibly distinct” null, empty, and unsupported values are spoken rather than seen. Nor does it describe how host_facts_disabled_in_query reads in help targets output beside today’s [? conditional] markers. Those are gaps to settle during review, not promises.