Zester v0.6.0
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 specialsstate.apply/state.highstate/
facts.*/settings.*/pillar.*/event.send);sys.doc <module>renders
that module's documentation through the samemodschema.RenderTextused by
zester docand 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 viasalt['<module>'].sys.docruns on the peel's
read-only fast path, so it answers even during a long highstate; its result
rides the existingExecResponseresult field (no wire change). -
sys.list_functionsnow 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 amodschema.Spec(Kindexec), registered via
Registry.RegisterSpec— sosys.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.versionacceptsname/package/pkg, defaulting to
the request ID) in the SAME precedence order the runtimeargStrhelper
checks them —cmd.runas an execution module (salt['cmd.run'](...),
zester '<target>' cmd.run ...) declarescmdas its canonical primary
(aliasescommand,name, argStr's exact check order), notcommand—
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 bothcmdandcommandare set. (This deliberately
differs from thecmd.runSTATE module, whose primary iscommand— a
different surface with its own precedence, see BD-8 below.) A new combined
reference page,
Execution Modules, documents them all
(generated byzester-docgen;cmd.runstays 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 SAMEmodschema.RenderTexta connected daemon
uses forsys.doc, so the offline CLI answer is byte-identical to the
peel-side one (pinned byTestDocdataMatchesLive). Fully offline — reads only
the embedded docdata, never contacts a master, and works on a peel-only box
with no config. Barezester doclists 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);--jsonemits the structuredModuleInfo. Cobra shell
completion now suggests documented module names for bothzester doc <TAB>
and the exec formzester '<target>' <TAB>(sourced frommoduledoc.All()).
The CLI's streamlined-output set gainssys.docso a peel-sidesys.doc
result prints as plain rendered text. Internally,parseModuleArgsnow 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,
andfile.managed's positional now binds to its canonical primaryname
(decode-identical to the formerpath, 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
.starmodule loader now
captures each module's documentation at load time — the function docstring
(Google-style summary/description +Args:section, via the pinned
go.starlark.netFunction.Doc()), the source location (Function.Position()),
and an optional per-function<fn>_paramsor module-globalPARAMSdict — and
registers amodschema.Specso Starlark modules appear insys.doc/zester docthrough the state Registry'sDescribeexactly like built-in Go modules.
A module is registered withOpenParams(accepts arbitrary keys) unless it
declares aPARAMS/<fn>_paramsdict; 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 andnamereserved. 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.deadcontribute the shared
TriStatesemantic-type$def,file.managedtheTemplateFlagand
FileMode$defs,user.presenttheGroupRefandStringList$defs,
file.appendis the second module to contribute to theStringList$def,
file.keyvaluecontributes theStringMap$def(itskey_valuesand
entriesparameters — the type's first migrated consumer), and
file.directory/file.recurseare furtherFileMode$defcontributors
(file.directory'smodewith adir_modefallback alias;file.recurse's
DECLARED-ONLYdir_modeand lazyfile_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 newcmd/zester-docgentool
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 aDocblock) 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 andzestertag grammar, semantic types, the
drift-correctedDoc, 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.starfile — and every module of a deleted.starfile 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. Newstate.Registry.Unregisterseam backs this;
a conformance test pins that built-ins are never unregistered. -
A
.starmodule 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. apkg.stardefininginstalledcolliding
with the built-inpkg.installed) and logs an error naming the file —
previously the.starsilently 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 astrict_paramsknob
(peel.yamlstrict_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.removedwithnmae:fails withunknown 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: falseto 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-hoczester '<target>' <module> …runs,module.runforwarding, reactordispatch.moduleactions,
and Starlark custom modules that declaredPARAMS— and never false-positives
on reserved directives (require/watch/onlyif/order/…), the exec-layer
test=Trueflag,module.run'snameselector, or the Salt universal
state-identifier idiom — an explicitname:on a module that declares no
nameparameter of its own (test.nop: - name: anchoris valid Salt even
thoughtest.nophas nonamefield) is excused fleet-wide, not just for
module.run. A module that DOES declare its ownnameparameter or alias
(e.g.file.managed) still decodes an explicitname:into that field as
normal — the reserved-key excuse only ever applies to a key no declared
parameter claimed. The compiler'snames: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 iscommand, not a barename), or the
historical literalnamekey otherwise (module.run, legacy/Starlark
modules without a declared schema, and parameter-less modules liketest.*
— 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 explicitcommand:(or itsname: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.runnow RUNS thename:command (Salt parity — BD-8).cmd.run's
primarycommandparameter gains thenamealias, so the Salt idiom
cmd.run: - name: apt-get updatenow executesapt-get updateexactly as Salt
does. Previously Zester read onlycommandand silently ran the STATE ID
instead — quietly wrong, and a latent bug inzester-migrate's output (it
passes Salt'sname:through unchanged). This changes what executes for that
idiom, from quietly-wrong to Salt-correct. Precedence is command-beats-name (a
declaredcommandwins overname), and an empty-stringcommandfalls
through tonamebefore the state-ID fallback. Pinned by permanent contract
fixtures across YAML/CLI/msgpack. -
cmd.runandservice.enabledmigrated — 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 anunmigratedAllowlist(it was
deleted) and the coverage conformance test is in its final form —
TestDocCoverage_EveryModuleHasSpecasserts every registered module carries a
Spec with no exemptions, and a Spec-less registration fails the build. The dead
providerBuildlegacy adapter (its last two callers were these modules) was
removed. Highlights:cmd.run:commandis the primary (defaults to the state ID) and now
accepts thenamealias (see the BD-8 entry above — the Salt idiom
cmd.run: - name: <command>runs the named command);
argsis aparamtypes.StringListandenvaparamtypes.StringMap;cwd
andcreatesare plain strings. The require-file-provider-when-createsrule
stays in the builder tail (cross-field module logic, not schema). Check/Apply/
Revert — thecreatesguard gating both phases, the shell-vs-direct execution
split onargs, the capturedcommand/stdout/stderr/exitcodedetails,
and the non-revertible Revert — are unchanged.cmd.go→cmd_run.go.service.enabled:nameis 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.go→service_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, thecreatesidempotency
mechanism, the shell-vs-directsh -cbehavior, the returned-details table now
carried as a Note,envmerged with the process environment, every example,
andcmd.run's dual-surface note that it is also an execution module reachable
from templates viasalt['cmd.run']) and reorganized under the shared anatomy,
drift-corrected against the live code (notablyservice.enabled's Apply/Revert
no-op semantics, which the hand page omitted). The permanent differential
contracts atpkg/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.docunified 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 fromDoc.Divergences
(BD-IDs only, per convention) into Notes; andguides/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'sargs(aparamtypes.StringList)
andenv(aparamtypes.StringMap) now surface the values the legacy parsers
silently dropped: a scalarargslist element is rendered to its string form
(the legacy element-wise.(string)assertion dropped any non-string element),
a nestedargselement is rejected with a typed error (was dropped), a
bare-stringargsvalue 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 compositeenvvalue is rejected with a typed error where the
legacyfmt.Sprintf("%v", v)sprint'd it into Go syntax (a scalarenvvalue
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 numericcmd.runcommand,
cwd, orcreates, or a numericservice.enabledname, is coerced to its
string form (was a silent drop — for the primarycommand/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 thenumeric-command-coerced-*
/composite-command-rejected-*,numeric-cwd-coerced-*/
composite-cwd-rejected-*,numeric-creates-coerced-*/
composite-creates-rejected-*(cmd.run) andnumeric-name-coerced-*/
composite-name-rejected-*(service.enabled) contract fixtures. -
The final
test.*helpers andmodule.runmigrated to the self-documenting
module-schema framework — the doc-coverage ratchet reaches 2. Every built-in
state module exceptcmd.runandservice.enablednow decodes through a single
compiled schema (modschema.Spec) plus registered documentation metadata,
replacing the hand-writtenconfig[...].(type)extractions. The multi-module
test_extra.gowas 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 existingtest_ping.go,
matching the per-module file convention so each generated Source line
resolves. TheRegistration.BuildPlainshape 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.nopdeclare 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_changescarry a single
commentstring (no default; the fail helper substitutes its built-in
failure without changesmessage at apply time).test.configurable_test_statecarries eagerdefault=trueresultand
changesbools plus acommentstring; Check/Apply are unchanged.module.runis an OpenParams passthrough: it declares no fixed
parameters (its schema has zero fields, markedOpenParamsso unknown-key
validation is skipped and it is documented as accepting arbitrary parameters
forwarded to the target module). Its dynamicname:/ dotted-<module.func>:
target resolution and the reserved-key filter (state.ReservedKeySet()plus
its own localname) 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/onchangeschain snippets now carried as Notes, the unimplemented
Salttest.show_notification/test.mod_watchnote, 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-changesmodule.run) and
reorganized under the shared anatomy, drift-corrected against the live code. The
permanent differential contracts atpkg/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 — onlycmd.runand
service.enabledremain, 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
CLIkey=valueand a YAML-quoted value alike):test.configurable_test_state's
result/changesgiven as a truthy/falsy string ("true","yes","on",
"false","no","off") is honored where the legacy assertion silently kept
the defaulttrue. 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 numericcommenton
test.fail_without_changes,test.succeed_with_changes, or
test.configurable_test_stateis coerced to its string form (was a silent drop
to""), and a compositecomment(a list/map) is rejected with a typed
error.test.configurable_test_state'sresult/changesbools 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 thecomment-numeric-coerced-*/
comment-composite-rejected-*and{result,changes}-float-rejected-yaml/
-composite-rejected-yamlcontract fixtures.Behavioral difference (BD-7, APPROVED 2026-07-12). The
resultand
changesbools oftest.configurable_test_statenow accept the integers1
and0(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 defaulttrue).
Pinned by the{result,changes}-int-{one,zero}-{yaml,msgpack}and
-invalid-int-{yaml,msgpack}contract fixtures. -
pkgrepo.managedanduser.absentmigrated 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-writtenconfig[...].(type)extractions. The single-module
pkgrepo.gowas renamed topkgrepo_managed.goandUserAbsentwas split out
ofuser.gointouser_absent.go(leavinguser.goas the shared slice
helperscontainsString/stringSliceEqualused across the user/group/host
modules), matching the per-module file convention. Highlights:pkgrepo.managedis 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.nameis the primary
(default state ID);humannameis a lazy DERIVED default — the decoder
never materializes it and the builder tail assigns it fromname, reproducing
the legacyif HumanName == "" { HumanName = RepoName };baseurl/ppa/
file/key_urlare plain strings;enabled/gpgcheck/refreshcarry an
eagerdefault=true. Nothing issensitive(a signing-KEY URL points at a
PUBLIC key — a per-module sensitivity pass).user.absentis an all-primitives migration:nameprimary (default state
ID);purge/forceplain bools. Nothing issensitive.forceremains
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/.repofile-format blocks, and pkgrepo's
Divergences-from-Salt facts:baseurlholding the fulldebline, the
deprecatedapt-key add, the unsupporteddisabled/mirrorlist/
gpgautoimport/comps/architecturesparameters, 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}.yamlguard
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
CLIkey=valueand a YAML-quoted value alike): apkgrepo.managed
enabled/gpgcheck/refreshor auser.absentpurge/forcegiven as a
truthy/falsy string ("false","no","yes","on") is honored (the three
pkgrepo bools were silently dropped to their defaulttrue, the two user.absent
bools tofalse). 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 primarynameparameter is now a compiled plain
string: a non-stringname(for examplename: 123) is coerced to its string
form (was a silent fallback to the state ID), and a compositename(a
list/map) is rejected with a typed error. The same acceptance/rejection class
coverspkgrepo.managed's remaining string params — a numeric
humanname/baseurl/ppa/file/key_urlis 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/refreshbools ofpkgrepo.managedand thepurge/force
bools ofuser.absentnow accept the integers1and0(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 defaulttrue/ 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, andtimezone.systemmigrated 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-writtenconfig[...].(type)
extractions. The legacygit.gowas split intogit_cloned.go(the
GitClonedmodule) plusgit.gokept as the shared rev-comparison helpers
(isHexRevPrefix/isFullHexSHA/resolveRevCommit/revAtHead) used by both
git.clonedandgit.latest; the single-modulemount.go-style renames
(locale.go→locale_present.go,timezone.go→timezone_system.go,
pip.go→pip_installed.go,archive.go→archive_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'sname(defaulting to the state ID) is the clone PATH;
git.latest'sname(defaulting to the state ID) is instead the remote
URL, with the clone path in its owntargetparameter. 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) andtarget(git.latest) arerequired;depth
(git.cloned) is a plain int;force(both) is a plain bool.
git.latest'snameisprimary, notrequired— it legitimately
falls back to the state ID — but the builder tail restores the legacy
git.latest: <id>: url (name) is requirederror for the case where
BOTH are empty (parity restoration, not a BD; pinned by the
TestGitLatestMissingURLunit test, following the same builder-tail-
logic-is-unit-tested-not-contract-tested convention asssh_auth's
user-or-config rule andsysctl.present's persist-needs-file-provider
rule — the equivalentgit.clonedgap doesn't exist because its
primary isname/path andurlis independentlyrequired). Their
Doc's Check/Revert prose is drift-corrected: a pinned tag/symbolic
rev converges via a localgit rev-parse --verify <rev>^{commit}
commit-id comparison, not merely a sha-prefix match as the old
git.latesthand page's "Divergences from Salt" section claimed.pip.installed'sbincarries an eagerdefault=pip3, reproducing the
legacy construction-time default.archive.extracted'ssourceisrequired;archive_formatcarries an
eagerdefault=auto;source_hashisTrimSpace'd in the builder tail
(a decoder never trims — same convention asssh_auth.present'sname).
Its Doc is drift-corrected: the hand page claimed Salt'ssource_hash
verification "is not supported", but the module already records a
source_hashmarker 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 unsupportedenforce_toplevel/
options/user/group/clean/trim_outputparameter list, and the
tar/unzip(pluscurlorwgetfor remote sources) binary
requirement — are carried forward verbatim (verified still accurate)
into the new Doc's own "Divergences from Salt" Note, alongside the
correctedsource_hashfact above.locale.presenthas 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.genwhen missing; the module in
fact never creates that file on a system that lacks it.timezone.system'sutcis 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}.yamlguard 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). Agit.cloneddepthgiven 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 sizedint8/uint), so a reactor-dispatched
depth: 1silently fell to0(full clone) — the same reproduced sized-int
class asfile.managed's BD-1. The uniform decoder honors the sized int.
Pinned by thedepth-msgpack-sized-intcontract 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 CLIkey=valueand a YAML-quoted value alike): a
git.cloned/git.latestforce, anarchive.extractedmakedirs, and a
timezone.systemutcgiven as a truthy/falsy string ("true","yes",
"false","no") is now honored (was silently dropped to its default
false); agit.cloneddepthgiven as a numeric string ("1"— the
CLI's ONLY delivery form for an int) is now parsed base-10 (was silently
dropped to0by the legacyconfig["depth"].(int)assertion, which never
matches a string). Pinned by the{force,makedirs,utc}-{truthy,falsy}- string-{cli,yaml}anddepth-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 examplename: 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.clonedurlandgit.latesttarget— ERROR→ACCEPT flips on
REQUIRED parameters (read deliberately). A non-string value for either
(for exampleurl: 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
sourcegets the identical flip.- String coercion / composite rejection on the remaining string params.
A numericbranch/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.clonedFLOATdepth. A finite-integral float (depth: 1.0) is
coerced to the integer, where the legacy.(int)assertion missed a
float64 and leftDepth=0.
The CLI already delivered a numeric primary as a string (PARITY). Pinned by
thenumeric-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 thedepth-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 integers1and0
(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
defaultfalse). 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.absentmigrated 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-writtenconfig[...].(type)
extractions. The multi-modulehost.go/ssh_auth.gowere 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-modulemount.go/sysctl.gowere renamed to
mount_mounted.go/sysctl_present.goto match the per-module file convention.
Highlights of the wave:mount.mountedis 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.deviceis
required;fstype/optscarry eagerdefault=ext4/default=defaults;
dump/passare plain ints;persistis an eagerdefault=truebool.sysctl.present'svalueisrequiredandpersistan eagerdefault=true
bool. The require-file-provider-when-persistrule stays in the builder tail —
cross-field module logic, not schema.host.present/host.absentare the per-field alias exemplar: the
hosts-file path binds theconfigkey with apathalias and an eager
default=/etc/hosts, reproducing the legacyconfig>path>/etc/hosts
precedence (the standalonehostsPathhelper is gone).host.present'sip
isrequired.ssh_auth.present/ssh_auth.absent:enccarries an eagerdefault=ssh-rsa.
Thenameprimary isTrimSpace'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.
hostandssh-authare the first N:1 page groups whose members carry
distinct parameter surfaces (e.g.host.presenthasip,host.absent
does not), sozester-docgennow 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).
Amount.mounteddump/passgiven as an INTEGER and delivered over msgpack is
now applied. The legacyconfig["dump"].(int)/config["pass"].(int)
assertions never matched a msgpack sized kind (msgpack v5 encodes a small int as
a sizedint8/uint), so a reactor-dispatchedpass: 2silently fell to0—
the same reproduced sized-int class asfile.managed's BD-1. The uniform decoder
honors the sized int. Pinned by thedump-int-msgpackandpass-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 CLIkey=valueand a YAML-quoted value alike): amount.mounted
dump/passgiven as a numeric string ("1"/"2") is parsed base-10, and a
mount.mounted/sysctl.presentpersistgiven as a truthy/falsy string
("false","no") is honored (was silently dropped to the defaulttrue).
Pinned by thedump-string-cli,dump-numeric-string-yaml,pass-string-cli,
pass-numeric-string-yaml, andpersist-falsy-string-{cli,yaml}(mount + sysctl)
contract fixtures.Behavioral difference (BD-6, APPROVED 2026-07-12). As with every prior
migration, each module's primarynameparameter is now a compiled plain
string: a non-stringname(for examplename: 123) is coerced to its string
form (was a silent fallback to the state ID), and a compositename(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.mounteddevice,sysctl.presentvalue, andhost.presentip—
ERROR→ACCEPT flips on REQUIRED parameters (read deliberately). A non-string
value for any of these required params (for exampleip: 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
numericfstype/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.mountedFLOATdump/passand compositedump/pass. A
finite-integral float (dump: 1.0) is coerced to the integer, and a composite
dump/passis rejected with a typed error, where the legacy.(int)
assertion left0. (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 canonicalconfigkey and itspath
ALIAS source — thepath-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
throughconfig.
Behavioral difference (BD-7, APPROVED 2026-07-12). The
persistboolean of
BOTHmount.mountedandsysctl.presentnow accepts the integers1and0
(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 defaulttrue). Pinned by the
persist-int-{one,zero,invalid}-{yaml,msgpack}contract fixtures (mount +
sysctl). -
cron.present,cron.absent,group.present, andgroup.absentmigrated
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
parseAnyStringListlist parser). The legacy multi-modulecron.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'scommandisrequired(a missing/empty
commandfails at decode with a typedMissingRequirederror, where legacy
raised its own explicit "command is required" error — both reject, PARITY);
usercarries an eagerdefault=root; andcron.present's five schedule
fields (minute/hour/daymonth/month/dayweek) carry eager
default=*, reproducing the legacy construction-time defaults.group.present'sgidis a plainint— group.present never resolves a
group NAME, so (unlikeuser.present) it is NOT aparamtypes.GroupRefand
there is no BD-4: a negative gid is accepted, not rejected. Its
members/addusers/delusersareparamtypes.StringListandsystemis 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'smembers/addusers/delusersare furtherStringList
$defcontributors to the combined JSON Schema artifact. The permanent
differential contracts at
pkg/state/modules/testdata/contract/cron.{present,absent}.yamland
.../group.{present,absent}.yamlguard 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.presentgidgiven as an INTEGER and delivered over msgpack is now
applied. The legacyconfig["gid"].(int)assertion never matched a msgpack
sized kind (msgpack v5 encodes999as auint16), so a reactor-dispatched
gid: 999silently fell to0(auto-assign / not compared) — the same
reproduced sized-int class asfile.managed's BD-1. The uniform decoder honors
the sized int as a numeric GID. Pinned by thegid-int-msgpackcontract
fixture. Presented for sign-off in this PR (keystone spec §11).Behavioral difference (BD-2, APPROVED 2026-07-12). A
group.present
string-formgidorsystemis 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 (GID0/ systemfalse).
Origin-independent: a YAML-quotedgid: "999"andsystem: "yes"are honored
the same way. Pinned by thegid-string-cli,gid-numeric-string-yaml,
system-truthy-string-cli, andsystem-truthy-string-yamlcontract 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. Acron.presentschedule 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: 5fell 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, sominute: 5decodes 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-clipins 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'smembers/addusers/delusersare nowparamtypes.StringList,
which handles the three arms the legacyparseAnyStringListsilently dropped:
a scalar list element is rendered to a string (members: [alice, 1000]→
[alice, "1000"], where legacy dropped the1000); 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 CLImembers=alice
work). Pinned per-param by themembers-*,addusers-*, anddelusers-*
(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 primarynameparameter is now a compiled plain
string: a non-stringname(for examplename: 123in YAML/msgpack) is
coerced to its string form (was a silent fallback to the state ID), and a
compositename(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.absentnumericcommand— an ERROR→ACCEPT flip on a
REQUIRED parameter (read deliberately). A non-stringcommand(for example
command: 123in 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-stringuseris likewise coerced to its string form instead
of the legacy silent fallback toroot.group.presentcompositegidand FLOATgid. A compositegid(a
list/map) is rejected with a typedWrongTypeerror instead of the legacy
silent0; and a finite-integral FLOATgid(for examplegid: 999.0) is
now coerced to the integer999, where the legacyconfig["gid"].(int)
assertion missed afloat64and leftGIDat0. (BD-2 remains strictly
string-coercion; a non-string wrong-typed value such as a float is BD-6's
class.)cron.presentcomposite schedule value (refiled from BD-3). A composite
schedule value (a list/map forminute/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
thenumeric-name-coerced-*andcomposite-name-rejected-*(all four modules),
command-numeric-accepted-{yaml,msgpack}and
user-numeric-accepted-{yaml,msgpack}(cron.present + cron.absent),
gid-composite-rejected-*andgid-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
systemboolean now accepts the integers1and0(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.directoryandfile.recursemigrated 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-writtenconfig[...].(type)
extractions and the sharedmodeConfigToStringhelper (now removed — its only
two callers were these modules). Both modules move their mode parameters onto
paramtypes.FileMode:file.directory'smodeis aparamtypes.FileModedeclared
lazy,default=0755withdir_modeas a fallback alias — the mode is
resolved at use time viaMode.Resolve(0755), and the legacy
mode-then-dir_mode-then-0755 chain is reproduced exactly by the alias source
precedence (namemodewins over thedir_modealias; an empty-string
modefalls THROUGH todir_mode, never to the state ID — §2.1 source
fall-through, PARITY).makedirsstays 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'sfile_modeis aparamtypes.FileModedeclared
lazy,default=0644(always enforced), anddir_modeis a
paramtypes.FileModedeclaredlazy,default=0755whoseDeclared()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.sourcestays a plain string whose emptiness is a
run-time "source is required" error (legacy parity, NOT a decode-time
required);clean/makedirsare 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 infile.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
makedirsis documented as inert rather than functional;file.recurse's
declared-onlydir_modesemantics are stated in both phases). Both modules are
furtherFileMode$defcontributors to the combined JSON Schema artifact.
The permanent differential contracts at
pkg/state/modules/testdata/contract/file.directory.yamland
.../file.recurse.yamlguard 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'smode(and itsdir_modealias) andfile.recurse's
file_mode/dir_mode. The legacymodeConfigToStringswitch handled only
int/int64/float64, so a reactor-dispatchedmode: 0700(encoded by
msgpack v5 as a sizeduint16) fell through to the empty string and silently
applied the module's default — the same reproduced0755→0644-class bug that
motivatedfile.managed's BD-1.paramtypes.FileModeinterprets the octal
value from every integer kind, so the requested mode now survives a reactor
dispatch; the setuid/setgid/sticky bits survive too. Forfile.recurse's
dir_modethe 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 keepsdir_modedeclared. Pinned by the
mode-0700-msgpack/mode-setgid-msgpack/dir-mode-alias-0700-msgpack
(file.directory) andfile-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 (andfile.recurse'sclean=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 themakedirs-cli-truthy-string
(both modules) andclean-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/sourceis coerced to its string form (was a fallback to the state ID /
the empty string), and a composite value (a list/map) intoname, or into a
mode parameter, is rejected with a typed error. Mode VALIDATION also moves to
DECODE time: a float mode (which the legacymodeConfigToString%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 thenumeric-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-*, andinvalid-octal-string-*-mode-rejected-*
contract fixtures.Behavioral difference (BD-7, APPROVED 2026-07-12). Every boolean-typed
parameter —file.directory'smakedirs, andfile.recurse'scleanAND
makedirs— now accepts the integers1and0(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, andfile.blockreplacemigrated 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-writtenconfig[...].(type)
extractions and the sharedfsxResolvePath/fsxToInthelpers (now removed).
Highlights of the wave:file.line'smodeparameter 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 legacystrings.ToLowernormalization.file.replace'spatternisrequired(a missing/empty pattern fails at
decode with a typedMissingRequirederror, 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;countis a plain int.file.comment/file.uncommentare the N:1 exemplar: ONEFileComment
proto backs TWO registered names, so there are twomodschema.Specs (one
per name), each with its own documentation, compiling the same tagged
parameter surface (name,chardefault#, requiredregex). 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'skey_valuesandentriesare TWO separate
paramtypes.StringMapparameters — NOT a single name-wins alias. The legacy
constructor UNIONED both maps (entrieswinning a per-key collision, since it
merged second), so a name-wins alias would have silently discardedentries
whenever both were supplied; the union merge is reproduced in the module tail
(both maps, then the singlekey/valueinjection last). The single
key/valueconvenience form keeps its legacy module-local injection as a
post-decode merge (keyrequires a present, non-nilvalue);separator
defaults to=. Pinned by thekey-values-and-entries-union-*and
key-values-entries-collision-entries-wins-*contract fixtures (PARITY, not a
BD).file.blockreplacekeeps itsname-only primary (its legacy constructor
never accepted apathalias, so one is deliberately NOT added — parity,
not a new divergence), with eagermarker_start/marker_enddefaults.
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 atpkg/state/modules/testdata/contract/file.{line,replace,comment, uncomment,keyvalue,blockreplace}.yamlguard the decode behavior; the
differential harness (pkg/modschema/schematest) gained aStringMapmatcher
(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 primarynameparameter is now a compiled plain
string: a non-string value (for examplename: 123in 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 typedwrong_type
error instead of being silently ignored. Pinned by thenumeric-name-*and
composite-name-*contract fixtures across all six modules. Additionally,
file.replace'scount(now a compiled plainint) rejects a NON-integral
float (count: 2.9) with a typedvalue_invaliderror at decode time, where
the legacyfsxToIntsilently TRUNCATED it (int(2.9)= 2); an integral float
still coerces, so only a non-integral value diverges (the same class as the
file.managedfloat-mode fixture). Pinned by thefloat-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 (thepkg.installedversion
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'sseparator/key, andfile.blockreplace's
content/marker_start/marker_end. For the REQUIRED params (file.replace's
pattern,file.comment/file.uncomment'sregex) and forfile.keyvalue's
keythis 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 SCALARvalueis NOT in this coercion set: the legacy
constructor already sprint'd a scalar withfmt.Sprintf, so a numeric single
valueis parity. A COMPOSITE singlevalue— a nested map/list withkey
set — IS a BD-6 rejection, though: the legacyfmt.Sprintf("%v", …)wrote
Go-syntax garbage into the file, where thevaluestring field now rejects it
with a typedwrong_typeerror 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'sappend_if_not_found/prepend_if_not_found,
file.blockreplace'sappend_if_not_found/append_newline), and a CLI
numeric-stringcount=2is parsed forfile.replace'scount, where the
legacy.(bool)/fsxToIntpaths silently dropped a string. Pinned by the
*-cli-truthy-stringandcount-cli-numeric-stringcontract 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
asint8— is honored) and rejects any other integer with a typed
value_invaliderror, where the legacy.(bool)assertion dropped an int
entirely. EVERY boolean parameter's integer arm is pinned explicitly (not just
a representative):file.replace'sappend_if_not_found/prepend_if_not_found
andfile.blockreplace'sappend_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
countnow honors a msgpack-delivered sized integer. msgpack v5 encodes a
small integer into the smallest kind by magnitude (count: 2→ anint8),
and the legacyfsxToIntswitch handled onlyint/int64/float64— so a
reactor-dispatchedfile.replacewithcount: 2fell through to 0
(replace-all) instead of limiting the replacement (the same sized-int class as
the reproducedfile.managed0755 → 0644bug).paramtypes-free primitive
int coercion interprets every integer kind, so the requested count now survives
a reactor dispatch. Pinned by thecount-msgpack-sized-intcontract 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(aparamtypes.StringMap) rejects a COMPOSITE value — a nested
map or list — with a typedvalue_invaliderror, 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 thecomposite-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.appendmigrated to the self-documenting module-schema framework
(an all-primitives wave plus a secondStringListconsumer). Each
constructor now decodes through a single compiled schema
(modschema.Spec) plus registered documentation metadata, replacing the
hand-writtenconfig[...].(type)extractions.file.touchand
file.copygain thepathalias on their primary (name,primary, aliases=path, thefile_managed.goexemplar) — matching what their
legacyfsxResolvePath-based constructors already accepted, so this is
parity, not a new divergence;file.absent,file.symlink, and
file.appendkeep their legacyname-only primary (their pre-migration
constructors never recognized apathalias, so one is deliberately NOT
added here — parity, not a new divergence).file.copy'ssourceis the first
requiredprimitive-string parameter to reach a migrated module: a
missing or emptysourcefails at decode with a typedMissingRequired
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'stextmoves ontoparamtypes.StringList(the same
semantic typeuser.present'sgroups/optional_groupsuse), replacing
the legacyparseAnyStringListhelper (which stays inuser.gofor the
still-unmigratedgroup.*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}.yamlguard 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 primarynameparameter (and the non-primary
string paramsfile.symlink'stargetandfile.copy'ssource) is now a
compiled plain string: a non-string value (for examplename: 123in
YAML/msgpack) is coerced to its string form instead of silently falling back
to the state ID (or, fortarget, staying empty; for the REQUIREDsource,
being zeroed and then failing the required check — so a numericsourceis a
reject-to-accept flip), and a composite value (a list/map) is now rejected
with a typedwrong_typeerror instead of being silently ignored. Pinned
per-param across YAML and msgpack by thenumeric-name-*,composite-name-*,
numeric-target-*,composite-target-*, andnumeric-source-*contract
fixtures across all five modules. (A MISSINGsourcestays parity — both
legacy and decoded reject — pinned bysource-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'smakedirs,file.copy'sforce/preserve,
file.symlink'sforce) instead of being silently dropped by the legacy
.(bool)assertion. Pinned by the*-cli-truthy-stringcontract 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 asint8— is honored) and reject any other
integer with a typedvalue_invaliderror, 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'smakedirs,file.copy's
force/preserve/makedirs, andfile.symlink'sforce/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
textlist element that is a scalar (for exampletext: [line1, 2]) is now
rendered to its string form ("2") instead of being silently DROPPED by the
legacyparseAnyStringList(which — stricter thanuser.present's
groups, which at least kept every already-string element — only ever
appended an element that type-asserted directly as a Gostring), and a
NESTED element (a list or map inside the list) is now rejected with a typed
value_invaliderror instead of being silently dropped. A plain list of
strings is unchanged. Additionally, a BARE-STRING value (for exampletext: someline, or a CLItext=someline) now decodes as a single-element list
and ACTIVATES line management, where the legacyconfig["text"].([]any)
type assertion failed entirely on a non-list value (sotext: someline
silently managed NO lines); this is also what makes the CLI scalar spelling
work. Pinned by thetext-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.managedmigrated 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-writtenconfig[...].(type)extractions. Two parameters
move onto named semantic types:templateis aparamtypes.TemplateFlag(a
bool, the string"jinja", or any truthy/falsy string) andmodeis a
paramtypes.FileModedeclaredlazy,default=0644— the mode is never
materialized into the struct; the module applies the documented0644default
at use time viaMode.Resolve(0644), andFileModehonors an octal string OR
an octal integer of any kind.name/path(withpathas the primary's
alias),content,source,user,groupare plain strings,makedirsa
plain bool, andcontext/defaultsaremap[string]anypassthroughs. 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 emptynamealongside a non-emptypathalias
resolves to the path, never the state ID, matching the legacy
if name == "" { name = path }chain (pinned by thename-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 infile.go) per the per-module
file-naming convention the docgenSource: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 workednginx.conf.jinjasource-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.managedis the first module to contribute the
TemplateFlagandFileModesemantic-type$defsto the combined JSON
Schema artifact. The permanent differential contract at
pkg/state/modules/testdata/contract/file.managed.yamlguards the decode
behavior across all three universes (YAML, CLI, msgpack); the differential
harness (pkg/modschema/schematest) gainedTemplateFlagandFileMode
matchers (as it gained aStringListmatcher foruser.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 legacymodeConfigToStringswitch handled only
int/int64/float64— so a reactor-dispatchedfile.managedwith
mode: 0755fell through to the empty string and silently applied the0644
default (the exact reproduced0755 → 0644bug that motivated this whole
effort).paramtypes.FileModeinterprets 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 (0o4755→uint16). Pinned
by themode-0755-msgpackandmode-setuid-msgpackcontract 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 legacyv == "jinja"check
ignored every other string (sozester '*' file.managed … template=truewas
silently a no-op), and a CLImakedirs=truestring 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-stringandmakedirs-cli-truthy-stringcontract
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/contentis 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 intomode(where the legacymodeConfigToStringreturned""and
the module silently applied0644) — is rejected with a typed error.
Relatedly,modeVALIDATION now happens at DECODE time rather than at apply
time: a floatmode(for examplemode: 493.0, which the legacy
modeConfigToString%04o-converted to0755) and an invalid-octal string
mode(for examplemode: "banana", which the legacy path carried through and
only rejected later at apply) are both rejected up front byparamtypes.FileMode;
and atemplatevalue thatTemplateFlagdoes 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
astemplate: mako(the legacyv == "jinja"string check silently left
rendering off for everything but"jinja", somakowas a no-op). The string
case is pinned across all three universes because a string survives every
ingress unchanged (the CLI deliversmakotoo, unlike the integer case where it
delivers a truthy"1"). Pinned by thenumeric-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
makedirsnow accepts the integers1and0(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 legacymakedirs, _ = config["makedirs"].(bool)assertion dropped an
integer entirely (a silent false). This is documented on the generated page
(themakedirsrow) and pinned by themakedirs-int-one-*,
makedirs-int-zero-*, andmakedirs-int-invalid-*contract fixtures across
the YAML and msgpack universes (the CLI delivers a string, covered by BD-2). -
user.presentmigrated 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-writtenconfig[...].(type)extractions. Three parameters move onto
named semantic types:gidis aparamtypes.GroupRef(a numeric GID or a
group name), andgroups/optional_groupsareparamtypes.StringList.
passwordis 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). Thegid/primary_group
precedence is reproduced exactly in the builder'sresolveGroupFacets(a
name-formgidwins overprimary_group; a numericgidsets the GID).
Behavior is unchanged for every realistic YAML input (string/int params, a
name-formgid, a list ofgroups);user.absentstays on its legacy
constructor (a later wave). The module moved to its own file
(user_present.go,user.absentstaying inuser.go) per the per-module
file-naming convention the docgenSource: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 claimedpasswordwas "always applied on
modify (not compared)" when the code actually converges by comparing the
shadow hash, and described a stringgidas 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.yamlguards 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 aStringList
matcher alongside the existingTriStateone.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-stringnameis 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 sensitivepassword— 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, sozester '*' user.present deploy uid=1500now sets
uid(the CLI"1500", coerced base-10) and a CLI
createhome=true/system=truebool-string is applied; the SAME rule coerces a
YAML-quotedcreatehome: "yes"(a string, not a native bool). Pinned by the
uid-cli,createhome-true-cli, andcreatehome-truthy-string-yamlcontract
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 —1is
true and0is false, across every signed/unsigned integer kind (so a
msgpack-delivered bool, which arrives as a sized kind such asint8, is
honored rather than dropped by the legacy.(bool)assertion) — and ANY
other integer (for example2) is rejected with a typedvalue_invalid
error. Pinned across all three universes (YAML, CLI, msgpack) by the
createhome-int-one-*,createhome-int-zero-*, andcreatehome-invalid-int-*
contract fixtures. (This is the same rule already documented on the
TriStatetype; here it applies touser.present's primitivebool
parameters.)Behavioral difference (BD-1, approved 2026-07-13 under the maintainer standing proceed-without-sign-off grant). A msgpack-
delivereduidor numericgidis now honored. msgpack v5 encodes an
integer into the smallest kind by magnitude (1500 → uint16,1 → int8),
none of which the legacyconfig["uid"].(int)/config["gid"].(int)
assertions matched — so a reactor-dispatcheduser.presentwithuid: 1500
silently leftuidat0(the exact reproduced0755 → 0644bug class).
The uniform compiled decoder honors every integer kind, so the value now
survives a reactor dispatch. Pinned by theuid-msgpackandgid-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
stringgid(for examplegid: "1000", or a CLIgid=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 stringgid— digits
included — as a group NAME and stored it intoprimary_group, sogid: "1000"mis-configured a group named "1000" instead of GID 1000. A non-digit
stringgidis still a group name (unchanged). Additionally, a NEGATIVEgid
(an integergid: -5) is now rejected up front with a typedvalue_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 thegid-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_groupslist element that is a scalar (for examplegroups: [docker, 1000]) is now rendered to its string form ("1000") instead of being
silently dropped by the legacyparseAnyStringList, and a NESTED element (a
list or map inside the list) is now rejected with a typedvalue_invalid
error instead of being silently dropped. A plain list of strings is unchanged.
Additionally, a BARE-STRING value (for examplegroups: docker, or a CLI
groups=docker) now decodes as a single-element list and ACTIVATES group
management, where the legacyparseAnyStringListignored any non-list value
entirely (sogroups: dockersilently managed no groups); this is also what
makes the CLI scalar spelling work. Pinned by thegroups-scalar-sprint-*,
groups-nested-rejected-*, andgroups-bare-string-*contract fixtures.
Presented for sign-off in this PR (keystone spec §11). -
pkg.installed,pkg.latest, andpkg.purgedmigrated to the
self-documenting module-schema framework (all-primitives wave — no
semantic types needed;name/version/refreshare all plain
string/bool parameters). Each constructor now decodes through a single
compiled schema (modschema.Spec) plus registered documentation metadata,
instead of hand-writtenconfig[...].(type)extractions.pkg.latest's
refresh(which defaults to true, Salt-parity) is now an EAGER compiled
default (default=true) rather than a construction-timeif okoverride,
with the same observable default.pkg.installed's file moved to
pkg_installed.go(frompkg.go) to match the per-module file-naming
convention the other migrated modules and the docgenSource:line both
rely on. Behavior is unchanged for every realistic input (a string
name/version, a native boolrefresh, 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-downgradesdetails,
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
atpkg/state/modules/testdata/contract/pkg.installed.yaml,
pkg.latest.yaml, andpkg.purged.yamlguard 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'snameparameter (andpkg.installed's
version) is now a compiled primary/plain string: a non-string value
(for examplename: 123orversion: 124in 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
typedwrong_typeerror instead of being silently ignored. This activates
the §11 BD-6 class forpkg.installed'sname/versionand for
pkg.latest's andpkg.purged'sname; pinned by thenumeric-name-*,
numeric-version-*,composite-name-*, andcomposite-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: onpkg.installed,
zester '*' pkg.installed nginx refresh=trueleftrefreshat its false
zero value; onpkg.latest,zester '*' pkg.latest nginx refresh=false
leftrefreshat its eagertruedefault regardless of the operator's
intent (the construction-timeif 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 forpkg.installed's andpkg.latest's
refresh; pinned by thecli-refresh-*contract fixtures.Behavioral difference (BD-7, APPROVED conditionally 2026-07-12; scope
amended 2026-07-12 to cover every boolean-typed parameter, primitive
boolincluded, not onlyTriState).pkg.installed's and
pkg.latest'srefresh— a plainbool, notTriState— given as an
INTEGER is now coerced explicitly:1is true and0is false, across
every signed/unsigned integer kind, so a msgpack-deliveredrefresh
(which arrives as a sized kind such asint8) is honored instead of being
silently ignored by the legacy.(bool)assertion (onpkg.latest, that
silent drop leftrefreshat its eagertruedefault regardless of an
integer override). ANY other integer (for example2) is rejected with a
typedvalue_invaliderror rather than being dropped. Pinned across YAML
and msgpack by therefresh-int-one-*/refresh-int-zero-*/
refresh-invalid-int-rejected-*contract fixtures on both modules (the
CLI leg is already covered by thecli-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 onlyTriStatefields. -
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 likenmae:for
name:— logs aWarnline 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-layertest=Truedry-run flag are known control
keys and never warned. Only migrated modules participate today; the warning
surface grows as more modules gain schemas. -
service.runningandservice.deadmigrated to the self-documenting
module-schema framework (semantic-type pilot). Theirenableparameter is
now a named semantic type,paramtypes.TriState— a three-valued
unset/true/false whose "declared" bit replaces the ad-hocEnable bool + hasEnable boolpair. Behavior is unchanged for every realistic YAML/msgpack
input: an omittedenablestill leaves boot enablement untouched,enable: trueenables (and, onservice.running, converges an enable-only drift
WITHOUT restarting a healthy service), andenable: falsedisables (the
inverted default thatservice.deadkeys 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 atpkg/state/modules/testdata/contract/service.running.yamland
service.dead.yamlguard 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 leftenableUNDECLARED —
zester '*' service.running nginx enable=truedid not manage boot
enablement at all, andzester '*' service.dead nginx enable=falsedid 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 theenable-*-clicontract fixtures.Behavioral difference (BD-6, APPROVED 2026-07-12). As with
pkg.removed, thenameparameter is now a compiled primary string: a
non-stringname(for examplename: 123in YAML/msgpack) is coerced to
its string form instead of silently falling back to the state ID, and a
compositename(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 thenumeric-name-*andcomposite-name-*contract
fixtures. (A wrong-typedenableinteger belongs to BD-7 below.)Behavioral difference (BD-7, APPROVED 2026-07-12). A
service.running/
service.deadenablegiven as an INTEGER is now coerced explicitly:1is
declared-true and0is declared-false — across every signed/unsigned integer
kind, so a msgpack-deliveredenable(which arrives as a sized kind such as
int8) is honored rather than silently ignored by the legacy.(bool)
assertion. ANY other integer (for example2) is rejected with a typed
value_invaliderror rather than being dropped. The rule is documented on the
TriStatesemantic type — itsDoc()(and thus the generated Parameter Types
section) states the 1/0 semantics explicitly, and its JSON Schema constrains the
integer form toenum: [0, 1]— and pinned across all three universes (YAML,
CLI, msgpack) at both the type level (theTriStateint-one/int-zero/
reject-twotype fixtures) and the module level (theenable-int-one-*/
enable-int-zero-*contract fixtures forservice.runningand
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 dpkgrc-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.removedmigrated 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-writtenconfig["name"].(string)extraction. Behavior is unchanged for
every realistic input (a stringname, or none — falling back to the state
ID). The permanent differential contract at
pkg/state/modules/testdata/contract/pkg.removed.yamlguards this.Behavioral difference (BD-6, APPROVED 2026-07-12). A non-string
namevalue is no longer silently dropped in favor of the state ID. Under the
uniform coercion framework a numericname(for examplename: 123in YAML or
over msgpack) is now coerced to its string form ("123"), and a composite
name(a list/map) is now rejected with a typedwrong_typeerror 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") forpkg.removed's
nameparameter; 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
parsedcmd=as key=value form but dispatch routes cmd.run through the STATE
module, whose strict schema only acceptedcommand/name— so the accepted
spelling was then rejected as an unknown parameter. The state schema now
acceptscmdas a second alias (the execution-module spelling); source
resolution order iscommand>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:cwdgains thediralias (the
execution-module spellingsalt['cmd.run'](dir=...)), sozester '<target>' cmd.run 'make' dir=/opt/srcno longer fails under strict params. -
key=valuefirst 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=hiworks, 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>(andpillar.items/pillar.keys) now
work from the CLI. pillar.* is the peel's Salt-compat alias of settings.,
answered by a handler that reads onlyargs["key"]— but the CLI had no
pillar. argument arms, so the key fell into the request ID and every keyed
invocation erroredrequires 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 exportedDoc.Clone/ModuleInfo.Cloneseal them. -
A
.starfile 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. -
modschemainput boundary sealed (review round 5).Compile/NewSpec
now detach the caller'sDoc(a registrant retaining and later mutating its
doc slices could taint rendered docs), andSpec.Paramsis 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 leadingname=/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):FileModestring values now carry the octal pattern
("999"/"banana"are schema-rejected, matching decode); float strings
follow the exactstrconv.ParseFloatgrammar (underscores only between
digits, hex floats incl.0x_1p2/0x.8p1;1__0/10_/0xp1rejected);
required-parameter checks reject""/nullstand-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). -
modschemadocumentation views are now deep copies.CompiledSchema. Schema()andSpec.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.