Run one rule of the CommonsLang_OCaml.Dk.OpamLock scriptmodule so its author-time OpamLock uirule ships in this distribution. Running any rule of a scriptmodule brings in the whole scriptmodule; the marker output is otherwise unused. The value-id is harvested from the first CI run (the DkML pattern).
The @1.1.8 revision makes the two generated artifacts self-describing and adds the Refresh uirule (see "### Refresh: self-describing regeneration"). Solve stamps its roots/pins (and wtest/local_opam_dir when set) into the lock's generated block, and GenerateDriver stamps its full parameter set into a top-level generated member of the driver values.jsonc (right after schema_version). This needs no dk-value schema change: MlFront's value reader pulls members by name and ignores unknown top-level members. The helper moves to Apparatus.OpamLockHelper@1.0.14. Its Export value-id is per-version and is harvested from CI as usual.
The @1.1.7 revision flips the default host-tool ABI (see "### Host tools and the hosttoolabi= escape hatch"): GenerateDriver now emits targetabi=Release.target_abi for ocamlfind/ocamlbuild and takes an optional hosttoolabi=SLOT parameter that restores Release.execution_abi for a matrix with a host-unemulatable cross slot. Its Export value-id is per-version and is harvested from CI as usual.
The @1.1.6 revision is behaviorally identical to @1.1.5. It exists because the lock-helper assets were collapsed onto the stable assets/opam-lock/dk_opam_lock.ml path (Apparatus.OpamLockHelper@1.0.10, the same bytes as the retired dk_opam_lock_filtered.ml) and every earlier OpamLock revision was retired from HEAD; see "## Assets". Its Export value-id is per-version and is harvested from CI as usual.
How the lock is produced: the OpamLock.Solve uirule launches opam to SOLVE the dependency closure -- it runs opam list --resolve to compute the closure and opam show to read each package's version/source/deps/build commands, and never opam installs. opam has no switch-less solve (the solver needs a switch's repositories, pins, and os/arch variables), so Solve creates an EMPTY opam switch purely as a throwaway resolution context: it adds the pinned repositories, applies the version pins from dk-opam-pins.txt, path-pins the local packages, then resolves. By default that switch is an ephemeral LOCAL switch in the rule's sandbox -- unique per run (no cross-project or cross-run pin contamination, so the lock is reproducible) and removed once the solve finishes; nothing is ever installed into it.
Resolving ocaml 5.1.1 as the solve compiler
ocaml is left unpinned so the solve resolves it from the pinned repository commit to 5.1.1, one of the first releases with the corrected ocaml-env, host-arch and conf-mingw-w64 selectors (pinning ocaml in the shared pin table would break the CI build's DkML toolchain). The pinned commit makes that resolution deterministic, and the version is a solve artifact only: the DkML 4.14.3 toolchain ocaml replaces it at build time.
- ocaml 5.1.1 carries the corrected ocaml-env, host-arch and conf-mingw-w64 selectors, so the solved closure resolves the right platform packages for every slot.
- The resolved ocaml version drives per-slot mingw selection: 5.1.1 resolves the Windows_x86 slot to the 32-bit ocaml-env-mingw32 toolchain, where 4.14.2 resolves the wrong 64-bit one.
- The DkML 4.14.3 toolchain ocaml replaces this solve compiler at build time, so the lock's ocaml and ocaml-base-compiler entries are solve artifacts, not build inputs.
Relocatable ocamlfind and ocamlbuild
The solver still resolves ocamlfind and ocamlbuild (so the rest of the closure resolves and both still satisfy their dependents at the resolved version), but when the helper emits these two entries it substitutes a fixed relocatable specification for the solved source and build commands. A stock findlib bakes an absolute stdlib path into topfind and findlib.conf, and a stock ocamlbuild bakes an absolute libdir. Those absolute paths are valid only in the build tree that produced them, so a prebuilt CommonsBase_Dk.Dk0.Pkg.Ocamlfind or Pkg.Ocamlbuild object is unusable once imported into a different build tree (the imported #use "topfind" loads a dead #directory). The relocatable builds write paths relative to the tool's own location instead, so the imported objects stay usable across build trees.
- ocamlfind is pinned to dra27's relocatable fork (v1.9.8) and built with
-sitelib ".", so findlib writes a relative topfind and findlib.conf. - ocamlbuild is pinned to dra27's relocatable-0.14.3 fork (0.14.3 plus the relocatable patch) and built through
configure.make with OCAMLBUILD_LIBDIR=.., so ocamlbuild computes its libdir at runtime. - Both sources are addressed by git commit, so the pin does not move. The substitution is a property of the lock the helper writes; downstream projects do not list these packages in their
dk-opam-pins.txt. - The DkML
4.14.3+relocatable toolchain supplies the runtime support the two forks rely on (standard_library_relative, -set-runtime-default), so no compiler change is needed.
ocamlfind and ocamlbuild are host tools: their built artifacts are native executables that run on the build host during every later package build (topkg's ocaml pkg/pkg.ml build invokes ocamlfind and ocamlbuild). They are dual-role: the build must RUN them, and their findlib metadata (stdlib path, ocamlmklib/ocamlc config, arch flags) flows into every later package build. Since @1.1.7 GenerateDriver emits targetabi=Release.target_abi for these two by default, so on a cross slot whose host can emulate the target (Windows_x86 under WOW64, Darwin_x86_64 under Rosetta, Linux_x86 under multilib) the metadata matches the target and the tool still runs. This is the dual-role convention in SPECIFICATION "Object Slot ABI"; it was validated as the ocamlearlybird 57fd802 hand edit before it moved into the generator.
The hosttoolabi=SLOT parameter restores Release.execution_abi for a matrix that contains a cross slot the host CANNOT emulate. Building a host tool at the target ABI there produces a binary the host cannot run: on Release.Linux_x86_64_musl the tool links against the target musl toolchain and becomes a musl-dynamic executable, and the glibc host that must run it during the build lacks the musl loader. Worse, the opam-build form is host-keyed and carries the Release.target_abi wildcard in its value-id, so a single such build is stored under one key and shared to every slot -- including the glibc Release.Linux_x86_64 one, where the tool then fails to exec. A consumer whose matrix includes such a slot passes hosttoolabi=Release.execution_abi to keep the host tools host-native; that is the musl hazard the @1.1.4 execution-abi pin originally guarded.
Refresh: self-describing regeneration
Since @1.1.8 the lock and the driver stamp their inputs, and the Refresh uirule reads those stamps back. Solve records roots/pins in the lock's generated block; GenerateDriver records its full parameter set in a top-level generated member of the driver values.jsonc. A consumer therefore regenerates without re-copying the roughly ten parameters that used to live only in prose, and CI detects a stale driver in seconds instead of hours into a release build.
Refresh has four modes. Default (driver) regenerates the driver from the existing lock using the stamped parameters, so a routine repin never silently changes solved package versions; when the lock is not checked in (a consume-from-archive consumer), it substitutes the target rulefn in place, which equals a full regeneration for a rule-version-only move. mode=solve fetches opam hermetically, re-solves the lock from its stamped roots/pins, then regenerates. mode=check is read-only: it compares each driver's stamped rulefn/tool against the newest the imported etc/dk/i/CommonsLang_OCaml.<ver>.values.json declares (and, when the lock is checked in, the stamped lock-sha256 against the lock on disk), prints the exact fix command and exits nonzero when stale. version=NEW rewrites the coupled version/formid/localsrc and regenerates.
A pre-@1.1.8 driver carries no stamp; the first driver-mode Refresh recovers its parameters from the generated text and emits the stamped form. mode=check on such a driver still recovers the rulefn: it fails when that rulefn is stale (the check that would have caught the 2026-08-20 stale-driver incident) and otherwise prints an "unstamped: run Refresh once to adopt" advisory and exits zero, so CI adoption is graceful. Rule versions are co-versioned: Refresh@1.1.8 bakes in its sibling Solve@1.1.8/GenerateDriver@1.1.8 and the newest F_BuildLockedPackage; rulefn= remains an explicit override.