diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/README.md b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/README.md
index 7910db2..7f2c221 100644
--- a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/README.md
+++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/README.md
@@ -18,7 +18,8 @@ rules both enforce:
the response set evaluated on the node right after the run.
- `FoamArtifactPolicyData` — what of the finished case travels back.
-`Rules/` holds what a case may be, as code every party runs: `FoamAllowList`
+`Rules/` holds what a case may be, as code every party runs (published as
+[SUPPORTED-INPUTS.md](https://github.com/OmnibusCloud/Controllers/blob/main/OpenFOAM/SUPPORTED-INPUTS.md)): `FoamAllowList`
(the utilities, the solver shape, and the controller's own step
`restore0Dir -processor`, which puts the initial fields into the processor
directories of a case meshed on its decomposed form), `FoamRecipeRules`
diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamSupportedInputsDocumentTests.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamSupportedInputsDocumentTests.cs
new file mode 100644
index 0000000..d21c5ba
--- /dev/null
+++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamSupportedInputsDocumentTests.cs
@@ -0,0 +1,135 @@
+using System.Text.RegularExpressions;
+using OutWit.Controller.OpenFOAM.Model;
+using OutWit.Controller.OpenFOAM.Model.Rules;
+using OutWit.Controller.OpenFOAM.Runtime;
+using OutWit.Controller.OpenFOAM.Tests.Utils;
+
+namespace OutWit.Controller.OpenFOAM.Tests.Model.Rules;
+
+///
+/// The supported-inputs document is held to the rules it publishes: the
+/// allow-list and its parallel column, the built-in steps, the forbidden
+/// flags, the step limit, the response kinds, the solver classes and the
+/// limits the controller applies; and the document and both READMEs carry
+/// the trademark statement word for word.
+///
+[TestFixture]
+public class FoamSupportedInputsDocumentTests
+{
+ private const string STATEMENT =
+ "OpenFOAM® is a registered trademark of OpenCFD Limited. This offering is not approved or endorsed by OpenCFD Limited, " +
+ "producer and distributor of the OpenFOAM software via www.openfoam.com, and owner of the OPENFOAM® and OpenCFD® trade marks.";
+
+ private static readonly Regex CODE = new("`([^`]+)`", RegexOptions.Compiled);
+
+ private string m_openFoamRoot = null!;
+
+ private string m_document = null!;
+
+ [OneTimeSetUp]
+ public void Setup()
+ {
+ var solutionRoot = OpenFOAMTestPaths.FindSolutionRoot();
+ if (solutionRoot == null)
+ Assert.Ignore("Solution root not found");
+
+ m_openFoamRoot = Path.Combine(solutionRoot, "OpenFOAM");
+ m_document = File.ReadAllText(Path.Combine(m_openFoamRoot, "SUPPORTED-INPUTS.md")).Replace("\r\n", "\n");
+ }
+
+ #region Tools
+
+ /// The lines of a section: from its heading to the next heading of any level.
+ private List Section(string heading)
+ {
+ var lines = m_document.Split('\n');
+ var start = Array.IndexOf(lines, heading);
+ Assert.That(start, Is.GreaterThanOrEqualTo(0), $"the document has no '{heading}'");
+
+ return lines.Skip(start + 1).TakeWhile(line => !line.StartsWith('#')).ToList();
+ }
+
+ /// The cells of a section's table rows, header and separator left out.
+ private List Rows(string heading)
+ {
+ return Section(heading)
+ .Where(line => line.StartsWith('|'))
+ .Skip(2)
+ .Select(line => line.Trim('|').Split('|').Select(cell => cell.Trim()).ToArray())
+ .ToList();
+ }
+
+ private static List CodeIn(string text)
+ {
+ return CODE.Matches(text).Select(match => match.Groups[1].Value).ToList();
+ }
+
+ private static string Flat(string text)
+ {
+ return Regex.Replace(text, @"\s+", " ");
+ }
+
+ #endregion
+
+ #region Rule Tests
+
+ [Test]
+ public void TheUtilitiesTableIsTheAllowListTest()
+ {
+ var rows = Rows("### Utilities");
+
+ Assert.That(rows.Select(row => CodeIn(row[0]).Single()), Is.EqualTo(FoamAllowList.UTILITIES));
+ Assert.That(rows.Where(row => row[1] == "yes").Select(row => CodeIn(row[0]).Single()), Is.EquivalentTo(FoamAllowList.PARALLEL_CAPABLE_UTILITIES));
+ }
+
+ [Test]
+ public void TheBuiltInStepsAndTheForbiddenFlagsAreTheRulesTest()
+ {
+ var builtIn = Section("### Built-in steps").Where(line => line.StartsWith("- ")).Select(line => CodeIn(line)[0].Split(' ')[0]);
+ Assert.That(builtIn, Is.EqualTo(FoamAllowList.BUILT_IN_STEPS));
+ Assert.That(m_document, Does.Contain($"`{FoamAllowList.RESTORE_INITIAL_FIELDS} {FoamAllowList.PROCESSOR_FORM}`"));
+
+ var arguments = Section("### Arguments");
+ var flags = arguments[arguments.FindIndex(line => line.EndsWith("refused in a recipe:")) + 2];
+ Assert.That(CodeIn(flags), Is.EqualTo(FoamAllowList.FORBIDDEN_FLAGS));
+ }
+
+ [Test]
+ public void TheLimitsAreTheControllersTest()
+ {
+ Assert.That(Flat(m_document), Does.Contain($"at most **{FoamRecipeRules.MAX_STEPS}**"));
+ Assert.That(Flat(m_document), Does.Contain($"at most {FoamDecomposition.MAX_DEFAULT_RANKS} ranks"));
+ Assert.That(Flat(m_document), Does.Contain($"larger than {FoamCaseContentRules.MAX_SCANNED_BYTES / (1024 * 1024)} MB"));
+ }
+
+ [Test]
+ public void TheResponseKindsAreTheVocabularyTest()
+ {
+ Assert.That(Rows("## Responses").Select(row => CodeIn(row[0]).Single()), Is.EqualTo(Enum.GetNames()));
+ }
+
+ [Test]
+ public void TheSolverClassesAreTheClassMapTest()
+ {
+ var published = Rows("## Solver classes")
+ .SelectMany(row => CodeIn(row[1]).Select(application => (Application: application, Class: CodeIn(row[0]).Single())))
+ .ToDictionary(pair => pair.Application, pair => pair.Class);
+
+ Assert.That(Rows("## Solver classes").Select(row => CodeIn(row[0]).Single()), Is.EqualTo(FoamSolverClasses.ALL));
+ Assert.That(published, Is.EquivalentTo(FoamSolverClasses.APPLICATIONS));
+ }
+
+ #endregion
+
+ #region Notice Tests
+
+ [TestCase("SUPPORTED-INPUTS.md")]
+ [TestCase("OutWit.Controller.OpenFOAM/README.md")]
+ [TestCase("OutWit.Controller.OpenFOAM.Model/README.md")]
+ public void EveryPageCarriesTheTrademarkStatementTest(string page)
+ {
+ Assert.That(Flat(File.ReadAllText(Path.Combine(m_openFoamRoot, page))), Does.Contain(STATEMENT));
+ }
+
+ #endregion
+}
diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM/README.md b/OpenFOAM/OutWit.Controller.OpenFOAM/README.md
index af7a4ab..945e3d5 100644
--- a/OpenFOAM/OutWit.Controller.OpenFOAM/README.md
+++ b/OpenFOAM/OutWit.Controller.OpenFOAM/README.md
@@ -28,7 +28,10 @@ the response request, the token coverage - live in
`OutWit.Controller.OpenFOAM.Model` (`Rules/`), so the node, the Sweep host
and an initiator's preflight refuse the same things with the same sentences.
What needs the kit or the materialised files (executables, libraries,
-run-time code) is checked here, on the node.
+run-time code) is checked here, on the node. The whole of it - the build and
+its platforms, the allow-list, what a case may not carry, the responses, the
+solver classes - is published, held to the rules by the tests, in
+[SUPPORTED-INPUTS.md](https://github.com/OmnibusCloud/Controllers/blob/main/OpenFOAM/SUPPORTED-INPUTS.md).
## What a case may contain
diff --git a/OpenFOAM/SUPPORTED-INPUTS.md b/OpenFOAM/SUPPORTED-INPUTS.md
new file mode 100644
index 0000000..7ec9a04
--- /dev/null
+++ b/OpenFOAM/SUPPORTED-INPUTS.md
@@ -0,0 +1,192 @@
+# OpenFOAM: supported inputs
+
+What the OpenFOAM controller runs and what it refuses, as the rules in
+`OutWit.Controller.OpenFOAM.Model` (`Rules/`) state them. The node, the Sweep
+host and an initiator's preflight apply the same rules and refuse with the same
+sentences; the tables below are held to those constants by the controller's
+tests, so a rule that changes cannot leave this page behind.
+
+## The build
+
+Every case runs on **OpenFOAM v2606**, whole, on one node, from the pinned kit
+of [OmnibusCloud/OpenFOAM](https://github.com/OmnibusCloud/OpenFOAM) (release
+`openfoam-v2606-3`). There is no conversion and no other build: a case written
+for another OpenFOAM line or version runs only if v2606 accepts it.
+
+| Platform | Kit | Notes |
+|---|---|---|
+| Linux x64 | `linux-x64` | built on Ubuntu 22.04: glibc 2.35 or newer |
+| macOS arm64 | `osx-arm64` | Apple Silicon |
+| Windows x64 | `win-x64` | a parallel step runs under MS-MPI when the node has it installed, serially otherwise |
+
+A node takes a case when it has at least 4 GB of RAM and 4 GB of free temporary
+storage. A parallel step runs on the ranks the task asks for; "all cores" means
+at most 16 ranks. The controller writes the `decomposeParDict` for that count
+(scotch); a `decomposeParDict` the case carries is not used.
+
+## The case
+
+A case is a **reconstructed** case directory: `system/controlDict` with an
+`application` entry, the time directories at the root (WitSweep uploads a case's
+`0.orig/` as `0/` when it has no `0/`), and either a mesh in `constant/polyMesh`
+or a step that makes one. Every file travels as it is: the node substitutes a
+study's tokens byte for byte, adds the function objects the responses need and,
+for a parallel run, writes its own `decomposeParDict`; nothing else changes.
+
+A case file path is refused when it
+
+- uses backslashes, contains whitespace, has empty or `.` segments, or leaves
+ the case directory;
+- is a log of an earlier run (`log.*` at the root);
+- lies under `postProcessing/` of an earlier run (its values would pass for this
+ run's);
+- lies under `processor*/` - a decomposed case is not accepted, submit the
+ reconstructed case;
+- repeats another path, or differs from one by letter case alone (one file on a
+ Windows or macOS node).
+
+A case is refused, by file and line, when it carries code that would be compiled
+while it runs - the kit ships no compiler, and a pool never runs code it cannot
+name:
+
+| Construct | Refused because |
+|---|---|
+| `codeStream`, `#codeStream` | compiles C++ at run time |
+| `#calc` | compiles C++ at run time (`#eval` is the in-built evaluator and is allowed) |
+| a `coded` boundary condition or function object | compiles C++ at run time |
+| `dynamicCode/` | compiled run-time code the case carries |
+| `libs (...)` naming a library the kit does not have | the library is not there |
+| `#include`, `#includeIfPresent`, `#includeFunc`, `#includeEtc` reaching outside the case | the run must be reproducible from the case alone |
+| a UTF-8 byte order mark at the start of a file | OpenFOAM reads it as part of the first word |
+
+Files larger than 8 MB (meshes, large fields) are not read for these
+constructs.
+
+## The recipe
+
+A recipe is an ordered list of steps, at most **32**, one of which runs the
+case's application - the solver its `controlDict` names. A step is a utility of
+the allow-list, a solver, or a step the controller does itself. There is no
+shell step and no custom command.
+
+**Solvers** are the kit's executables whose name ends in `Foam` (`simpleFoam`,
+`interFoam`, `rhoPimpleFoam`, ...) other than the utilities below
+(`potentialFoam` is a utility); every solver may run in parallel.
+
+### Utilities
+
+| Utility | Parallel |
+|---|---|
+| `blockMesh` | |
+| `surfaceFeatureExtract` | |
+| `snappyHexMesh` | yes |
+| `extrudeMesh` | |
+| `refineMesh` | yes |
+| `setFields` | yes |
+| `mapFields` | |
+| `topoSet` | yes |
+| `createPatch` | yes |
+| `createBaffles` | yes |
+| `transformPoints` | |
+| `renumberMesh` | yes |
+| `checkMesh` | yes |
+| `decomposePar` | |
+| `reconstructPar` | |
+| `reconstructParMesh` | |
+| `postProcess` | yes |
+| `foamDictionary` | |
+| `potentialFoam` | yes |
+| `setsToZones` | |
+| `subsetMesh` | |
+| `splitMeshRegions` | yes |
+| `mergeMeshes` | |
+| `mergeOrSplitBaffles` | |
+| `extrudeToRegionMesh` | |
+| `refineHexMesh` | |
+| `collapseEdges` | |
+| `extrude2DMesh` | |
+| `setAlphaField` | |
+| `makeFaMesh` | yes |
+
+A parallel step naming a utility not marked here is refused.
+
+### Built-in steps
+
+- `restore0Dir -processor` - puts the initial fields into every processor
+ directory after `decomposePar` (what a case meshed in parallel needs). Its only
+ form; it needs a `decomposePar` step before it and never runs under MPI.
+
+### Arguments
+
+A step's arguments are flags (`-overwrite`, `-latestTime`, ...) and plain values.
+No argument may point outside the case, and `-parallel` is never an argument -
+it is the step's parallel switch. These flags are decided by the controller and
+refused in a recipe:
+
+`-case`, `-decomposeParDict`, `-fileHandler`, `-hostRoots`, `-roots`, `-lib`, `-libs`
+
+## Responses
+
+A response is a number the node reads from what OpenFOAM writes, by a function
+object the case already runs or one the controller adds for the run:
+
+| Kind | Function object | Needs |
+|---|---|---|
+| `ForceCoeffs` | `forceCoeffs` | at least one patch; the reference values (`magUInf`, `lRef`, `Aref`, ...) as parameters |
+| `Forces` | `forces` | at least one patch |
+| `PatchValue` | `surfaceFieldValue` | exactly one patch, at least one field, an operation (`areaAverage`, `areaIntegrate`, `min`, `max`, ...) |
+| `VolumeValue` | `volFieldValue` | at least one field, an operation (`volAverage`, `volIntegrate`, `min`, `max`, ...) |
+| `FieldMinMax` | `fieldMinMax` | at least one field |
+| `Probe` | `probes` | at least one field, a `probeLocations` parameter |
+
+A response's name is a word, unique in the request, and not the name of a file
+the case carries in `system/`. A value is reported only from the run's final
+time: a function object that stopped writing earlier is left out and named in
+`log.responses`. In a parameter study a response parameter may carry the
+study's token (a reference speed that follows the swept speed).
+
+## Solver classes
+
+The class of a solver prices a run for the scheduler and, in WitSweep, estimates
+its memory from measured curves. A solver outside the table has no class: it
+runs, priced as the unknown class.
+
+| Class | Solvers |
+|---|---|
+| `incompressible-steady` | `simpleFoam`, `porousSimpleFoam`, `SRFSimpleFoam`, `overSimpleFoam`, `boundaryFoam`, `adjointOptimisationFoam`, `adjointShapeOptimizationFoam` |
+| `incompressible-transient` | `pimpleFoam`, `pisoFoam`, `icoFoam`, `nonNewtonianIcoFoam`, `SRFPimpleFoam`, `overPimpleDyMFoam`, `shallowWaterFoam` |
+| `compressible-steady` | `rhoSimpleFoam`, `rhoPorousSimpleFoam`, `overRhoSimpleFoam` |
+| `compressible-transient` | `rhoPimpleFoam`, `rhoCentralFoam`, `rhoPimpleAdiabaticFoam`, `overRhoPimpleDyMFoam`, `sonicFoam`, `sonicDyMFoam`, `sonicLiquidFoam` |
+| `thermal-steady` | `buoyantSimpleFoam`, `buoyantBoussinesqSimpleFoam`, `chtMultiRegionSimpleFoam` |
+| `thermal-transient` | `buoyantPimpleFoam`, `buoyantBoussinesqPimpleFoam`, `overBuoyantPimpleDyMFoam`, `chtMultiRegionFoam`, `laplacianFoam`, `solidFoam` |
+| `multiphase-transient` | `interFoam`, `interIsoFoam`, `interMixingFoam`, `overInterDyMFoam`, `interPhaseChangeFoam`, `interPhaseChangeDyMFoam`, `compressibleInterFoam`, `compressibleInterIsoFoam`, `compressibleInterDyMFoam`, `multiphaseInterFoam`, `multiphaseEulerFoam`, `twoPhaseEulerFoam`, `reactingTwoPhaseEulerFoam`, `reactingMultiphaseEulerFoam`, `driftFluxFoam`, `cavitatingFoam`, `twoLiquidMixingFoam`, `potentialFreeSurfaceFoam` |
+
+## What comes back
+
+Every run returns its facts - converged or not, iterations, final residuals,
+cells, `checkMesh`'s verdict, the time of each step, exit code, warnings - and
+its responses. What else comes back is the task's artifact policy: nothing, the
+final time and the mesh, or every written time; the step logs; `postProcessing/`.
+A node keeps nothing: what is not returned is gone when the run ends.
+
+## Studies
+
+`OutWit.Controller.Sweep` runs a parameter study over a case in one of two
+forms:
+
+- **A case with tokens.** Values in the case's dictionaries are replaced by
+ tokens (`{{oc1}}`, ...) in the files marked as templated; every variant is the
+ same case with its own values, substituted on the node byte for byte. Before
+ any node sees a task, the Sweep host checks that every token has a place in a
+ templated file and every place a declared token.
+- **A case set.** Every variant is its own ready case - its own tree and recipe,
+ nothing templated - and the study carries what the cases share: the ranks, the
+ responses, the artifact policy.
+
+---
+
+OpenFOAM® is a registered trademark of OpenCFD Limited. This offering is not
+approved or endorsed by OpenCFD Limited, producer and distributor of the
+OpenFOAM software via www.openfoam.com, and owner of the OPENFOAM® and
+OpenCFD® trade marks. The kit is built from OpenFOAM's sources under the
+GPL-3.0; the kit's repository carries the licence and the source offer.