Skip to content

Zester v0.6.0

Choose a tag to compare

@github-actions github-actions released this 13 Jul 20:05
· 33 commits to main since this release

Added

  • sys.doc — on-node module documentation. zester '<target>' sys.doc
    returns a unified index of every callable surface (state modules, execution
    functions, and the peel dispatch specials state.apply/state.highstate/
    facts.*/settings.*/pillar.*/event.send); sys.doc <module> renders
    that module's documentation through the same modschema.RenderText used by
    zester doc and the generated pages, mirroring dispatch precedence
    (dispatch specials → state registry → execution registry). A module that is
    both a state and an execution surface (e.g. cmd.run) is annotated as also
    reachable from templates via salt['<module>']. sys.doc runs on the peel's
    read-only fast path, so it answers even during a long highstate; its result
    rides the existing ExecResponse result field (no wire change).

  • sys.list_functions now lists every callable surface on the peel — state
    modules and dispatch specials in addition to execution functions (it
    previously listed only execution functions).

  • Self-documenting execution modules + the first execution-module reference
    page.
    Every built-in remote-execution function (test.echo/version/true/
    false, pkg.version/list_pkgs, service.status/start/stop/restart,
    disk.usage, cmd.run, grains.item/items, sys.list_functions,
    sys.doc) now carries a modschema.Spec (Kind exec), registered via
    Registry.RegisterSpec — so sys.doc <function>, zester doc, and the
    generated docs all describe it with drift-corrected parameter and behavior
    metadata. Parameter schemas are lifted from each function's actual argument
    alias sets (e.g. pkg.version accepts name/package/pkg, defaulting to
    the request ID) in the SAME precedence order the runtime argStr helper
    checks them
    cmd.run as an execution module (salt['cmd.run'](...),
    zester '<target>' cmd.run ...) declares cmd as its canonical primary
    (aliases command, name, argStr's exact check order), not command
    a declared canonical name is checked before its aliases, so a spec whose
    order didn't match the runtime's own precedence would silently accept the
    wrong value when a caller set more than one of them; pinned by a permanent
    contract fixture where both cmd and command are set. (This deliberately
    differs from the cmd.run STATE module, whose primary is command — a
    different surface with its own precedence, see BD-8 below.) A new combined
    reference page,
    Execution Modules, documents them all
    (generated by zester-docgen; cmd.run stays on its dual-surface state
    page). A closed execmod doc-coverage conformance gate now requires every
    registered execution function to carry a spec, and permanent contract fixtures
    pin the argument-alias decoding across YAML/CLI/msgpack.

  • zester doc [module] [--json] — offline operator documentation for
    self-documenting modules (Track B2).
    Renders the embedded module docs
    (pkg/moduledoc) through the SAME modschema.RenderText a connected daemon
    uses for sys.doc, so the offline CLI answer is byte-identical to the
    peel-side one (pinned by TestDocdataMatchesLive). Fully offline — reads only
    the embedded docdata, never contacts a master, and works on a peel-only box
    with no config. Bare zester doc lists documented modules grouped by family;
    zester doc <module> prints the full parameter/effects/examples/notes render;
    an unknown module reports a clear error with nearest-match suggestions (edit
    distance ≤ 2); --json emits the structured ModuleInfo. Cobra shell
    completion now suggests documented module names for both zester doc <TAB>
    and the exec form zester '<target>' <TAB> (sourced from moduledoc.All()).
    The CLI's streamlined-output set gains sys.doc so a peel-side sys.doc
    result prints as plain rendered text. Internally, parseModuleArgs now binds
    a bare positional to a self-documenting module's declared primary parameter
    looked up from the embedded docdata (replacing the hand-maintained per-module
    positional table); every previously hard-coded module resolves identically,
    and file.managed's positional now binds to its canonical primary name
    (decode-identical to the former path, which is a registered alias, with the
    ID carrying the same value). Modules absent from docdata keep the legacy
    fallback (mixed-fleet honesty).

  • Self-documenting Starlark custom modules. The .star module loader now
    captures each module's documentation at load time — the function docstring
    (Google-style summary/description + Args: section, via the pinned
    go.starlark.net Function.Doc()), the source location (Function.Position()),
    and an optional per-function <fn>_params or module-global PARAMS dict — and
    registers a modschema.Spec so Starlark modules appear in sys.doc / zester doc through the state Registry's Describe exactly like built-in Go modules.
    A module is registered with OpenParams (accepts arbitrary keys) unless it
    declares a PARAMS/<fn>_params dict; a declared dict opts the module into
    unknown-key validation, which surfaces unknown config keys through the peel's
    configured decode policy (LoaderConfig.DecodeOptions) while leaving the
    requisite/attribute/compiler directives and name reserved. Documentation is
    re-captured on hot-reload. Authoring convention documented in the hand-owned
    guides/modules/starlark.mdx.

  • Generated JSON Schema artifact for module parameters
    (website/public/schema/zester-modules.schema.json, JSON Schema draft
    2020-12) — machine-readable parameter schemas for every state module that
    has a migrated self-documenting schema (file.managed, file.directory,
    file.absent, file.append, file.copy, file.recurse, file.symlink,
    file.touch, file.line, file.replace, file.comment, file.uncomment,
    file.keyvalue, file.blockreplace, pkg.installed, pkg.latest,
    pkg.purged, pkg.removed, service.running, service.dead, and
    user.present) — service.running/service.dead contribute the shared
    TriState semantic-type $def, file.managed the TemplateFlag and
    FileMode $defs, user.present the GroupRef and StringList $defs,
    file.append is the second module to contribute to the StringList $def,
    file.keyvalue contributes the StringMap $def (its key_values and
    entries parameters — the type's first migrated consumer), and
    file.directory/file.recurse are further FileMode $def contributors
    (file.directory's mode with a dir_mode fallback alias; file.recurse's
    DECLARED-ONLY dir_mode and lazy file_mode); a module without a schema
    is left unconstrained, so the artifact
    grows automatically as more modules migrate, never breaking existing
    consumers. Generated deterministically by the new cmd/zester-docgen tool
    from the live module registries.

  • New architecture page: Self-Documenting Modules.
    A hand-owned overview, for contributors and operators, of the module-schema
    framework introduced above: how one compiled schema declaration per module
    (a tagged struct plus a Doc block) drives parsing, the strict unknown-
    parameter policy, sys.doc/zester doc, the generated reference pages, and
    the JSON Schema export from a single source of truth; the sealed six-type
    semantic vocabulary; permanent contract fixtures as behavioral regression
    guards across YAML/CLI/msgpack; and the concrete gates (the docgen
    freshness diff, the closed doc-coverage ratchet, TestDocdataMatchesLive,
    the package-boundary architecture test) that make documentation drift a
    build failure instead of a silent fact of life. Registered in the
    Architecture nav (architecture/meta.json).

  • New guide: Developing a Built-in Module (guides/modules/developing) —
    the step-by-step howto for adding a state module with a self-documenting
    schema: the proto struct and zester tag grammar, semantic types, the
    drift-corrected Doc, builder/lifecycle contract, registration and the
    coverage gates, contract fixtures across the three input universes, docgen
    regeneration, and the exec-module variant. Complements the operator-facing
    Starlark guide and the architecture page.

Changed

  • Starlark hot-reload now UNREGISTERS removed modules. A function removed
    from a reloaded .star file — and every module of a deleted .star file or
    a removed _modules/ directory — is unregistered from the state registry on
    the next load pass, so it is neither callable nor documented (previously the
    stale builder and docs survived until a peel restart). When another loaded
    file still provides the same module name (a deleted formula override with a
    surviving global definition), that surviving file is re-executed so ITS
    builder becomes live again. New state.Registry.Unregister seam backs this;
    a conformance test pins that built-ins are never unregistered.

  • A .star module can no longer shadow a built-in module name. The
    Starlark loader refuses to register a module whose name is already held by a
    non-Starlark registration (e.g. a pkg.star defining installed colliding
    with the built-in pkg.installed) and logs an error naming the file —
    previously the .star silently hijacked the built-in fleet-wide with no way
    to restore it until restart. Starlark-over-Starlark overrides (formula
    overrides global, hot-reload) are unaffected.

  • A typo'd state-module parameter now FAILS the build instead of being silently
    ignored (strict_params, default on).
    The peel gains a strict_params knob
    (peel.yaml strict_params: / --strict-params, default true). With every
    built-in state module now decoding through a compiled schema, an unrecognized
    parameter on a migrated module is a hard error carrying the module name, the
    offending key, and a did-you-mean suggestion (edit distance ≤ 2) — for example
    pkg.removed with nmae: fails with unknown parameter "nmae" (did you mean "name"?) rather than applying the state with a defaulted name. This is a
    behavior change from the previous warn-and-continue default; set
    strict_params: false to restore the old behavior (a warning is logged through
    the peel logger and the state still builds). The policy is enforced uniformly
    across every decode path — compiled highstates, ad-hoc zester '<target>' <module> … runs, module.run forwarding, reactor dispatch.module actions,
    and Starlark custom modules that declared PARAMS — and never false-positives
    on reserved directives (require/watch/onlyif/order/…), the exec-layer
    test=True flag, module.run's name selector, or the Salt universal
    state-identifier idiom — an explicit name: on a module that declares no
    name parameter of its own (test.nop: - name: anchor is valid Salt even
    though test.nop has no name field) is excused fleet-wide, not just for
    module.run. A module that DOES declare its own name parameter or alias
    (e.g. file.managed) still decodes an explicit name: into that field as
    normal — the reserved-key excuse only ever applies to a key no declared
    parameter claimed. The compiler's names: expansion is schema-aware: each
    expanded name is injected into the module's PRIMARY parameter's canonical key
    when it has one (cmd.run's primary is command, not a bare name), or the
    historical literal name key otherwise (module.run, legacy/Starlark
    modules without a declared schema, and parameter-less modules like test.*
    — all now reserved-tolerant of "name", so this no longer trips a false
    strict failure). The injection never overwrites an EXPLICIT value already
    present at the primary key or one of its aliases — names: [...] alongside
    an explicit command: (or its name: alias) keeps that command for every
    expanded instance instead of clobbering it with the per-instance name.
    Reserved keys carried into a build (they are still present in the config map
    when the compiler builds a compiled state) are always excused; only genuine
    unknown parameters fail.

  • cmd.run now RUNS the name: command (Salt parity — BD-8). cmd.run's
    primary command parameter gains the name alias, so the Salt idiom
    cmd.run: - name: apt-get update now executes apt-get update exactly as Salt
    does. Previously Zester read only command and silently ran the STATE ID
    instead — quietly wrong, and a latent bug in zester-migrate's output (it
    passes Salt's name: through unchanged). This changes what executes for that
    idiom, from quietly-wrong to Salt-correct. Precedence is command-beats-name (a
    declared command wins over name), and an empty-string command falls
    through to name before the state-ID fallback. Pinned by permanent contract
    fixtures across YAML/CLI/msgpack.

  • cmd.run and service.enabled migrated — the doc-coverage ratchet reaches
    ZERO and the gate is CLOSED.
    With these two, EVERY built-in state module now
    decodes through a single compiled schema (modschema.Spec) plus registered
    documentation metadata; there is no longer an unmigratedAllowlist (it was
    deleted) and the coverage conformance test is in its final form —
    TestDocCoverage_EveryModuleHasSpec asserts every registered module carries a
    Spec with no exemptions, and a Spec-less registration fails the build. The dead
    providerBuild legacy adapter (its last two callers were these modules) was
    removed. Highlights:

    • cmd.run: command is the primary (defaults to the state ID) and now
      accepts the name alias (see the BD-8 entry above — the Salt idiom
      cmd.run: - name: <command> runs the named command);
      args is a paramtypes.StringList and env a paramtypes.StringMap; cwd
      and creates are plain strings. The require-file-provider-when-creates rule
      stays in the builder tail (cross-field module logic, not schema). Check/Apply/
      Revert — the creates guard gating both phases, the shell-vs-direct execution
      split on args, the captured command/stdout/stderr/exitcode details,
      and the non-revertible Revert — are unchanged. cmd.gocmd_run.go.
    • service.enabled: name is the sole parameter (primary, defaults to the
      state ID); Check/Apply/Revert (including the already-enabled Apply no-op that
      does not arm the revert memo, and the standalone-revert clean no-op) are
      unchanged. service.goservice_enabled.go.
      Behavior is unchanged for every realistic input across all three universes
      (YAML, CLI, msgpack) except for the flagged behavioral differences below. The
      reference pages (cmd-run.mdx, service-enabled.mdx) are now generated from
      the registered schema + documentation metadata rather than hand-maintained —
      all prior content is preserved (parameter tables, the creates idempotency
      mechanism, the shell-vs-direct sh -c behavior, the returned-details table now
      carried as a Note, env merged with the process environment, every example,
      and cmd.run's dual-surface note that it is also an execution module reachable
      from templates via salt['cmd.run']) and reorganized under the shared anatomy,
      drift-corrected against the live code (notably service.enabled's Apply/Revert
      no-op semantics, which the hand page omitted). The permanent differential
      contracts at pkg/state/modules/testdata/contract/{cmd.run,service.enabled}.yaml
      guard the decode behavior across all three universes. The doc-coverage ratchet
      shrinks by 2 (2 → 0 unmigrated modules — the ratchet is now empty).

    Alongside the gate-close, several documentation-surface fixes landed: the
    sys.doc unified index is now pinned to cover every registered state module by
    a permanent peel test (TestPeelDocSourceCoversAllStateModules) rather than an
    empirical observation; zero-parameter generated pages (test.ping, test.nop,
    module.run) now render the auto requisites boilerplate with an
    "no parameters of its own" note instead of silently omitting the Parameters
    section; the N:1 page-group renderer no longer silently falls back to
    per-member rendering on a shared-parameter mismatch — distinct-parameter groups
    (host, ssh-auth, test-helpers) now opt in explicitly via a group-table
    flag, so a genuinely mismatched shared-proto group fails generation loudly;
    module.run's two Salt-divergence facts moved from Doc.Divergences
    (BD-IDs only, per convention) into Notes; and guides/modules/index.mdx's
    Source column was corrected for every renamed/split module file.

    Behavioral difference (BD-5, approved 2026-07-13 under the maintainer standing
    proceed-without-sign-off grant).
    cmd.run's args (a paramtypes.StringList)
    and env (a paramtypes.StringMap) now surface the values the legacy parsers
    silently dropped: a scalar args list element is rendered to its string form
    (the legacy element-wise .(string) assertion dropped any non-string element),
    a nested args element is rejected with a typed error (was dropped), a
    bare-string args value decodes as a single-element list — which, downstream,
    switches execution to the direct (non-shell) path — where the legacy
    config["args"].([]any) assertion failed entirely and left the command on the
    shell path, and a composite env value is rejected with a typed error where the
    legacy fmt.Sprintf("%v", v) sprint'd it into Go syntax (a scalar env value
    stays parity — both render it to a string). Pinned per-param by the
    args-scalar-sprint-*, args-nested-rejected-*, args-bare-string-*, and
    env-composite-value-rejected-* contract fixtures.

    Behavioral difference (BD-6, APPROVED 2026-07-12). The migrated string
    parameters are now compiled plain strings: a numeric cmd.run command,
    cwd, or creates, or a numeric service.enabled name, is coerced to its
    string form (was a silent drop — for the primary command/name, a silent fall
    back to the state ID), and a composite value (a list/map) is rejected with a
    typed error rather than silently zeroing. The CLI already delivered a numeric
    token as a string (PARITY). Pinned per-param by the numeric-command-coerced-*
    / composite-command-rejected-*, numeric-cwd-coerced-* /
    composite-cwd-rejected-*, numeric-creates-coerced-* /
    composite-creates-rejected-* (cmd.run) and numeric-name-coerced-* /
    composite-name-rejected-* (service.enabled) contract fixtures.

  • The final test.* helpers and module.run migrated to the self-documenting
    module-schema framework — the doc-coverage ratchet reaches 2.
    Every built-in
    state module except cmd.run and service.enabled now decodes through a single
    compiled schema (modschema.Spec) plus registered documentation metadata,
    replacing the hand-written config[...].(type) extractions. The multi-module
    test_extra.go was split into per-module files (test_nop.go,
    test_fail_without_changes.go, test_succeed_with_changes.go,
    test_configurable_test_state.go) alongside the existing test_ping.go,
    matching the per-module file convention so each generated Source line
    resolves. The Registration.BuildPlain shape gained the decode policy
    (func(modschema.DecodeOptions) state.Builder) so the provider-less test
    helpers thread reserved keys + the fleet unknown-key policy into their decode
    exactly like the provider-carrying modules. Highlights:

    • test.ping / test.nop declare no parameters; the decode still runs so
      reserved requisite/attribute keys are honored and unknown keys follow the
      fleet policy.
    • test.fail_without_changes / test.succeed_with_changes carry a single
      comment string (no default; the fail helper substitutes its built-in
      failure without changes message at apply time).
    • test.configurable_test_state carries eager default=true result and
      changes bools plus a comment string; Check/Apply are unchanged.
    • module.run is an OpenParams passthrough: it declares no fixed
      parameters (its schema has zero fields, marked OpenParams so unknown-key
      validation is skipped and it is documented as accepting arbitrary parameters
      forwarded to the target module). Its dynamic name: / dotted-<module.func>:
      target resolution and the reserved-key filter (state.ReservedKeySet() plus
      its own local name) are unchanged.
      Behavior is unchanged for every realistic input across all three universes
      (YAML, CLI, msgpack) except for the flagged behavioral differences below. The
      reference pages (test-ping.mdx, test-helpers.mdx — the N:1 group covering
      the four helpers, module-run.mdx) are now generated from the registered
      schema + documentation metadata rather than hand-maintained — all prior content
      is preserved (parameter tables, Check/Apply/Revert behavior, every example, the
      onfail/onchanges chain snippets now carried as Notes, the unimplemented
      Salt test.show_notification/test.mod_watch note, and module.run's two-form
      usage + its Divergences-from-Salt facts: state-registry targets only, and
      idempotent-as-its-target unlike Salt's always-changes module.run) and
      reorganized under the shared anatomy, drift-corrected against the live code. The
      permanent differential contracts at pkg/state/modules/testdata/contract/{test. fail_without_changes,test.succeed_with_changes,test.configurable_test_state}.yaml
      guard the decode behavior across all three universes (test.ping/test.nop
      declare no parameters, so they have no contract fixture). The doc-coverage
      ratchet shrinks by 6 (8 → 2 unmigrated modules — only cmd.run and
      service.enabled remain, migrated by their own closeout).

    Behavioral difference (BD-2, APPROVED 2026-07-12). String-form values that
    the legacy .(bool) assertion dropped are now coerced, origin-independently (a
    CLI key=value and a YAML-quoted value alike): test.configurable_test_state's
    result/changes given as a truthy/falsy string ("true", "yes", "on",
    "false", "no", "off") is honored where the legacy assertion silently kept
    the default true. Pinned by the {result,changes}-falsy-string-{cli,yaml}
    contract fixtures.

    Behavioral difference (BD-6, APPROVED 2026-07-12). Each migrated module's
    string parameters are now compiled plain strings: a numeric comment on
    test.fail_without_changes, test.succeed_with_changes, or
    test.configurable_test_state is coerced to its string form (was a silent drop
    to ""), and a composite comment (a list/map) is rejected with a typed
    error. test.configurable_test_state's result/changes bools likewise reject
    a composite or a float (a float is not a boolean) with a typed error rather than
    silently keeping the default. The CLI already delivered a numeric comment as a
    string (PARITY). Pinned by the comment-numeric-coerced-* /
    comment-composite-rejected-* and {result,changes}-float-rejected-yaml /
    -composite-rejected-yaml contract fixtures.

    Behavioral difference (BD-7, APPROVED 2026-07-12). The result and
    changes bools of test.configurable_test_state now accept the integers 1
    and 0 (1 → true, 0 → false) and reject any other integer with a typed
    error, per the approved §2.3 coercion table and the §11 SCOPE ruling that BD-7
    covers ALL boolean-typed parameters (each pinned per-param). The legacy .(bool)
    assertion dropped an integer entirely (a silent fall to the default true).
    Pinned by the {result,changes}-int-{one,zero}-{yaml,msgpack} and
    -invalid-int-{yaml,msgpack} contract fixtures.

  • pkgrepo.managed and user.absent migrated to the self-documenting
    module-schema framework.
    Each constructor now decodes through a single
    compiled schema (modschema.Spec) plus registered documentation metadata,
    replacing the hand-written config[...].(type) extractions. The single-module
    pkgrepo.go was renamed to pkgrepo_managed.go and UserAbsent was split out
    of user.go into user_absent.go (leaving user.go as the shared slice
    helpers containsString/stringSliceEqual used across the user/group/host
    modules), matching the per-module file convention. Highlights:

    • pkgrepo.managed is a parameter-decode-only migration: Check/Apply/Revert
      and the DEFERRED in-place signing-key-rotation detection (Check is
      presence-only — a key rotated at the same URL is not re-detected; delete the
      keyring file to force a re-import) are unchanged. name is the primary
      (default state ID); humanname is a lazy DERIVED default — the decoder
      never materializes it and the builder tail assigns it from name, reproducing
      the legacy if HumanName == "" { HumanName = RepoName }; baseurl/ppa/
      file/key_url are plain strings; enabled/gpgcheck/refresh carry an
      eager default=true. Nothing is sensitive (a signing-KEY URL points at a
      PUBLIC key — a per-module sensitivity pass).
    • user.absent is an all-primitives migration: name primary (default state
      ID); purge/force plain bools. Nothing is sensitive. force remains
      accepted for Salt compatibility but is not yet wired into the execution layer
      (documented, no effect).
      Behavior is unchanged for every realistic input across all three universes
      (YAML, CLI, msgpack) except for the flagged behavioral differences below. The
      reference pages (pkgrepo-managed.mdx, user-absent.mdx) are now generated
      from the registered schema + documentation metadata rather than hand-maintained
      — all prior content is preserved (parameter tables, Check/Apply/Revert behavior,
      every example, the rendered .list/.repo file-format blocks, and pkgrepo's
      Divergences-from-Salt facts: baseurl holding the full deb line, the
      deprecated apt-key add, the unsupported disabled/mirrorlist/
      gpgautoimport/comps/architectures parameters, and the presence-only
      keyring-convergence caveat) and reorganized under the shared anatomy,
      drift-corrected against the live code. The permanent differential contracts at
      pkg/state/modules/testdata/contract/{pkgrepo.managed,user.absent}.yaml guard
      the decode behavior across all three universes. The doc-coverage ratchet shrinks
      by 2 (10 → 8 unmigrated modules).

    Behavioral difference (BD-2, APPROVED 2026-07-12). String-form values that
    the legacy .(bool) assertion dropped are now coerced, origin-independently (a
    CLI key=value and a YAML-quoted value alike): a pkgrepo.managed
    enabled/gpgcheck/refresh or a user.absent purge/force given as a
    truthy/falsy string ("false", "no", "yes", "on") is honored (the three
    pkgrepo bools were silently dropped to their default true, the two user.absent
    bools to false). Pinned by the {enabled,gpgcheck,refresh}-falsy-string-{cli, yaml} and {purge,force}-truthy-string-{cli,yaml} contract fixtures.

    Behavioral difference (BD-6, APPROVED 2026-07-12). As with every prior
    migration, each module's primary name parameter is now a compiled plain
    string: a non-string name (for example name: 123) is coerced to its string
    form (was a silent fallback to the state ID), and a composite name (a
    list/map) is rejected with a typed error. The same acceptance/rejection class
    covers pkgrepo.managed's remaining string params — a numeric
    humanname/baseurl/ppa/file/key_url is coerced to its string form and a
    composite value for any of them is rejected — instead of the legacy silent drop.
    The CLI already delivered a numeric name as a string (PARITY). Pinned by the
    numeric-name-coerced-* / composite-name-rejected-* (both modules) and the
    {humanname,baseurl,ppa,file,key_url}-numeric-coerced-* / -composite-rejected-*
    contract fixtures.

    Behavioral difference (BD-7, APPROVED 2026-07-12). The
    enabled/gpgcheck/refresh bools of pkgrepo.managed and the purge/force
    bools of user.absent now accept the integers 1 and 0 (1 → true, 0
    false) and reject any other integer with a typed error, per the approved §2.3
    coercion table and the §11 SCOPE ruling that BD-7 covers ALL boolean-typed
    parameters (each pinned per-param). The legacy .(bool) assertion dropped an
    integer entirely (a silent fall to the pkgrepo default true / the user.absent
    false). Pinned by the {enabled,gpgcheck,refresh,purge,force}-int-{one,zero}- {yaml,msgpack} and -invalid-int-{yaml,msgpack} contract fixtures.

  • git.cloned, git.latest, pip.installed, archive.extracted,
    locale.present, and timezone.system migrated to the self-documenting
    module-schema framework
    (the tooling wave). Each constructor now decodes
    through a single compiled schema (modschema.Spec) plus registered
    documentation metadata, replacing the hand-written config[...].(type)
    extractions. The legacy git.go was split into git_cloned.go (the
    GitCloned module) plus git.go kept as the shared rev-comparison helpers
    (isHexRevPrefix/isFullHexSHA/resolveRevCommit/revAtHead) used by both
    git.cloned and git.latest; the single-module mount.go-style renames
    (locale.golocale_present.go, timezone.gotimezone_system.go,
    pip.gopip_installed.go, archive.goarchive_extracted.go) match
    the per-module file convention. Every field in all six modules is a
    primitive (string/int/bool) — no semantic types are needed anywhere in this
    wave. Highlights:

    • git.cloned's name (defaulting to the state ID) is the clone PATH;
      git.latest's name (defaulting to the state ID) is instead the remote
      URL, with the clone path in its own target parameter. This DIFFERENT
      primary meaning between the two modules — the most common authoring
      mistake between them — is called out unmissably in both generated pages
      (a dedicated warning Note on each, plus the Description prose). url
      (git.cloned) and target (git.latest) are required; depth
      (git.cloned) is a plain int; force (both) is a plain bool.
      git.latest's name is primary, not required — it legitimately
      falls back to the state ID — but the builder tail restores the legacy
      git.latest: <id>: url (name) is required error for the case where
      BOTH are empty (parity restoration, not a BD; pinned by the
      TestGitLatestMissingURL unit test, following the same builder-tail-
      logic-is-unit-tested-not-contract-tested convention as ssh_auth's
      user-or-config rule and sysctl.present's persist-needs-file-provider
      rule — the equivalent git.cloned gap doesn't exist because its
      primary is name/path and url is independently required). Their
      Doc's Check/Revert prose is drift-corrected: a pinned tag/symbolic
      rev converges via a local git rev-parse --verify <rev>^{commit}
      commit-id comparison, not merely a sha-prefix match as the old
      git.latest hand page's "Divergences from Salt" section claimed.
    • pip.installed's bin carries an eager default=pip3, reproducing the
      legacy construction-time default.
    • archive.extracted's source is required; archive_format carries an
      eager default=auto; source_hash is TrimSpace'd in the builder tail
      (a decoder never trims — same convention as ssh_auth.present's name).
      Its Doc is drift-corrected: the hand page claimed Salt's source_hash
      verification "is not supported", but the module already records a
      source_hash marker after extraction and re-extracts on a mismatch — it
      is an opaque string comparison, never a byte-verified checksum, which the
      new Doc states plainly instead of omitting the feature. The hand page's
      "Divergences from Salt" facts — the unsupported enforce_toplevel/
      options/user/group/clean/trim_output parameter list, and the
      tar/unzip (plus curl or wget for remote sources) binary
      requirement — are carried forward verbatim (verified still accurate)
      into the new Doc's own "Divergences from Salt" Note, alongside the
      corrected source_hash fact above.
    • locale.present has a single parameter (name, the locale string) — the
      single-primary-param exemplar. Its Doc is drift-corrected: the hand
      page claimed Apply creates /etc/locale.gen when missing; the module in
      fact never creates that file on a system that lacks it.
    • timezone.system's utc is a plain bool.
      Behavior is unchanged for every realistic input across all three universes
      (YAML, CLI, msgpack) except for the flagged behavioral differences below.
      The reference pages (git-cloned.mdx, git-latest.mdx, pip-installed.mdx,
      archive-extracted.mdx, locale-present.mdx, timezone-system.mdx) are now
      generated from the registered schema + documentation metadata rather than
      hand-maintained — all prior content is preserved (parameter tables,
      Check/Apply/Revert behavior, every example, the per-manager/per-format
      tables, the Divergences-from-Salt notes) and reorganized under the shared
      anatomy, drift-corrected against the live code where the hand pages had
      fallen behind. The permanent differential contracts at
      pkg/state/modules/testdata/contract/{git.cloned,git.latest,pip.installed, archive.extracted,locale.present,timezone.system}.yaml guard the decode
      behavior across all three universes. The doc-coverage ratchet shrinks by 6
      (16 → 10 unmigrated modules).

    Behavioral difference (BD-1, approved 2026-07-13 under the maintainer
    standing proceed-without-sign-off grant).
    A git.cloned depth given as
    an INTEGER and delivered over msgpack is now applied. The legacy
    config["depth"].(int) assertion never matched a msgpack sized kind (msgpack
    v5 encodes a small int as a sized int8/uint), so a reactor-dispatched
    depth: 1 silently fell to 0 (full clone) — the same reproduced sized-int
    class as file.managed's BD-1. The uniform decoder honors the sized int.
    Pinned by the depth-msgpack-sized-int contract fixture. Presented for
    sign-off in this PR
    (keystone spec §11).

    Behavioral difference (BD-2, APPROVED 2026-07-12). String-form values
    that the legacy .(bool)/.(int) assertions dropped are now coerced,
    origin-independently (a CLI key=value and a YAML-quoted value alike): a
    git.cloned/git.latest force, an archive.extracted makedirs, and a
    timezone.system utc given as a truthy/falsy string ("true", "yes",
    "false", "no") is now honored (was silently dropped to its default
    false); a git.cloned depth given as a numeric string ("1" — the
    CLI's ONLY delivery form for an int) is now parsed base-10 (was silently
    dropped to 0 by the legacy config["depth"].(int) assertion, which never
    matches a string). Pinned by the {force,makedirs,utc}-{truthy,falsy}- string-{cli,yaml} and depth-numeric-string-{cli,yaml} contract fixtures.

    Behavioral difference (BD-6, APPROVED 2026-07-12). As with every prior
    migration, each module's primary parameter is now a compiled plain string: a
    non-string value (for example name: 123) is coerced to its string form
    (was a silent fallback to the state ID), and a composite value (a
    list/map) is rejected with a typed error. Further wrong-typed→typed-handling
    changes land under BD-6's approved acceptance/rejection class in this wave:

    • git.cloned url and git.latest target — ERROR→ACCEPT flips on
      REQUIRED parameters (read deliberately).
      A non-string value for either
      (for example url: 123) is now coerced to its string form and
      accepted, where the legacy .(string) assertion missed the non-string
      and raised the module's own "is required" error. archive.extracted's
      source gets the identical flip.
    • String coercion / composite rejection on the remaining string params.
      A numeric branch/rev (both git modules), version/requirements/bin
      (pip.installed), archive_format/if_missing/source_hash
      (archive.extracted) is coerced to its string form, and a composite value
      for any of them is rejected with a typed error, instead of the legacy
      silent drop.
    • git.cloned FLOAT depth. A finite-integral float (depth: 1.0) is
      coerced to the integer, where the legacy .(int) assertion missed a
      float64 and left Depth=0.
      The CLI already delivered a numeric primary as a string (PARITY). Pinned by
      the numeric-name-coerced-*/composite-name-rejected-* (all six modules),
      the {url,target,source}-numeric-accepted-*/-composite-rejected-*, the
      {branch,rev,version,requirements,bin,archive_format,if_missing,source_hash} -numeric-coerced-*/-composite-rejected-*, and the depth-float-* contract
      fixtures.

    Behavioral difference (BD-7, APPROVED 2026-07-12). The boolean
    force (git.cloned/git.latest), makedirs (archive.extracted), and
    utc (timezone.system) parameters now accept the integers 1 and 0
    (1 → true, 0 → false) and reject any other integer with a typed error,
    per the approved §2.3 coercion table and the §11 SCOPE ruling that BD-7
    covers ALL boolean-typed parameters (each pinned per-param). The legacy
    .(bool) assertion dropped an integer entirely (a silent fall to the
    default false). Pinned by the
    {force,makedirs,utc}-int-{one,zero,invalid}-{yaml,msgpack} contract
    fixtures.

  • mount.mounted, sysctl.present, host.present/host.absent, and
    ssh_auth.present/ssh_auth.absent migrated to the self-documenting
    module-schema framework
    (the system wave). Each constructor now decodes
    through a single compiled schema (modschema.Spec) plus registered
    documentation metadata, replacing the hand-written config[...].(type)
    extractions. The multi-module host.go/ssh_auth.go were split into per-module
    files (host_present.go, host_absent.go, ssh_auth_present.go,
    ssh_auth_absent.go), with the shared line-managed-file helpers moved to
    linemanaged.go; the single-module mount.go/sysctl.go were renamed to
    mount_mounted.go/sysctl_present.go to match the per-module file convention.
    Highlights of the wave:

    • mount.mounted is a parameter-decode-only migration: Check/Apply/Revert
      and the deliberate blindness to the LIVE mount's device/fstype/options (the
      audit's open live-facet-normalization item) are unchanged. device is
      required; fstype/opts carry eager default=ext4/default=defaults;
      dump/pass are plain ints; persist is an eager default=true bool.
    • sysctl.present's value is required and persist an eager default=true
      bool. The require-file-provider-when-persist rule stays in the builder tail —
      cross-field module logic, not schema.
    • host.present/host.absent are the per-field alias exemplar: the
      hosts-file path binds the config key with a path alias and an eager
      default=/etc/hosts, reproducing the legacy config > path > /etc/hosts
      precedence (the standalone hostsPath helper is gone). host.present's ip
      is required.
    • ssh_auth.present/ssh_auth.absent: enc carries an eager default=ssh-rsa.
      The name primary is TrimSpace'd and the require-user-OR-config
      cross-field rule are enforced in the builder tail (a decoder never trims;
      cross-field validation is module logic, not schema). The key material is a
      PUBLIC key, so — per a per-module sensitivity pass — no parameter is
      sensitive.
      Behavior is unchanged for every realistic input across all three universes
      (YAML, CLI, msgpack) except for the flagged behavioral differences below. The
      reference pages (mount-mounted.mdx, sysctl-present.mdx, host.mdx,
      ssh-auth.mdx) are now generated from the registered schema + documentation
      metadata rather than hand-maintained — all prior content is preserved and
      reorganized under the shared anatomy, drift-corrected against the live code.
      host and ssh-auth are the first N:1 page groups whose members carry
      distinct parameter surfaces (e.g. host.present has ip, host.absent
      does not), so zester-docgen now renders each member's own **Source** line
      and Parameters table under a per-module banner (extending the shared-proto
      page-group renderer). The permanent differential contracts at
      pkg/state/modules/testdata/contract/{mount.mounted,sysctl.present,host.present,host.absent,ssh_auth.present,ssh_auth.absent}.yaml
      guard the decode behavior across all three universes. The doc-coverage ratchet
      shrinks by 6 (22 → 16 unmigrated modules).

    Behavioral difference (BD-1, approved 2026-07-13 under the maintainer standing proceed-without-sign-off grant).
    A mount.mounted dump/pass given as an INTEGER and delivered over msgpack is
    now applied. The legacy config["dump"].(int) / config["pass"].(int)
    assertions never matched a msgpack sized kind (msgpack v5 encodes a small int as
    a sized int8/uint), so a reactor-dispatched pass: 2 silently fell to 0
    the same reproduced sized-int class as file.managed's BD-1. The uniform decoder
    honors the sized int. Pinned by the dump-int-msgpack and pass-int-msgpack
    contract fixtures. Presented for sign-off in this PR (keystone spec §11).

    Behavioral difference (BD-2, APPROVED 2026-07-12). String-form values that
    the legacy .(int)/.(bool) assertions dropped are now coerced, origin-
    independently (a CLI key=value and a YAML-quoted value alike): a mount.mounted
    dump/pass given as a numeric string ("1"/"2") is parsed base-10, and a
    mount.mounted/sysctl.present persist given as a truthy/falsy string
    ("false", "no") is honored (was silently dropped to the default true).
    Pinned by the dump-string-cli, dump-numeric-string-yaml, pass-string-cli,
    pass-numeric-string-yaml, and persist-falsy-string-{cli,yaml} (mount + sysctl)
    contract fixtures.

    Behavioral difference (BD-6, APPROVED 2026-07-12). As with every prior
    migration, each module's primary name parameter is now a compiled plain
    string: a non-string name (for example name: 123) is coerced to its string
    form (was a silent fallback to the state ID), and a composite name (a
    list/map) is rejected with a typed error. Further wrong-typed→typed-handling
    changes land under BD-6's approved acceptance/rejection class in this wave:

    • mount.mounted device, sysctl.present value, and host.present ip
      ERROR→ACCEPT flips on REQUIRED parameters (read deliberately).
      A non-string
      value for any of these required params (for example ip: 123) is now coerced
      to its string form and accepted, where the legacy .(string) assertion
      missed the non-string and raised the module's "X is required" error.
    • String coercion / composite rejection on the remaining string params. A
      numeric fstype/opts (mount), config/path (host), and
      user/enc/comment/config (ssh_auth) is coerced to its string form, and a
      composite value for any of them is rejected with a typed error, instead of the
      legacy silent drop.
    • mount.mounted FLOAT dump/pass and composite dump/pass. A
      finite-integral float (dump: 1.0) is coerced to the integer, and a composite
      dump/pass is rejected with a typed error, where the legacy .(int)
      assertion left 0. (BD-2 remains strictly string-coercion; a non-string
      wrong-typed value such as a float is BD-6's class.)
      The CLI already delivered a numeric name as a string (PARITY). Pinned by the
      numeric-name-coerced-* / composite-name-rejected-* (all six modules), the
      {device,value,ip}-numeric-accepted-* / {device,value,ip}-composite-rejected-*,
      the {fstype,opts,config,user,enc,comment}-numeric-coerced-* /
      -composite-rejected-*, and the {dump,pass}-float-* / -composite-rejected-*
      contract fixtures. Because coercion/rejection is origin-independent, host's
      hosts-file path is pinned through BOTH its canonical config key and its path
      ALIAS source — the path-alias-numeric-coerced-* (yaml + msgpack) and
      path-alias-composite-rejected-* fixtures (host.present and host.absent) prove a
      numeric/composite value delivered via the alias coerces/rejects exactly as
      through config.

    Behavioral difference (BD-7, APPROVED 2026-07-12). The persist boolean of
    BOTH mount.mounted and sysctl.present now accepts the integers 1 and 0
    (1 → true, 0 → false) and rejects any other integer with a typed error, per
    the approved §2.3 coercion table and the §11 SCOPE ruling that BD-7 covers ALL
    boolean-typed parameters (each pinned per-param). The legacy .(bool) assertion
    dropped an integer entirely (a silent fall to the default true). Pinned by the
    persist-int-{one,zero,invalid}-{yaml,msgpack} contract fixtures (mount +
    sysctl).

  • cron.present, cron.absent, group.present, and group.absent migrated
    to the self-documenting module-schema framework
    (the cron/group wave). Each
    constructor now decodes through a single compiled schema (modschema.Spec)
    plus registered documentation metadata, replacing the hand-written
    config[...].(type) extractions (and, for the group modules, the legacy
    parseAnyStringList list parser). The legacy multi-module cron.go/group.go
    were split into per-module files (cron_present.go, cron_absent.go,
    group_present.go, group_absent.go). Highlights of the wave:

    • cron.present/cron.absent's command is required (a missing/empty
      command fails at decode with a typed MissingRequired error, where legacy
      raised its own explicit "command is required" error — both reject, PARITY);
      user carries an eager default=root; and cron.present's five schedule
      fields (minute/hour/daymonth/month/dayweek) carry eager
      default=*, reproducing the legacy construction-time defaults.
    • group.present's gid is a plain int — group.present never resolves a
      group NAME, so (unlike user.present) it is NOT a paramtypes.GroupRef and
      there is no BD-4: a negative gid is accepted, not rejected. Its
      members/addusers/delusers are paramtypes.StringList and system is a
      plain bool. The Revert prose is drift-corrected: the hand page wrongly
      claimed membership changes are not reverted, but Revert diffs the current
      group against the memoized original and restores GID and membership.
      Behavior is unchanged for every realistic input across all three universes
      (YAML, CLI, msgpack) except for the flagged behavioral differences below. The
      reference pages (cron-present.mdx, cron-absent.mdx, group-present.mdx,
      group-absent.mdx) are now generated from the registered schema + documentation
      metadata rather than hand-maintained — all prior content is preserved
      (parameter tables, Check/Apply/Revert behavior, every example) and reorganized
      under the shared anatomy, drift-corrected against the live code.
      group.present's members/addusers/delusers are further StringList
      $def contributors to the combined JSON Schema artifact. The permanent
      differential contracts at
      pkg/state/modules/testdata/contract/cron.{present,absent}.yaml and
      .../group.{present,absent}.yaml guard the decode behavior across all three
      universes. The doc-coverage ratchet shrinks by 4 (26 → 22 unmigrated modules).

    Behavioral difference (BD-1, approved 2026-07-13 under the maintainer standing proceed-without-sign-off grant). A
    group.present gid given as an INTEGER and delivered over msgpack is now
    applied. The legacy config["gid"].(int) assertion never matched a msgpack
    sized kind (msgpack v5 encodes 999 as a uint16), so a reactor-dispatched
    gid: 999 silently fell to 0 (auto-assign / not compared) — the same
    reproduced sized-int class as file.managed's BD-1. The uniform decoder honors
    the sized int as a numeric GID. Pinned by the gid-int-msgpack contract
    fixture. Presented for sign-off in this PR (keystone spec §11).

    Behavioral difference (BD-2, APPROVED 2026-07-12). A group.present
    string-form gid or system is now coerced instead of dropped: a CLI
    gid=999 (the string "999") is parsed base-10 to the numeric GID, and a CLI
    system=true (the string "true") enables the system flag, where the legacy
    .(int)/.(bool) assertions dropped the string (GID 0 / system false).
    Origin-independent: a YAML-quoted gid: "999" and system: "yes" are honored
    the same way. Pinned by the gid-string-cli, gid-numeric-string-yaml,
    system-truthy-string-cli, and system-truthy-string-yaml contract fixtures.

    ⚠️ Behavioral difference (BD-3, approved 2026-07-13 under the maintainer standing proceed-without-sign-off grant) — THIS CHANGES
    REAL SCHEDULES; READ BEFORE APPROVING.
    A cron.present schedule value given
    as a YAML or msgpack INTEGER is now coerced to its string form. This applies to
    ALL FIVE schedule fields (minute/hour/daymonth/month/dayweek); the
    canonical case: minute: 5.

    • OLD behavior (the every-minute bug): the legacy
      config["minute"].(string) assertion did not match a non-string, so
      minute: 5 fell to the empty string "", which the constructor then
      defaulted to "*". The job ran every minute — never at minute 5.
    • NEW behavior: the uniform string decoder renders the integer via
      fmt.Sprint, so minute: 5 decodes to "5" and the job runs at minute 5,
      as written.
      This alters the actual cron schedule of any state that passed an unquoted
      integer
      schedule field through a reactor/msgpack path. Operators who relied on
      the old accidental every-minute behavior (unlikely, but possible) must quote the
      value or adjust. The CLI already delivered "5" as a string, so it is
      unaffected (PARITY). This integer-coercion arm is the ONLY change on the
      BD-3 sign-off sheet — the composite-schedule REJECTION (a
      wrong-typed→typed-error) is the APPROVED BD-6's class and is documented under
      BD-6 below, not here. Pinned per-field by the
      {minute,hour,daymonth,month,dayweek}-int-{yaml,msgpack} contract fixtures
      (minute-int-cli pins the CLI parity). Presented for sign-off in this PR
      (keystone spec §11).

    Behavioral difference (BD-5, approved 2026-07-13 under the maintainer standing proceed-without-sign-off grant).
    group.present's members/addusers/delusers are now paramtypes.StringList,
    which handles the three arms the legacy parseAnyStringList silently dropped:
    a scalar list element is rendered to a string (members: [alice, 1000]
    [alice, "1000"], where legacy dropped the 1000); a nested list/map
    element is rejected with a typed error (was silently dropped); and a
    bare-string value decodes as a single-element list that ACTIVATES membership
    management (members: alice[alice], where legacy ignored a non-list value
    entirely, managing no members — this is also what makes a CLI members=alice
    work). Pinned per-param by the members-*, addusers-*, and delusers-*
    (scalar-sprint / nested-rejected / bare-string) contract fixtures. Presented
    for sign-off in this PR
    (keystone spec §11).

    Behavioral difference (BD-6, APPROVED 2026-07-12). As with every prior
    migration, each module's primary name parameter is now a compiled plain
    string: a non-string name (for example name: 123 in YAML/msgpack) is
    coerced to its string form (was a silent fallback to the state ID), and a
    composite name (a list/map) is rejected with a typed error. Three further
    wrong-typed→typed-handling changes land under BD-6's approved acceptance/
    rejection class in this wave:

    • cron.present/cron.absent numeric command — an ERROR→ACCEPT flip on a
      REQUIRED parameter (read deliberately).
      A non-string command (for example
      command: 123 in YAML, or over msgpack where the sized-int arm applies) is
      now coerced to its string form "123" and accepted, where the legacy
      config["command"].(string) assertion missed the non-string and raised
      command is required. This is an accept-direction change on a required
      parameter. A non-string user is likewise coerced to its string form instead
      of the legacy silent fallback to root.
    • group.present composite gid and FLOAT gid. A composite gid (a
      list/map) is rejected with a typed WrongType error instead of the legacy
      silent 0; and a finite-integral FLOAT gid (for example gid: 999.0) is
      now coerced to the integer 999, where the legacy config["gid"].(int)
      assertion missed a float64 and left GID at 0. (BD-2 remains strictly
      string-coercion; a non-string wrong-typed value such as a float is BD-6's
      class.)
    • cron.present composite schedule value (refiled from BD-3). A composite
      schedule value (a list/map for minute/hour/…) is rejected up front with a
      typed error instead of silently falling to "*". This wrong-typed→typed-error
      rejection belongs to the APPROVED BD-6 class, NOT the BD-3 sign-off sheet
      (which carries only the schedule integer-coercion arm).
      The CLI already delivered a numeric name/command as a string (PARITY). Pinned by
      the numeric-name-coerced-* and composite-name-rejected-* (all four modules),
      command-numeric-accepted-{yaml,msgpack} and
      user-numeric-accepted-{yaml,msgpack} (cron.present + cron.absent),
      gid-composite-rejected-* and gid-float-{yaml,msgpack} (group.present), and
      minute-composite-rejected-{yaml,msgpack} (cron.present) contract fixtures.

    Behavioral difference (BD-7, APPROVED 2026-07-12). group.present's
    system boolean now accepts the integers 1 and 0 (1 → true, 0 → false)
    and rejects any other integer with a typed error, per the approved §2.3
    coercion table and the §11 SCOPE ruling that BD-7 covers ALL boolean-typed
    parameters (each pinned per-param). The legacy .(bool) assertion dropped an
    integer entirely (a silent false). Pinned by the
    system-int-{one,zero,invalid}-{yaml,msgpack,cli} contract fixtures across all
    three universes.

  • file.directory and file.recurse migrated to the self-documenting
    module-schema framework
    (the declared-facet wave). Each constructor now
    decodes through a single compiled schema (modschema.Spec) plus registered
    documentation metadata, replacing the hand-written config[...].(type)
    extractions and the shared modeConfigToString helper (now removed — its only
    two callers were these modules). Both modules move their mode parameters onto
    paramtypes.FileMode:

    • file.directory's mode is a paramtypes.FileMode declared
      lazy,default=0755 with dir_mode as a fallback alias — the mode is
      resolved at use time via Mode.Resolve(0755), and the legacy
      mode-then-dir_mode-then-0755 chain is reproduced exactly by the alias source
      precedence (name mode wins over the dir_mode alias; an empty-string
      mode falls THROUGH to dir_mode, never to the state ID — §2.1 source
      fall-through, PARITY). makedirs stays a plain bool whose documented no-op
      behavior (MkdirAll is unconditional) is now stated honestly on the generated
      page. Check/Apply/Revert are unchanged except for reading the typed mode
      (Mode.Resolve); the chmod-before-chown Apply order is preserved verbatim.
    • file.recurse's file_mode is a paramtypes.FileMode declared
      lazy,default=0644 (always enforced), and dir_mode is a
      paramtypes.FileMode declared lazy,default=0755 whose Declared() bit
      is load-bearing
      : it is a DECLARED-ONLY facet — undeclared, existing
      directory modes are neither compared (Check) nor rewritten (Apply), and the
      0755 default is used only as the MkdirAll creation perm. The module gates on
      r.DirMode.Declared() in both phases, reproducing the legacy
      DirMode != "" guard. source stays a plain string whose emptiness is a
      run-time "source is required" error (legacy parity, NOT a decode-time
      required); clean/makedirs are plain bools. Behavior is unchanged for
      every realistic YAML input.
      Each module moved to its own file already (file_directory.go,
      file_recurse.go); the shared file-module helpers stay in file.go. Their
      reference pages (file-directory.mdx, file-recurse.mdx) are now generated
      from the registered schema + documentation metadata rather than
      hand-maintained: all prior content is preserved (parameter tables, the
      Check/Apply/Revert behavior, the deploy/private-directory examples, the
      declared-only-dir_mode and clean-removes-regular-files-only caveats) and
      reorganized under the shared
      Source/Parameters/Parameter-Types/Effects/Examples/Notes/Divergences/See-Also
      anatomy, drift-corrected against the live code (notably: file.directory's
      makedirs is documented as inert rather than functional; file.recurse's
      declared-only dir_mode semantics are stated in both phases). Both modules are
      further FileMode $def contributors to the combined JSON Schema artifact.
      The permanent differential contracts at
      pkg/state/modules/testdata/contract/file.directory.yaml and
      .../file.recurse.yaml guard the decode behavior across all three universes
      (YAML, CLI, msgpack). The doc-coverage ratchet shrinks by 2 (28 → 26
      unmigrated modules).

    Behavioral difference (BD-1, approved 2026-07-13 under the maintainer standing proceed-without-sign-off grant). A mode given as
    an octal INTEGER and delivered over msgpack is now applied, for
    file.directory's mode (and its dir_mode alias) and file.recurse's
    file_mode/dir_mode. The legacy modeConfigToString switch handled only
    int/int64/float64, so a reactor-dispatched mode: 0700 (encoded by
    msgpack v5 as a sized uint16) fell through to the empty string and silently
    applied the module's default — the same reproduced 0755→0644-class bug that
    motivated file.managed's BD-1. paramtypes.FileMode interprets the octal
    value from every integer kind, so the requested mode now survives a reactor
    dispatch; the setuid/setgid/sticky bits survive too. For file.recurse's
    dir_mode the fix additionally restores the DECLARED-ONLY facet: over msgpack
    the legacy value fell through to "", which not only lost the mode but also
    DISABLED the facet — the new decoder keeps dir_mode declared. Pinned by the
    mode-0700-msgpack/mode-setgid-msgpack/dir-mode-alias-0700-msgpack
    (file.directory) and file-mode-0640-msgpack/dir-mode-0750-msgpack
    (file.recurse) contract fixtures. Presented for sign-off in this PR
    (keystone spec §11).

    Behavioral difference (BD-2, APPROVED 2026-07-12). A CLI makedirs=true
    string (and file.recurse's clean=true) now applies, where the legacy
    .(bool) assertion dropped the string and left the flag false. The same rule
    honors a YAML-quoted boolean string. Pinned by the makedirs-cli-truthy-string
    (both modules) and clean-cli-truthy-string (file.recurse) contract fixtures.

    Behavioral difference (BD-6, APPROVED 2026-07-12). A wrong-typed value is
    now handled deterministically instead of a silent zero/fallback: a non-string
    name/source is coerced to its string form (was a fallback to the state ID /
    the empty string), and a composite value (a list/map) into name, or into a
    mode parameter, is rejected with a typed error. Mode VALIDATION also moves to
    DECODE time: a float mode (which the legacy modeConfigToString %04o-
    converted and applied) and an invalid-octal string mode (which the legacy
    path carried through and only rejected at apply) are both rejected up front by
    paramtypes.FileMode. Pinned by the numeric-name-*/numeric-source-coerced-*,
    composite-name-rejected-*, composite-mode-rejected-*/composite-dir-mode- rejected-*, float-mode-rejected-*/float-file-mode-rejected-*/
    float-dir-mode-rejected-*, and invalid-octal-string-*-mode-rejected-*
    contract fixtures.

    Behavioral difference (BD-7, APPROVED 2026-07-12). Every boolean-typed
    parameter — file.directory's makedirs, and file.recurse's clean AND
    makedirs — now accepts the integers 1 and 0 (1 → true, 0 → false)
    and rejects any other integer with a typed error, per the approved §2.3
    coercion table and the §11 SCOPE ruling that BD-7 covers ALL boolean-typed
    parameters (each pinned per-param, not "same as the other"). The legacy
    .(bool) assertion dropped an integer entirely (a silent false). Pinned by the
    makedirs-int-{one,zero,invalid}-* (file.directory) and the
    clean-int-{one,zero,invalid}-* + makedirs-int-{one,zero,invalid}-*
    (file.recurse) contract fixtures across the YAML and msgpack universes (the CLI
    delivers a string, covered by BD-2).

  • file.line, file.replace, file.comment, file.uncomment,
    file.keyvalue, and file.blockreplace migrated to the self-documenting
    module-schema framework
    (the file-surgery wave). Each constructor now
    decodes through a single compiled schema (modschema.Spec) plus registered
    documentation metadata, replacing the hand-written config[...].(type)
    extractions and the shared fsxResolvePath/fsxToInt helpers (now removed).
    Highlights of the wave:

    • file.line's mode parameter is an ACTION ENUM (ensure/replace/insert/
      delete), NOT a permission mode — it is a plain string with an eager
      default=ensure, and the builder lowercases the resolved action to
      reproduce the legacy strings.ToLower normalization.
    • file.replace's pattern is required (a missing/empty pattern fails at
      decode with a typed MissingRequired error, where legacy raised its own
      explicit "pattern is required" error — both reject, PARITY), and the
      (?m)-anchored regex compile stays a construction-time error in the builder
      tail; count is a plain int.
    • file.comment/file.uncomment are the N:1 exemplar: ONE FileComment
      proto backs TWO registered names, so there are two modschema.Specs (one
      per name), each with its own documentation, compiling the same tagged
      parameter surface (name, char default #, required regex). Which
      behavior a built state performs is selected by an untagged runtime field set
      by the builder. Their single reference page (file-comment.mdx) is now a
      generated N:1 combined page — one shared Parameters section plus a per-name
      Effects/Examples/Notes/Divergences/See-Also section.
    • file.keyvalue's key_values and entries are TWO separate
      paramtypes.StringMap parameters — NOT a single name-wins alias. The legacy
      constructor UNIONED both maps (entries winning a per-key collision, since it
      merged second), so a name-wins alias would have silently discarded entries
      whenever both were supplied; the union merge is reproduced in the module tail
      (both maps, then the single key/value injection last). The single
      key/value convenience form keeps its legacy module-local injection as a
      post-decode merge (key requires a present, non-nil value); separator
      defaults to =. Pinned by the key-values-and-entries-union-* and
      key-values-entries-collision-entries-wins-* contract fixtures (PARITY, not a
      BD).
    • file.blockreplace keeps its name-only primary (its legacy constructor
      never accepted a path alias, so one is deliberately NOT added — parity,
      not a new divergence), with eager marker_start/marker_end defaults.
      Behavior is unchanged for every realistic input across all three universes
      (YAML, CLI, msgpack). The reference pages (file-line.mdx, file-replace.mdx,
      file-comment.mdx, file-keyvalue.mdx, file-blockreplace.mdx) are now
      generated from the registered schema + documentation metadata rather than
      hand-maintained — all prior content is preserved (parameter tables,
      Check/Apply/Revert behavior, every example and note, including the "Divergences
      from Salt" material folded into Notes) and reorganized under the shared
      anatomy, drift-corrected against the live code. The permanent differential
      contracts at pkg/state/modules/testdata/contract/file.{line,replace,comment, uncomment,keyvalue,blockreplace}.yaml guard the decode behavior; the
      differential harness (pkg/modschema/schematest) gained a StringMap matcher
      (its first migrated consumer). The doc-coverage ratchet shrinks by 6
      (34 → 28 unmigrated modules).

    Behavioral difference (BD-6, APPROVED 2026-07-12). As with every prior
    migration, each module's primary name parameter is now a compiled plain
    string: a non-string value (for example name: 123 in YAML/msgpack) is
    coerced to its string form instead of silently falling back to the state ID,
    and a composite value (a list/map) is now rejected with a typed wrong_type
    error instead of being silently ignored. Pinned by the numeric-name-* and
    composite-name-* contract fixtures across all six modules. Additionally,
    file.replace's count (now a compiled plain int) rejects a NON-integral
    float (count: 2.9) with a typed value_invalid error at decode time, where
    the legacy fsxToInt silently TRUNCATED it (int(2.9) = 2); an integral float
    still coerces, so only a non-integral value diverges (the same class as the
    file.managed float-mode fixture). Pinned by the float-count-rejected-{yaml, msgpack} contract fixtures. Additionally, EVERY non-primary string parameter
    that the legacy constructors read through a silent .(string) assertion now
    sprints a numeric scalar to its string form (the pkg.installed version
    precedent), pinned per-param across YAML and msgpack by
    numeric-<param>-coerced-{yaml,msgpack} fixtures: file.line's
    content/match/before/after/mode, file.replace's
    pattern/repl/not_found_content, file.comment/file.uncomment's
    regex/char, file.keyvalue's separator/key, and file.blockreplace's
    content/marker_start/marker_end. For the REQUIRED params (file.replace's
    pattern, file.comment/file.uncomment's regex) and for file.keyvalue's
    key this is a reject-to-accept flip — legacy zeroed the value and then hit the
    required/"no entries" check, where it now decodes to the coerced string.
    (file.keyvalue's SCALAR value is NOT in this coercion set: the legacy
    constructor already sprint'd a scalar with fmt.Sprintf, so a numeric single
    value is parity. A COMPOSITE single value — a nested map/list with key
    set — IS a BD-6 rejection, though: the legacy fmt.Sprintf("%v", …) wrote
    Go-syntax garbage into the file, where the value string field now rejects it
    with a typed wrong_type error at decode; pinned by the
    composite-single-value-rejected-{yaml,msgpack} fixtures.)

    Behavioral difference (BD-2, APPROVED 2026-07-12). A CLI
    <bool-param>=<truthy/falsy string> is now honored on every boolean
    parameter (file.replace's append_if_not_found/prepend_if_not_found,
    file.blockreplace's append_if_not_found/append_newline), and a CLI
    numeric-string count=2 is parsed for file.replace's count, where the
    legacy .(bool) / fsxToInt paths silently dropped a string. Pinned by the
    *-cli-truthy-string and count-cli-numeric-string contract fixtures.

    Behavioral difference (BD-7, APPROVED 2026-07-12). Each boolean parameter
    accepts the INTEGERS 1 and 0 (1 = true, 0 = false, across every signed/unsigned
    integer kind, so a msgpack-delivered bool — which arrives as a sized kind such
    as int8 — is honored) and rejects any other integer with a typed
    value_invalid error, where the legacy .(bool) assertion dropped an int
    entirely. EVERY boolean parameter's integer arm is pinned explicitly (not just
    a representative): file.replace's append_if_not_found/prepend_if_not_found
    and file.blockreplace's append_if_not_found/append_newline, each across
    YAML and msgpack by the *-int-one-*/*-int-zero-*/*-int-invalid-* contract
    fixtures, per the §11 SCOPE ruling that BD-7 covers ALL boolean-typed
    parameters.

    Behavioral difference (BD-1, approved 2026-07-13 under the maintainer standing proceed-without-sign-off grant). file.replace's
    count now honors a msgpack-delivered sized integer. msgpack v5 encodes a
    small integer into the smallest kind by magnitude (count: 2 → an int8),
    and the legacy fsxToInt switch handled only int/int64/float64 — so a
    reactor-dispatched file.replace with count: 2 fell through to 0
    (replace-all) instead of limiting the replacement (the same sized-int class as
    the reproduced file.managed 0755 → 0644 bug). paramtypes-free primitive
    int coercion interprets every integer kind, so the requested count now survives
    a reactor dispatch. Pinned by the count-msgpack-sized-int contract fixture.
    Presented for sign-off in this PR (keystone spec §11).

    Behavioral difference (BD-5, approved 2026-07-13 under the maintainer standing proceed-without-sign-off grant). file.keyvalue's
    key_values (a paramtypes.StringMap) rejects a COMPOSITE value — a nested
    map or list — with a typed value_invalid error, where the legacy
    fmt.Sprintf("%v", v) sprint'd it into Go syntax and wrote that garbage into
    the file. A scalar value is unchanged (rendered to its string form by both).
    Pinned by the composite-value-rejected-* contract fixtures (yaml/msgpack; a
    nested map has no CLI spelling). Presented for sign-off in this PR
    (keystone spec §11).

  • file.absent, file.touch, file.copy, file.symlink, and
    file.append migrated to the self-documenting module-schema framework

    (an all-primitives wave plus a second StringList consumer). Each
    constructor now decodes through a single compiled schema
    (modschema.Spec) plus registered documentation metadata, replacing the
    hand-written config[...].(type) extractions. file.touch and
    file.copy gain the path alias on their primary (name,primary, aliases=path, the file_managed.go exemplar) — matching what their
    legacy fsxResolvePath-based constructors already accepted, so this is
    parity, not a new divergence; file.absent, file.symlink, and
    file.append keep their legacy name-only primary (their pre-migration
    constructors never recognized a path alias, so one is deliberately NOT
    added here — parity, not a new divergence). file.copy's source is the first
    required primitive-string parameter to reach a migrated module: a
    missing or empty source fails at decode with a typed MissingRequired
    error, where legacy raised its own explicit "source is required" error —
    both reject, so this is parity under the differential harness, not a BD.
    file.append's text moves onto paramtypes.StringList (the same
    semantic type user.present's groups/optional_groups use), replacing
    the legacy parseAnyStringList helper (which stays in user.go for the
    still-unmigrated group.* modules). Behavior is unchanged for every
    realistic input across all three universes (YAML, CLI, msgpack). Each
    module moved fully self-contained (they already had their own files); their
    reference pages (file-absent.mdx, file-touch.mdx, file-copy.mdx,
    file-symlink.mdx, file-append.mdx) are now generated from the
    registered schema + documentation metadata rather than hand-maintained —
    all prior content is preserved (parameter tables, Check/Apply/Revert
    behavior, every example and note, including the "Divergences from Salt"
    material folded into Notes) and reorganized under the shared
    Source/Parameters/Effects/Examples/Notes/Divergences/See-Also anatomy. The
    permanent differential contracts at
    pkg/state/modules/testdata/contract/file.{absent,touch,copy,symlink, append}.yaml guard the decode behavior; the doc-coverage ratchet shrinks
    by 5 (39 → 34 unmigrated modules).

    Behavioral difference (BD-6, APPROVED 2026-07-12). As with every prior
    migration, each module's primary name parameter (and the non-primary
    string params file.symlink's target and file.copy's source) is now a
    compiled plain string: a non-string value (for example name: 123 in
    YAML/msgpack) is coerced to its string form instead of silently falling back
    to the state ID (or, for target, staying empty; for the REQUIRED source,
    being zeroed and then failing the required check — so a numeric source is a
    reject-to-accept flip), and a composite value (a list/map) is now rejected
    with a typed wrong_type error instead of being silently ignored. Pinned
    per-param across YAML and msgpack by the numeric-name-*, composite-name-*,
    numeric-target-*, composite-target-*, and numeric-source-* contract
    fixtures across all five modules. (A MISSING source stays parity — both
    legacy and decoded reject — pinned by source-missing-rejected.)

    Behavioral difference (BD-2, APPROVED 2026-07-12). A CLI
    <bool-param>=<truthy/falsy string> is now honored on every boolean
    parameter across the five modules (file.touch's/file.copy's/
    file.symlink's makedirs, file.copy's force/preserve,
    file.symlink's force) instead of being silently dropped by the legacy
    .(bool) assertion. Pinned by the *-cli-truthy-string contract fixtures.

    Behavioral difference (BD-7, APPROVED 2026-07-12). Each module's
    boolean parameters accept the INTEGERS 1 and 0 (1 = true, 0 = false, across
    every signed/unsigned integer kind, so a msgpack-delivered bool — which
    arrives as a sized kind such as int8 — is honored) and reject any other
    integer with a typed value_invalid error, where the legacy .(bool)
    assertion silently dropped an int entirely. EVERY boolean parameter's integer
    arm is pinned explicitly (per the §11 per-param pinning standard, not by a
    representative): file.touch's makedirs, file.copy's
    force/preserve/makedirs, and file.symlink's force/makedirs, each
    across YAML and msgpack by the <param>-int-one-*/<param>-int-zero-*/
    <param>-int-invalid-* contract fixtures.

    Behavioral difference (BD-5, approved 2026-07-13 under the maintainer standing proceed-without-sign-off grant). file.append's
    text list element that is a scalar (for example text: [line1, 2]) is now
    rendered to its string form ("2") instead of being silently DROPPED by the
    legacy parseAnyStringList (which — stricter than user.present's
    groups, which at least kept every already-string element — only ever
    appended an element that type-asserted directly as a Go string), and a
    NESTED element (a list or map inside the list) is now rejected with a typed
    value_invalid error instead of being silently dropped. A plain list of
    strings is unchanged. Additionally, a BARE-STRING value (for example text: someline, or a CLI text=someline) now decodes as a single-element list
    and ACTIVATES line management, where the legacy config["text"].([]any)
    type assertion failed entirely on a non-list value (so text: someline
    silently managed NO lines); this is also what makes the CLI scalar spelling
    work. Pinned by the text-scalar-sprint-*, text-nested-rejected-*, and
    text-bare-string-* contract fixtures (the scalar/nested-element arms have
    no CLI spelling for a mixed/nested list; the bare-string arm is pinned
    across all three universes). Presented for sign-off in this PR (keystone
    spec §11).

  • file.managed migrated to the self-documenting module-schema framework
    (the BD-1 flagship).
    Its parameter declaration now decodes through a single
    compiled schema (modschema.Spec) plus registered documentation metadata,
    replacing the hand-written config[...].(type) extractions. Two parameters
    move onto named semantic types: template is a paramtypes.TemplateFlag (a
    bool, the string "jinja", or any truthy/falsy string) and mode is a
    paramtypes.FileMode declared lazy,default=0644 — the mode is never
    materialized into the struct; the module applies the documented 0644 default
    at use time via Mode.Resolve(0644), and FileMode honors an octal string OR
    an octal integer of any kind. name/path (with path as the primary's
    alias), content, source, user, group are plain strings, makedirs a
    plain bool, and context/defaults are map[string]any passthroughs. The
    Check/Apply/Revert code paths are unchanged except for reading the two typed
    fields (Mode.Resolve, Template.Enabled). Behavior is unchanged for every
    realistic input — including the compiled decoder's source resolution, which
    now falls THROUGH an empty-string source exactly like an absent one (keystone
    spec §2.1/§2.2 amendment): an empty name alongside a non-empty path alias
    resolves to the path, never the state ID, matching the legacy
    if name == "" { name = path } chain (pinned by the name-empty-path-wins
    contract fixtures; the framework fix and its unit tests live in
    pkg/modschema). The module moved to its own file (file_managed.go; the
    shared file-module helpers — resolveOwnerIDs, checkOwnershipDrift,
    modeConfigToString, hashBytes — stay in file.go) per the per-module
    file-naming convention the docgen Source: line relies on. Its reference page
    (file-managed.mdx) is now generated from the registered schema +
    documentation metadata rather than hand-maintained: all prior content is
    preserved (the full parameter table, the Check/Apply/Revert behavior, the
    inline/source/template examples INCLUDING the "Create directories on demand"
    example, the worked nginx.conf.jinja source-template render walkthrough, the
    explicit template-namespace list — facts.*/settings.*/context/defaults
    with their precedence, restored as its own Note section — and the opt-in /
    double-render template caveat) and reorganized under the shared
    Source/Parameters/Parameter-Types/Effects/Examples/Notes/Divergences/See-Also
    anatomy, drift-corrected against the live code: the hand page claimed a
    parse-time content/source mutual-exclusion that does not exist (source simply
    wins when both are set) and omitted the ownership-drift Check facet, the
    chown-before-chmod ordering, and the pre-existing-file mode enforcement — all
    now documented honestly. file.managed is the first module to contribute the
    TemplateFlag and FileMode semantic-type $defs to the combined JSON
    Schema artifact. The permanent differential contract at
    pkg/state/modules/testdata/contract/file.managed.yaml guards the decode
    behavior across all three universes (YAML, CLI, msgpack); the differential
    harness (pkg/modschema/schematest) gained TemplateFlag and FileMode
    matchers (as it gained a StringList matcher for user.present). The
    doc-coverage ratchet shrinks by 1 (40 → 39 unmigrated modules).

    Behavioral difference (BD-1, approved 2026-07-13 under the maintainer standing proceed-without-sign-off grant). A mode given as
    an octal INTEGER and delivered over msgpack is now applied. msgpack v5 encodes
    a small integer into the smallest kind by magnitude (0755 → the octal int
    493 → uint16), and the legacy modeConfigToString switch handled only
    int/int64/float64 — so a reactor-dispatched file.managed with
    mode: 0755 fell through to the empty string and silently applied the 0644
    default (the exact reproduced 0755 → 0644 bug that motivated this whole
    effort). paramtypes.FileMode interprets the octal value from every integer
    kind, so the requested mode now survives a reactor dispatch; the setuid/
    setgid/sticky special bits survive the same path (0o4755uint16). Pinned
    by the mode-0755-msgpack and mode-setuid-msgpack contract fixtures.
    Presented for sign-off in this PR (keystone spec §11).

    Behavioral difference (BD-2, APPROVED 2026-07-12). A CLI template=<truthy string> now enables Jinja rendering, where the legacy v == "jinja" check
    ignored every other string (so zester '*' file.managed … template=true was
    silently a no-op), and a CLI makedirs=true string now applies where the
    legacy .(bool) assertion dropped it. The SAME rule honors a YAML-quoted
    template: "yes" (a string, not a native bool). Pinned by the
    template-cli-truthy-string and makedirs-cli-truthy-string contract
    fixtures.

    Behavioral difference (BD-6, APPROVED 2026-07-12). A wrong-typed value is
    now handled deterministically instead of a silent zero/fallback: a non-string
    name/content is coerced to its string form (was a silent fallback to the
    state ID / the empty string), and a composite value (a list/map) — into
    name, or into mode (where the legacy modeConfigToString returned "" and
    the module silently applied 0644) — is rejected with a typed error.
    Relatedly, mode VALIDATION now happens at DECODE time rather than at apply
    time: a float mode (for example mode: 493.0, which the legacy
    modeConfigToString %04o-converted to 0755) and an invalid-octal string
    mode (for example mode: "banana", which the legacy path carried through and
    only rejected later at apply) are both rejected up front by paramtypes.FileMode;
    and a template value that TemplateFlag does not accept — it takes only a
    bool, "jinja", or a truthy/falsy string — is now rejected with a typed error,
    whether it is a bare integer (template: 1) or an arbitrary string such
    as template: mako (the legacy v == "jinja" string check silently left
    rendering off for everything but "jinja", so mako was a no-op). The string
    case is pinned across all three universes because a string survives every
    ingress unchanged (the CLI delivers mako too, unlike the integer case where it
    delivers a truthy "1"). Pinned by the numeric-name-*,
    numeric-content-coerced-*, composite-name-rejected-*,
    composite-mode-rejected-*, float-mode-rejected-*,
    invalid-octal-string-mode-rejected-*, template-int-rejected-*, and
    template-invalid-string-rejected-* contract fixtures.

    Behavioral difference (BD-7, APPROVED 2026-07-12). The boolean-typed
    makedirs now accepts the integers 1 and 0 (1 → true, 0 → false) and
    rejects any other integer with a typed error, per the approved §2.3 coercion
    table and the §11 SCOPE ruling that BD-7 covers ALL boolean-typed parameters.
    The legacy makedirs, _ = config["makedirs"].(bool) assertion dropped an
    integer entirely (a silent false). This is documented on the generated page
    (the makedirs row) and pinned by the makedirs-int-one-*,
    makedirs-int-zero-*, and makedirs-int-invalid-* contract fixtures across
    the YAML and msgpack universes (the CLI delivers a string, covered by BD-2).

  • user.present migrated to the self-documenting module-schema framework
    (the semantic-type-heavy surface).
    Its 14-parameter, previously
    zero-doc-comment declaration now decodes through a single compiled schema
    (modschema.Spec) plus registered documentation metadata, replacing the
    hand-written config[...].(type) extractions. Three parameters move onto
    named semantic types: gid is a paramtypes.GroupRef (a numeric GID or a
    group name), and groups / optional_groups are paramtypes.StringList.
    password is now marked sensitive: its value is redacted across the
    entire decode-error chain and never rendered into docs, defaults, or
    examples (verified by a redaction unit test). The gid/primary_group
    precedence is reproduced exactly in the builder's resolveGroupFacets (a
    name-form gid wins over primary_group; a numeric gid sets the GID).
    Behavior is unchanged for every realistic YAML input (string/int params, a
    name-form gid, a list of groups); user.absent stays on its legacy
    constructor (a later wave). The module moved to its own file
    (user_present.go, user.absent staying in user.go) per the per-module
    file-naming convention the docgen Source: line relies on, and its
    reference page (user-present.mdx) is now generated from the registered
    schema + documentation metadata — drift-corrected against the live
    Check/Apply/Revert: the hand page claimed password was "always applied on
    modify (not compared)" when the code actually converges by comparing the
    shadow hash, and described a string gid as always a group name (see BD-4).
    All prior page content is preserved (the full parameter table, the
    optional_groups/remove_groups semantics, the Check ordering, the example
    set) and reorganized under the shared
    Source/Parameters/Effects/Examples/Notes/Divergences/See Also anatomy. The
    permanent differential contract at
    pkg/state/modules/testdata/contract/user.present.yaml guards the decode
    behavior across all three universes (YAML, CLI, msgpack); the doc-coverage
    ratchet shrinks by 1 (41 → 40 unmigrated modules). paramtypes.StringList
    is the first list-valued semantic type to reach a module contract, so the
    differential harness (pkg/modschema/schematest) gained a StringList
    matcher alongside the existing TriState one.

    Behavioral difference (BD-6, APPROVED 2026-07-12). As with the earlier
    migrations, a wrong-typed value is now handled deterministically instead of
    a silent zero/fallback: a non-string name is coerced to its string form
    (was a silent fallback to the state ID), and a composite value (a list/map)
    into any scalar parameter — name, uid, or the sensitive password — is
    rejected with a typed error (was silently ignored). Pinned by the
    numeric-name-*, composite-name-*, uid-composite-rejected-*, and
    password-composite-rejected-* contract fixtures (the sensitive password's
    error value is redacted).

    Behavioral difference (BD-2, APPROVED 2026-07-12; origin-independent string
    coercion per the approved §2.3 table).
    A string-form value for a typed
    parameter is now coerced no matter which universe delivered it, where the
    legacy .(int)/.(bool) assertions silently dropped it. The CLI delivers
    every value as a string, so zester '*' user.present deploy uid=1500 now sets
    uid (the CLI "1500", coerced base-10) and a CLI
    createhome=true/system=true bool-string is applied; the SAME rule coerces a
    YAML-quoted createhome: "yes" (a string, not a native bool). Pinned by the
    uid-cli, createhome-true-cli, and createhome-truthy-string-yaml contract
    fixtures.

    Behavioral difference (BD-7, APPROVED 2026-07-12; scope extended to all
    boolean-typed parameters).
    An INTEGER given to a boolean parameter
    (createhome, system, remove_groups) is now coerced narrowly — 1 is
    true and 0 is false, across every signed/unsigned integer kind (so a
    msgpack-delivered bool, which arrives as a sized kind such as int8, is
    honored rather than dropped by the legacy .(bool) assertion) — and ANY
    other integer (for example 2) is rejected with a typed value_invalid
    error. Pinned across all three universes (YAML, CLI, msgpack) by the
    createhome-int-one-*, createhome-int-zero-*, and createhome-invalid-int-*
    contract fixtures. (This is the same rule already documented on the
    TriState type; here it applies to user.present's primitive bool
    parameters.)

    Behavioral difference (BD-1, approved 2026-07-13 under the maintainer standing proceed-without-sign-off grant). A msgpack-
    delivered uid or numeric gid is now honored. msgpack v5 encodes an
    integer into the smallest kind by magnitude (1500 → uint16, 1 → int8),
    none of which the legacy config["uid"].(int) / config["gid"].(int)
    assertions matched — so a reactor-dispatched user.present with uid: 1500
    silently left uid at 0 (the exact reproduced 0755 → 0644 bug class).
    The uniform compiled decoder honors every integer kind, so the value now
    survives a reactor dispatch. Pinned by the uid-msgpack and gid-int-msgpack
    contract fixtures. Presented for sign-off in this PR (keystone spec §11).

    Behavioral difference (BD-4, approved 2026-07-13 under the maintainer standing proceed-without-sign-off grant). An all-digit
    string gid (for example gid: "1000", or a CLI gid=1000, or a native int
    arriving as the string "1500" over the CLI) is now resolved as a numeric
    GID, matching how the OS treats a numeric group and how the integer form
    already behaved. The legacy string branch treated ANY string gid — digits
    included — as a group NAME and stored it into primary_group, so gid: "1000" mis-configured a group named "1000" instead of GID 1000. A non-digit
    string gid is still a group name (unchanged). Additionally, a NEGATIVE gid
    (an integer gid: -5) is now rejected up front with a typed value_invalid
    error, where the legacy path forwarded it and only failed later at the group
    provider (a non-digit string such as "-5" over the CLI remains a group name,
    so this arm is YAML/msgpack). Pinned by the gid-alldigit-string-* and
    gid-negative-rejected-* contract fixtures. Presented for sign-off in this
    PR
    (keystone spec §11).

    Behavioral difference (BD-5, approved 2026-07-13 under the maintainer standing proceed-without-sign-off grant). A groups /
    optional_groups list element that is a scalar (for example groups: [docker, 1000]) is now rendered to its string form ("1000") instead of being
    silently dropped by the legacy parseAnyStringList, and a NESTED element (a
    list or map inside the list) is now rejected with a typed value_invalid
    error instead of being silently dropped. A plain list of strings is unchanged.
    Additionally, a BARE-STRING value (for example groups: docker, or a CLI
    groups=docker) now decodes as a single-element list and ACTIVATES group
    management, where the legacy parseAnyStringList ignored any non-list value
    entirely (so groups: docker silently managed no groups); this is also what
    makes the CLI scalar spelling work. Pinned by the groups-scalar-sprint-*,
    groups-nested-rejected-*, and groups-bare-string-* contract fixtures.
    Presented for sign-off in this PR (keystone spec §11).

  • pkg.installed, pkg.latest, and pkg.purged migrated to the
    self-documenting module-schema framework
    (all-primitives wave — no
    semantic types needed; name/version/refresh are all plain
    string/bool parameters). Each constructor now decodes through a single
    compiled schema (modschema.Spec) plus registered documentation metadata,
    instead of hand-written config[...].(type) extractions. pkg.latest's
    refresh (which defaults to true, Salt-parity) is now an EAGER compiled
    default (default=true) rather than a construction-time if ok override,
    with the same observable default. pkg.installed's file moved to
    pkg_installed.go (from pkg.go) to match the per-module file-naming
    convention the other migrated modules and the docgen Source: line both
    rely on. Behavior is unchanged for every realistic input (a string
    name/version, a native bool refresh, or none of the above — falling
    back to the state ID / the field's default). Their reference pages
    (pkg-installed.mdx, pkg-latest.mdx, pkg-purged.mdx) are now generated
    from the registered schema + documentation metadata rather than
    hand-maintained — all prior content is preserved (the apt/dnf/yum/brew
    per-manager command tables, the version-pinning format table, the dpkg
    rc-state notes, the yum-downgrade and apt --allow-downgrades details,
    the Salt divergence notes) and reorganized under the shared
    Source/Parameters/Effects/Examples/Notes/Divergences/See Also page anatomy,
    corrected against the live provider implementations
    (pkg/exec/pkg_apt.go, pkg_dnf.go, pkg_yum.go, pkg_brew.go) where the
    hand pages had drifted (missing the apt noninteractive/conffile flags and
    the yum-downgrade fallback entirely). The permanent differential contracts
    at pkg/state/modules/testdata/contract/pkg.installed.yaml,
    pkg.latest.yaml, and pkg.purged.yaml guard the decode behavior; the
    doc-coverage ratchet shrinks by 3 (44 → 41 unmigrated modules).

    Behavioral difference (BD-6, APPROVED 2026-07-12). As with
    pkg.removed, each module's name parameter (and pkg.installed's
    version) is now a compiled primary/plain string: a non-string value
    (for example name: 123 or version: 124 in YAML/msgpack) is coerced to
    its string form instead of silently falling back to the state ID / the
    empty string, and a composite value (a list/map) is now rejected with a
    typed wrong_type error instead of being silently ignored. This activates
    the §11 BD-6 class for pkg.installed's name/version and for
    pkg.latest's and pkg.purged's name; pinned by the numeric-name-*,
    numeric-version-*, composite-name-*, and composite-version-* contract
    fixtures.

    Behavioral difference (BD-2, APPROVED 2026-07-12). A CLI
    refresh=<truthy/falsy string> is now honored instead of silently dropped
    or ignored. The CLI delivers every value as a string, so the legacy
    config["refresh"].(bool) assertion always failed: on pkg.installed,
    zester '*' pkg.installed nginx refresh=true left refresh at its false
    zero value; on pkg.latest, zester '*' pkg.latest nginx refresh=false
    left refresh at its eager true default regardless of the operator's
    intent (the construction-time if r, ok := config["refresh"].(bool); ok
    override never fired against a CLI string). Under the compiled decoder the
    string is coerced (the same true/yes/1/on ∥ false/no/0/off set the
    framework uses), so the operator's intent is now applied on both modules.
    This activates the §11 BD-2 class for pkg.installed's and pkg.latest's
    refresh; pinned by the cli-refresh-* contract fixtures.

    Behavioral difference (BD-7, APPROVED conditionally 2026-07-12; scope
    amended 2026-07-12 to cover every boolean-typed parameter, primitive
    bool included, not only TriState).
    pkg.installed's and
    pkg.latest's refresh — a plain bool, not TriState — given as an
    INTEGER is now coerced explicitly: 1 is true and 0 is false, across
    every signed/unsigned integer kind, so a msgpack-delivered refresh
    (which arrives as a sized kind such as int8) is honored instead of being
    silently ignored by the legacy .(bool) assertion (on pkg.latest, that
    silent drop left refresh at its eager true default regardless of an
    integer override). ANY other integer (for example 2) is rejected with a
    typed value_invalid error rather than being dropped. Pinned across YAML
    and msgpack by the refresh-int-one-* / refresh-int-zero-* /
    refresh-invalid-int-rejected-* contract fixtures on both modules (the
    CLI leg is already covered by the cli-refresh-* BD-2 fixtures above — the
    CLI never delivers a raw integer). This activates the §11 BD-7 class,
    whose scope the orchestrator ruled (2026-07-12) extends to ALL
    boolean-typed parameters under the maintainer's existing TriState 1/0
    approval and coercion table, not only TriState fields.

  • The peel now warns on an unknown state-module parameter (keystone spec
    §5, Phase-1 activation). A migrated (schema-carrying) module given a config
    key that matches no parameter and no reserved key — a typo like nmae: for
    name: — logs a Warn line through the peel's logger
    (modschema: <module>: unknown parameter "<key>") and the state STILL
    builds and applies. The check is warn-only, never fatal: a stray key can no
    longer be silently absorbed, but it also can't break an apply. The
    fleet-wide reserved keys (requisites, generic attributes, compiler
    directives) and the exec-layer test=True dry-run flag are known control
    keys and never warned. Only migrated modules participate today; the warning
    surface grows as more modules gain schemas.

  • service.running and service.dead migrated to the self-documenting
    module-schema framework (semantic-type pilot).
    Their enable parameter is
    now a named semantic type, paramtypes.TriState — a three-valued
    unset/true/false whose "declared" bit replaces the ad-hoc Enable bool + hasEnable bool pair. Behavior is unchanged for every realistic YAML/msgpack
    input: an omitted enable still leaves boot enablement untouched, enable: true enables (and, on service.running, converges an enable-only drift
    WITHOUT restarting a healthy service), and enable: false disables (the
    inverted default that service.dead keys off). Both modules moved to their
    own files (service_running.go, service_dead.go) per the per-module
    file-naming convention, and their reference pages
    (service-running.mdx, service-dead.mdx) are now generated from the
    registered schema + documentation metadata — drift-corrected against the
    live tri-state Check/Apply/Revert behavior. The permanent differential
    contracts at pkg/state/modules/testdata/contract/service.running.yaml and
    service.dead.yaml guard the decode behavior.

    Behavioral difference (BD-2, APPROVED 2026-07-12). A CLI
    enable=<truthy/falsy string> is now honored instead of silently dropped.
    The CLI delivers every value as a string, so the legacy
    config["enable"].(bool) assertion failed and left enable UNDECLARED —
    zester '*' service.running nginx enable=true did not manage boot
    enablement at all, and zester '*' service.dead nginx enable=false did not
    disable the unit. Under TriState the string is coerced (the same
    true/yes/1/on ∥ false/no/0/off set the framework uses), so the operator's
    intent is now applied. This activates the §11 BD-2 class for both modules'
    enable; pinned by the enable-*-cli contract fixtures.

    Behavioral difference (BD-6, APPROVED 2026-07-12). As with
    pkg.removed, the name parameter is now a compiled primary string: a
    non-string name (for example name: 123 in YAML/msgpack) is coerced to
    its string form instead of silently falling back to the state ID, and a
    composite name (a list/map) is now rejected with a typed error instead
    of being silently ignored. This activates the §11 BD-6 class for both
    modules; pinned by the numeric-name-* and composite-name-* contract
    fixtures. (A wrong-typed enable integer belongs to BD-7 below.)

    Behavioral difference (BD-7, APPROVED 2026-07-12). A service.running /
    service.dead enable given as an INTEGER is now coerced explicitly: 1 is
    declared-true and 0 is declared-false — across every signed/unsigned integer
    kind, so a msgpack-delivered enable (which arrives as a sized kind such as
    int8) is honored rather than silently ignored by the legacy .(bool)
    assertion. ANY other integer (for example 2) is rejected with a typed
    value_invalid error rather than being dropped. The rule is documented on the
    TriState semantic type — its Doc() (and thus the generated Parameter Types
    section) states the 1/0 semantics explicitly, and its JSON Schema constrains the
    integer form to enum: [0, 1] — and pinned across all three universes (YAML,
    CLI, msgpack) at both the type level (the TriState int-one / int-zero /
    reject-two type fixtures) and the module level (the enable-int-one-* /
    enable-int-zero-* contract fixtures for service.running and
    service.dead). Approved conditionally by the maintainer on 2026-07-12
    (keystone spec §11).

  • pkg.removed's reference page is now generated
    (website/content/docs/guides/modules/pkg-removed.mdx), from its
    registered schema and documentation metadata rather than hand-maintained.
    All prior content is preserved (the apt/dnf/yum/brew package-manager list,
    the dpkg rc-state convergence note) and reorganized under the new
    Source/Parameters/Effects/Examples/Notes/Divergences page anatomy shared by
    every future self-documenting module page.

    This tranche (documentation infrastructure only) activates no new
    behavioral differences itself — it introduces no Decode/coercion changes.
    Sign-off status (keystone spec §11, 2026-07-12): BD-2, BD-6, and BD-7 are
    APPROVED
    ; BD-1/BD-3/BD-4/BD-5 remain pending and are presented for
    sign-off in the Phase-1 PRs that activate them.

  • pkg.removed migrated to the self-documenting module-schema framework
    (pilot #1).
    Its constructor now decodes through a single compiled schema
    (modschema.Spec) plus registered documentation metadata, instead of a
    hand-written config["name"].(string) extraction. Behavior is unchanged for
    every realistic input (a string name, or none — falling back to the state
    ID). The permanent differential contract at
    pkg/state/modules/testdata/contract/pkg.removed.yaml guards this.

    Behavioral difference (BD-6, APPROVED 2026-07-12). A non-string
    name value is no longer silently dropped in favor of the state ID. Under the
    uniform coercion framework a numeric name (for example name: 123 in YAML or
    over msgpack) is now coerced to its string form ("123"), and a composite
    name (a list/map) is now rejected with a typed wrong_type error instead of
    falling back to the ID. This activates the §11 BD-6 class ("wrong-typed values
    are handled deterministically instead of a silent zero") for pkg.removed's
    name parameter; it is pinned by the contract fixtures (approved 2026-07-12).

Fixed

  • Starlark modules survive a states-directory switch (review round 5). When
    the peel switches its states dir (baked tree → KV cache, or a lazy engine
    rebuild) it replaces the Starlark loader but keeps the registry; the fresh
    loader's empty ownership ledger then treated every previously loaded Starlark
    name as non-Starlark and shadow-refused all reloads — freezing custom modules
    (no hot-reload, no removal, no override) until restart. The old loader now
    purges its registrations first (starmod.Loader.UnloadAll; built-ins are
    untouched), and the new tree re-registers cleanly via LoadGlobal/LoadDir.

  • zester '<target>' cmd.run cmd=<command> now works end to end. The CLI
    parsed cmd= as key=value form but dispatch routes cmd.run through the STATE
    module, whose strict schema only accepted command/name — so the accepted
    spelling was then rejected as an unknown parameter. The state schema now
    accepts cmd as a second alias (the execution-module spelling); source
    resolution order is command > name > cmd, pinned by contract fixtures
    across YAML/CLI/msgpack and a Docker salt-compat test. The same class was
    closed for the working directory: cwd gains the dir alias (the
    execution-module spelling salt['cmd.run'](dir=...)), so zester '<target>' cmd.run 'make' dir=/opt/src no longer fails under strict params.

  • key=value first tokens now parse as key=value for EVERY self-documenting
    module on the CLI, not just cmd.run.
    zester '*' pkg.installed name=nginx
    previously bound the literal string "name=nginx" as the package name (the
    round-4 fix covered only the bespoke cmd.run arm). A first token assigning to
    one of the module's DECLARED parameter keys (canonical name or alias) now
    switches the whole invocation to key=value form — file.managed path=/etc/motd contents=hi works, and a default-less primary missing from the assignments is
    a usage error. Assignments to undeclared keys stay positional values, so
    exotic positional values containing = keep working.

  • zester '<target>' pillar.get <key> (and pillar.items/pillar.keys) now
    work from the CLI.
    pillar.* is the peel's Salt-compat alias of settings.,
    answered by a handler that reads only args["key"] — but the CLI had no
    pillar.
    argument arms, so the key fell into the request ID and every keyed
    invocation errored requires a key argument. The pillar.* spellings now bind
    identically to their settings.* counterparts.

  • Documentation served from long-lived caches is now cloned on egress.
    moduledoc.Lookup/All (the embedded offline docs) and
    modules.DispatchInfo (the dispatch-specials table) handed out ModuleInfo
    values whose doc slices and schema fragments aliased process-wide state,
    violating the returned-views-are-safe-to-vandalize contract the rest of the
    framework pins; new exported Doc.Clone/ModuleInfo.Clone seal them.

  • A .star file that fails to load is retried on the next load pass. The
    loader recorded the file's mtime before executing it, so a failed load was
    skipped until the mtime changed; after a states-directory switch (old
    registrations purged) that left the module missing — not stale-but-callable —
    until a republish or restart. The mtime record now rolls back on failure.

  • modschema input boundary sealed (review round 5). Compile/NewSpec
    now detach the caller's Doc (a registrant retaining and later mutating its
    doc slices could taint rendered docs), and Spec.Params is a deep-copied
    snapshot instead of an alias of the compiled plan's internal schema — the
    round-4 fix had sealed the output side (Schema()/Info()) only.

  • zester '<target>' cmd.run name=<cmd> no longer executes the literal
    assignment string.
    The CLI treated a leading name=/command=/cmd=
    token as the positional command, so the Salt-parity form ran e.g.
    name=echo hi (exit 127). A leading explicit command-key assignment now
    parses as key=value form; a positional command merely containing =
    (env-prefix style, FOO=bar env) still runs verbatim.

  • JSON Schema artifact tightened to match the runtime decoder exactly
    (review findings): FileMode string values now carry the octal pattern
    ("999"/"banana" are schema-rejected, matching decode); float strings
    follow the exact strconv.ParseFloat grammar (underscores only between
    digits, hex floats incl. 0x_1p2/0x.8p1; 1__0/10_/0xp1 rejected);
    required-parameter checks reject ""/null stand-ins across array-of-maps
    items. Known, documented limitation: when the same parameter key appears in
    multiple list items the runtime merges last-occurrence-wins, which JSON
    Schema cannot express — the schema validates items independently and the
    decoder stays authoritative (docgen refuses to GENERATE examples with
    duplicate keys).

  • modschema documentation views are now deep copies. CompiledSchema. Schema() and Spec.Info() returned internally shared maps/slices; a
    consumer mutating a returned view (fields, aliases, JSON-Schema fragments,
    doc slices, semantic-type fragments) could corrupt later docs/schema output
    or race concurrent readers. Both now return fully detached values.