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.16 revision emits deterministic archives from the generators: GenerateSrc's localized-source form and GenerateDriver's legacy mergedprefix driver archive with the engine's in-process --zip special form (same inputs, same bytes on every platform), and the emitted Src form drops its S7z dependency. Building the emitted forms requires dk 2.4.2.342 or later.
The @1.1.15 revision declares the uirule capability manifest (M.uirule_capabilities): each OpamLock uirule names the capabilities it may request (Solve/Adopt/Refresh/OpamVenv: run+write; the three Generate* rules and GenerateForms: write). An engine >= 2.4.2.339 enforces the manifest as an upper bound and lists every declared capability in its trust prompts and suggested trust grant commands, so a consumer grants a rule's full need in one command; older engines ignore the field. No rule body changed.
The @1.1.14 revision completes the adoption generators. GenerateSrc emits the assembled tree's dune-workspace root marker (the project's own file, or a minimal (lang dune <X>) synthesized into the dk-owned dk-src/ directory and registered by Adopt): an assembly without the marker made every warm run-object of the final executable re-derive and re-save its traces instead of a silent cache read. Adopt forwards its derived root to the form generators, so the reuse_lock=t path works against a pre-@1.1.8 minimal-stamp lock whose generated.roots is not recorded.
The @1.1.13 revision makes the solve universe hermetic and re-adoption non-destructive. The opam-lock helper (Apparatus.OpamLockHelper@1.0.16) registers each pin-table repository under a URL-derived dk-owned name and selects only those names for its ephemeral switch, so a pre-existing repository in the machine's opam root can never supply the solve universe (a stale root's default silently overrode the pin table's snapshot, hiding the pinned dune/ocaml versions and surfacing as an unrelated-looking conflict on any constrained dependency); the ephemeral switch is recreated on every solve, the constraints repository name embeds the work directory so concurrent solves sharing one root cannot repoint each other, and every pin NAME VER is verified visible in the universe before solving, failing with a message that names the repository that should contain it. Adopt re-solves with --with-test (the canonical adoption lock is the with-test lock; wtest=f opts out), forwards wdoc=, and accepts reuse_lock=t to keep an existing committed lock untouched and regenerate only the build forms.
The @1.1.12 revision teaches the driver machinery the closure shape that pairs with Dk.OpamBuild's F_BuildLockedClosure (see "## Dk.OpamBuild"). GenerateDriver keys its emission off the rulefn family: an F_BuildLockedClosure rulefn -- the default when the import declares one -- emits ONE run-function precommand carrying the whole parameter set (the closure rule re-derives the per-package plan from the lock at build time), while an F_BuildLockedPackage rulefn emits the per-package lines byte-identically to @1.1.11 so existing drivers regenerate unchanged. Import resolution stays at author time (the emitted line carries resolved impdep_ ids, since a build rule cannot read the impsrclock project files), and the line keeps a literal ocaml= token that OpamVenv still reads off the raw driver text. Refresh resolves its target per driver: an explicit rulefn= wins; with the lock on disk a stamped driver upgrades to the newest F_BuildLockedClosure the import declares (a deliberate shape change that re-keys every Pkg object id, so it happens only when the lock -- the regeneration source -- is present); a stampless driver adopts the stamp in the per-package shape first (preserving the pkg=-token reproducibility guard, which a shape change would defeat); and a lockless (consume-from-archive) driver substitutes only within its stamped rulefn's family. mode=check compares within family and flags a per-package driver STALE when the import provides the closure rule and the lock is on disk. Its Export value-id is per-version and is harvested from CI as usual.
The @1.1.11 revision reduces what a maintainer types to adopt an opam+dune project. GenerateDriver derives its nine arguments from one pkg=MODULE@VERSION (formid, pkgpath, version, localsrc, and the out path; the roots from the lock's stamped generated.roots; the rulefn from the CommonsLang_OCaml import). GenerateSrc and GenerateFinal generate the localized-source and thin final forms a maintainer used to hand-author, and GenerateForms runs the fixed trio (Src, driver, Final) from one pkg=. Adopt carries a first-time adoption end to end (seed the pin table, solve, generate the forms, register the dk.u assets). Parallel scheduling is the default (sequential=t opts out); Refresh injects sequential=t when replaying a driver that stamped neither, keeping pre-@1.1.11 drivers byte-stable. Every stamped tool id and printed message derives from M.id, and in-module cross-calls dispatch through version-unique function bindings (H.Solve, H.GenerateDriver, ...) rather than the rebindable rules/uirules globals.
The generated driver values.jsonc stays a committed, on-disk artifact rather than being produced dynamically at build time. Dynamic generation is feasible (the engine's x-dynamic-driver-spike cram proves a stub form plus a generator function rule emitting the per-package forms via submit.values), but the committed driver is kept deliberately: the primary metric is characters TYPED, and the driver is machine-generated (GenerateDriver/Adopt write it), so it costs the maintainer zero typed characters -- collapsing it to a stub saves nothing on that metric. Meanwhile the committed driver earns its place: its generated stamp powers Refresh mode=check and the CI refresh-check gate (which compare the on-disk driver), it is on the include path at scan time so a fresh clone builds offline without first running a generator, and its formid is a published contract (the thin form and dist/*.u reference Closure@VER by name). Under the @1.1.12 closure shape the driver is one run-function line, so the auditable build plan a reviewer reads is the committed lock plus the closure rule rather than the enumerated per-package lines; that trade was made for warm-run speed (see "## Dk.OpamBuild"), and the stamp/formid/offline-scan reasons are unchanged. A dynamic driver would additionally retire the staleness-anchor and published-contract subsystem for no gain on the headline metric, so the layer is kept and only its authoring was removed.
The @1.1.10 revision adds the OpamVenv uirule (see "### OpamVenv: a native dune inner loop"). It materializes a real, dune-usable opam prefix from a GenerateDriver mergedprefix=t skiplocal=t closure into the project's opam-venv/ directory, so a developer runs native dune build -w against the working tree instead of dk's per-edit whole-package rebuild. Its Export value-id is per-version and is harvested from CI as usual.
The @1.1.9 revision adds the --wdoc solve: Solve passes --with-doc to opam when wdoc is set and stamps wdoc into the lock generated block, so a with-doc lock carries odoc (the odoc package that dune build @doc needs). The helper moves to Apparatus.OpamLockHelper@1.0.15; the --wdoc content edit rides an Apparatus bump per "## Assets". Its Export value-id is per-version and is harvested from CI as usual.
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.12 bakes in its sibling Solve@1.1.12/GenerateDriver@1.1.12/OpamVenv@1.1.12 and, absent an explicit rulefn=, prefers the newest F_BuildLockedClosure the import declares (falling back to F_BuildLockedPackage); rulefn= remains an explicit override.
OpamVenv: a native dune inner loop
dk's incremental unit is the whole package object: editing one source file and rebuilding re-stages the localized-source object and rebuilds the entire leaf package in a fresh hermetic sandbox, which is minutes even with every dependency cached. The OpamVenv uirule hands the inner loop to dune instead. It reuses a GenerateDriver mergedprefix=t skiplocal=t driver -- the whole non-local dependency closure merged into one cached prefix.zip object -- and stages that prefix, the DkML compiler, and dune into the project's opam-venv/ directory, so source opam-venv/env.sh && dune build -w recompiles only the edited module and its dependents.
OpamVenv discovers the dev-prefix driver (the sole one whose stamp carries mergedprefix=t, or an explicit driver=), reads its stamp for the formid, lock, and lock-sha256, and refuses to run when the lock on disk no longer matches that stamp (pointing at the Refresh fix command). It get-objects the prefix for the requested slot= (default: the host execution ABI), rewrites the staged prefix to be relocatable (the @OPAM_IP@ dune-package sentinel and findlib.conf), and writes env.sh/env.ps1/env.cmd activators plus a dk-opam-venv.json stamp. A re-run is a no-op when that stamp still matches (force=t overrides); after a lock change, Refresh regenerates the dev-prefix driver and the next OpamVenv re-materializes.
A caller can also skip the driver: formid=ID@VER names the merged-prefix form to get-object directly, so a project whose merged prefix is a released object (a released with-test closure form, for one) needs no dev-prefix driver of its own, and lock=PATH names the lock that prefix was solved from so the stamp records its sha256 and a lock edit forces a rebuild. The stamp also records the formid, and the up-to-date check compares it, so a new closure version is never mistaken for the old one.
The activators also carry the project's declared environment modifications. A committed dk-opam-venv.envmods beside dk.u (or envmods=PATH) holds one modification per line in the specification's grammar (+NAME=VALUE, <NAME=VALUE, -NAME; # comments; ${PREFIX} and ${DKML} stand for the staged prefix and compiler), planned by the engine's envmod library so the ordering and dedupe are the engine's. Each of env.ps1, env.cmd and env.sh renders the same plan after its toolchain block: a prepend adds no separator when the variable is empty in all three, a removal is Remove-Item, unset or cmd's set "NAME=", and the stamp records the file and its sha256 so an edit re-materializes. A project with no such file gets the same activators as before.
The opam venv is additive and host-only: opam-venv/ and dune's _build/ are invisible to git and to dune. dk0 drops a self-ignoring .gitignore ("*") and a dune ("(dirs)") into its t/ store, and the OpamVenv dialog does the same for opam-venv/, so a host dune build never scans them and the reproducible run-object release build is unchanged. It resolves to the same dependency versions and the same DkML compiler the release build uses, driven from the same lock.