# Haskell Fp Standards

> Haskell production engineering standards. Trigger on .hs/.lhs files, .cabal files, cabal.project/cabal.project.freeze, stack.yaml/stack.yaml.lock, package.yaml (hpack), hie.yaml, fourmolu.yaml/.ormolu, .hlint.yaml, ghcup/GHC toolchain pins, Stackage resolvers, and packages like base, text, bytestring, containers, aeson, servant, persistent, conduit, streaming, mtl, effectful, cleff, fused-effects, relude, rio, hspec, tasty, QuickCheck, hedgehog, criterion, async, stm. Apply when writing, reviewing or setting up CI for Haskell, and when deciding whether a team should adopt Haskell at all.

- Skill: `serialexperimentslainnnn/haskell-fp-standards` (Agent Skill)
- Install (CLI): `npx skillmds@latest add serialexperimentslainnnn/haskell-fp-standards`
- Raw SKILL.md: https://api.skillmd.com/api/skills/serialexperimentslainnnn/haskell-fp-standards/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: serialexperimentslainnnn (https://skillmd.com/u/serialexperimentslainnnn)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/serialexperimentslainnnn/haskell-fp-standards

---


# Haskell in production standards

Criteria verified as of **August 2026**. Re-verify on the web before committing to anything (§8).

## 1. Scope and triggers

Applies to all Haskell work: `.hs`/`.lhs`, `.cabal` files, `cabal.project` and `cabal.project.freeze`, `stack.yaml`/`stack.yaml.lock`, `package.yaml` (hpack), `hie.yaml`, `fourmolu.yaml`, `.hlint.yaml`, GHCup pins and Stackage resolvers, and the pipelines that build/test Haskell. Covers backend services, CLIs, compilers/DSLs and libraries published on Hackage.

**The axis of this skill: adopting Haskell in production is a team decision before it is a technical one.** The language is not the risk — the risk is the *bus factor*, the learning curve, the hiring market and the compile times. A Haskell service written well by one person who leaves is a liability, not an asset. Before writing the first line you must be able to answer: are there ≥3 people who can operate and modify this?, does the team accept a 10-30 min CI on a clean build?, is there a training budget? If the answer is no, the right decision is another language (§7). Where Haskell genuinely wins: compilers/parsers/DSLs, business logic with dense invariants, correct-by-construction data transformation, and teams that already master it. Where it does not win: CRUD without invariants, infrastructure glue, code that will rotate through many hands.

**Not applicable**: see `ocaml-fsharp-standards` (the strict MLs — the boundary is **laziness versus strict evaluation** and the class of type system: type classes + higher-kinded types + monadic `IO` here, modules/functors and effects via *handlers* there; not "both are functional"), `scala-standards` (the other functional language with type classes: the boundary is the **JVM runtime** and Java interop as a design requirement, plus its Typelevel/ZIO ecosystem choice; `cats-effect`/`fs2` are theirs even if the style looks similar), `rust-standards` (shares ADTs and ML lineage: the boundary is the **memory model** —ownership/no GC versus GC + laziness— and the purpose: systems and bounded latency versus correctness of the logic), `jvm-spring-standards` (only if the problem comes in through JVM/Spring), `iac-standards` (**Nix as an infrastructure/deployment manager is theirs; Nix as this Haskell project's reproducible toolchain —`flake.nix`, `haskell.nix`, development shells— belongs to this skill**), `cicd-standards` (the pipeline that runs the gates and its cache), `kubernetes-standards` (OCI image and deployment of the binary), `api-design-standards` (the HTTP/gRPC contract — here only its implementation with servant/wai), `appsec-standards` (threat modelling and agnostic vulnerability classes; here only the concrete Haskell sinks), `vulnerability-management-standards` (CVE triage and SLA; here only `cabal audit`/advisory-db in the gate), `secrets-management-standards` (where secrets live; here only not leaking them through `Show`), `observability-standards` (OTel pipeline and SLOs; here only the instrumentation and the RTS metrics), `sql-standards` (the SQL generated by persistent/esqueleto/hasql), `python-standards`/`go-standards`/`typescript-standards` (the real alternative when §7 says Haskell is not the fit).

## 2. Default toolchain

> Verify the latest version on the web before pinning it in a real project (§8). What follows is the state verified as of **Aug 2026**.

| Piece | Choice | Verified state (Aug 2026) | Criteria |
|---|---|---|---|
| Installer | **GHCup** | the only supported one; Haskell Platform dead for years | Never the distro package manager's GHC |
| Compiler | **GHC 9.14.x (first LTS release)** | 9.14.1 published 2025-12-19; 9.14.2-rc1 in progress | LTS ⇒ ≥2 years of bugfixes; **not the newest, the LTS** |
| Live GHCs | 9.14 (LTS), 9.12.4, 9.10.3, 9.8.4, 9.6.7 | 9.4 and earlier **EOL** | ≤9.4 in production = no patches |
| Edition | **`GHC2024`** declared explicitly | introduced in GHC 9.10.1; **the default is still `GHC2021`** | The default can change: always pin the edition |
| Build | **cabal-install 3.18.x** | 3.18.1.0 (2026-07-29) | Sober default: GHCup + cabal |
| Alt. build | **Stack 3.11.x** | 3.11.1 (2026-06-15) — **active, not abandoned** | Only if you want the curated set as the build model |
| Dependency set | **Stackage LTS** as the compatibility oracle | LTS 24.x ⇒ ghc-9.10.3; Nightly 2026-08-01 ⇒ ghc-9.12.4 | See the GHC-LTS vs Stackage-LTS discrepancy below |
| IDE | **HLS (haskell-language-server)** | 2.14.0.0 (2026-04-27) | Installed by GHCup, version tied to the GHC |
| Formatter | **fourmolu** | 0.20.0.0 (2026-06-18), BSD-3-Clause | `ormolu` if you want zero configuration; only one per repo |
| Linter | **hlint** | 3.10 (2025-02-02), BSD-3-Clause | Low cadence: useful but **verify before pinning it as a hard gate** |
| Effects | **`ReaderT Env IO`** | — | `effectful` 2.6.1.0 (2025-08-30, BSD-3) if the default is not enough |
| Tests | **hspec** or **tasty** + **QuickCheck**/**Hedgehog** | verify versions (§8) | Property-based is not optional (§4) |

**Declared discrepancy (important).** There are **two notions of "LTS" that do not coincide**: the GHC LTS (9.14, since Dec 2025) and the Stackage LTS (series 24, built on ghc-9.10.3 as of Aug 2026). Adopting GHC 9.14 today means **leaving Stackage's curated set** and resolving dependencies with cabal's solver + your own freeze. Default decision: **the GHC is pinned by the availability of a curated snapshot that builds your dependencies**, not by the highest number. Document the choice in an ADR and revisit it when Stackage promotes an LTS on 9.14.

**GHC upgrade policy.** LTS-to-LTS. The non-LTS releases come out ~every 6 months with a short life; the overlap between consecutive LTSs is ~6 months, and that is the real migration window. Adopting a `.1` in production is unnecessary: wait for `.2`/`.3`. Each jump is tested first on a CI branch with the full matrix (`-Wall -Werror` included) because **`-Wall` gains new warnings between versions** — verified example: `-Wincomplete-record-selectors` entered `-Wall` in GHC 9.14, and libraries with `-Werror` break because of it.

**GHCup / Stack / Cabal / Nix — real criteria, not a catalogue.**
- **GHCup + cabal**: the default. Fewer pieces, it is what the rest of the ecosystem and HLS assume.
- **Stack**: choose it only if the value is *the curated set as the build model* (large team, many repos, a desire for "one resolver and done"). Still active (3.11.1, Jun 2026) — it is not a legacy decision. Do not mix the two in the same repo.
- **Nix** (`flake.nix` + `haskell.nix` or `nixpkgs`): only when the requirement is **bit-reproducibility or non-trivial non-Haskell system dependencies** (libpq, ICU, ffmpeg, cross-compilation). Nix doubles the tooling surface the team must be able to debug; it comes in with a designated owner or it does not come in. If it comes in, it comes in for everyone: none of this "some with Nix and others with cabal" — the `shell.nix`/devShell is the canonical environment and CI uses the same one.

**Stackage versus Hackage — how a reproducible set is pinned.**
- **Hackage** is the registry (uncurated, anyone publishes). **Stackage** is a set of versions *verified to build together* against a specific GHC; the snapshots are **immutable** once published.
- With Stack: `resolver: lts-XX.YY` + a versioned `stack.yaml.lock`. Extra-deps from Hackage with a hash, and each one is debt to justify.
- With cabal: `cabal.project` can import the Stackage snapshot as `import: https://www.stackage.org/lts-XX.YY/cabal.config` and **in addition** `cabal.project.freeze` is versioned (`cabal freeze`). Without a freeze there is no reproducible build: the solver will resolve differently tomorrow.
- Rule: **the lockfile/freeze is always versioned**, in libraries too (for their CI; the library publishes ranges, not pins).
- A dependency outside the snapshot ⇒ an explicit decision: look at maintenance, licence and transitive tree. No `git:` without a pinned `--sha256`/rev.

## 3. Structure and conventions

- Layout: `src/` (library), `app/` (thin executable), `test/`, `bench/`. **All the logic lives in the library**; the executable only parses the CLI, builds the `Env` and calls `main`. This is what makes the code testable and reusable, and what avoids the 2000-line `Main.hs`.
- Multi-package as soon as there are two real boundaries: `cabal.project` with `packages: ./pkg-*`. `common` stanzas in the `.cabal` to avoid repeating `ghc-options`/`default-extensions`.
- Modules by **domain**, not by technical type (`Billing.Invoice`, not `Types`/`Utils`). Explicit export lists in every module: the API is a decision, not an accident.
- Types first: newtypes over primitives (`newtype UserId = UserId UUID`), *smart constructors* that validate at the boundary, impossible states unrepresentable with ADTs. `NonEmpty` instead of "a list that is never empty". The type system is the first layer of tests.
- Record fields **strict by default** (`!Int`) in domain data types; `StrictData` per module when the type is a data container.
- `deriving stock`/`newtype`/`anyclass` **always explicit** (`DerivingStrategies` already comes in `GHC2024`): implicit deriving is ambiguous when reading.

### Language extensions

- **Declare `default-language: GHC2024`** in the `.cabal`. Verified: `GHC2024` was introduced in GHC 9.10.1 and **adds on top of `GHC2021`**: `DataKinds`, `DerivingStrategies`, `DisambiguateRecordFields`, `ExplicitNamespaces`, `GADTs`, `MonoLocalBinds`, `LambdaCase`, `RoleAnnotations`. GHC's default with nothing declared **is still `GHC2021`** (for compatibility), which is why it is declared.
- If the project is tied to GHC <9.10: `GHC2021` + minimal `default-extensions`.
- Commonly sane additions per project: `OverloadedStrings`, `StrictData`, `DerivingVia`, `TypeFamilies` (if the design calls for it).
- **Banned for maintenance cost** (require an ADR and an owner): `UndecidableInstances`, `IncoherentInstances`, `OverlappingInstances`/`{-# OVERLAPPING #-}` in bulk, `AllowAmbiguousTypes` as a patch over a confused design, `ImpredicativeTypes`, `TemplateHaskell` beyond what already requires it (aeson/persistent/lens) — TH costs compile time, breaks cross-compilation and is opaque when debugging. `CPP` only for version compatibility bands, never for logic.
- `unsafeCoerce` and `GeneralizedNewtypeDeriving` over classes with dangerous methods: FORBIDDEN without written justification (§7).

### `String` / `Text` / `ByteString`

- **`String = [Char]` is a performance bug by default**: a linked list of boxed characters, one cons-cell per character. It is not a style choice.
- **`Text`** (`text`, UTF-8 since text-2.0) for **all human text**. **`ByteString`** for **bytes** (I/O, network, binary, files). `String` only at the boundary with old APIs (`FilePath`, `Show`, some in `base`) and converted immediately.
- `OverloadedStrings` enabled; literals without scattered `pack`.
- Lazy versus strict: `Data.Text` / `Data.ByteString` strict by default; the `.Lazy` variants only for *streaming* large data that does not fit in memory, and then with explicit reasoning.
- Decoding bytes to text **always with explicit error handling** (`decodeUtf8'`/`decodeUtf8With`), never `decodeUtf8` over untrusted input: it throws an exception impossible to catch in pure code.
- File paths: `OsPath`/`OsString` when the project touches non-ASCII or binary paths; `FilePath = String` is the classic trap.

### Laziness and space leaks — a first-class bug category

Laziness is the language's distinctive feature and the **primary source of production incidents**: an accumulated thunk does not fail in tests, it fails 6 hours in with a full heap.

- **`foldl` is banned.** Use `foldl'`. Verified: `foldl'` is exported from `Prelude` starting with **base-4.20 (GHC 9.10)** (CLC #167) — before that you must import it from `Data.List`/`Data.Foldable`. On GHC <9.10 the explicit import is mandatory; none of this "it just wasn't at hand".
- Strict accumulators: `BangPatterns` (`go !acc x = ...`), `seq`/`force` (`deepseq`) where the accumulator is structured.
- Structures: `Data.Map.Strict` and `Data.IntMap.Strict` by default — the lazy variant only with a reason. `modifyIORef'`/`atomicModifyIORef'`, never the lazy versions. `foldl'` over a `Map`, not `foldr` accumulating.
- Record fields that accumulate state: **strict** (`!`) or `StrictData`. Lazy `State` is a leak generator: use `Control.Monad.State.Strict`.
- **Heap profiling as a routine tool, not an emergency one**: build with `-prof -fprof-auto`, run with `+RTS -hc -hy -hb -l-au`, visualise with `eventlog2html`/`hp2ps`; `ghc-debug` to inspect the heap of a live process. Every long-lived service is profiled **before** going to production, not after the first OOM.
- Operational metric: RTS `live bytes` with a sustained upward trend = a leak until proven otherwise (§6).
- `-Wall` does not detect space leaks. There is no linter that replaces profiling.

### Alternative `Prelude`

- Default: **the standard `Prelude`**. A custom `Prelude` is an entry barrier for every new person and a source of friction with examples and libraries.
- **`relude`**: eligible in a new greenfield project where the team agrees — it removes the partial functions, uses `Text` by default, brings `NonEmpty`. It is declared with `mixins`/`NoImplicitPrelude` uniformly across the whole repo.
- **`rio`**: eligible if its architecture (`RIO env`, logging, resource handling) is adopted as a whole as well. Adopting `rio` just for the Prelude is paying the cost without the benefit.
- Hard rule: **zero or one**. Never two different preludes in the same tree, nor a homemade `MyProject.Prelude` module that grows without an owner.

### Error handling

- Two distinct axes that must not be mixed: **expected domain failures** versus **exceptional/infrastructure failures**.
- Domain ⇒ **types**: `Either MyError a`, `ExceptT MyError` in the layer that needs it, an error ADT per operation. The caller pattern matches and the compiler checks exhaustiveness.
- Infrastructure (I/O, network, disk, timeouts, `bracket`) ⇒ **`IO` exceptions**, which in Haskell are unavoidable (async exceptions included). Catch with `safe-exceptions` (or `Control.Exception` with judgement): **`bracket`/`finally` for every resource**, never `catch` over `SomeException` that also swallows the asynchronous ones (`ThreadKilled`, timeouts).
- **FORBIDDEN in production code**: `error`, `head`, `tail`, `init`, `last`, `fromJust`, `read`, `(!!)`, `maximum`/`minimum` over possibly empty lists, `undefined`. Alternatives: pattern matching, `uncons`, `listToMaybe`, `NonEmpty`, `lookup`, `readMaybe`.
- Verified state of `base`: **`head` and `tail` from `Data.List` carry a `{-# WARNING #-}` with category `x-partial` since base-4.19 (GHC 9.8)**, code `[GHC-63394]`. Nuances you must know: (a) **it is a warning, not a deprecation** — the CLC explicitly left it out of deprecating/removing; (b) **`init` and `last` are NOT marked**, so the warning does not cover the whole class; (c) it is silenced with `-Wno-x-partial` and there is pressure in GHC to take it out of `-Wdefault`. **Operational conclusion: do not delegate the prohibition to the compiler.** The real gate is `hlint` + review + `-Werror`, and `-Wno-x-partial` is forbidden in this catalogue.
- An error is never silenced by turning it into a default value (`fromMaybe 0` over a real failure). The error is propagated with context or decided explicitly.

### Effect architecture — criteria, not a catalogue

Decision order, from less to more machinery. **Do not go up a level without a concrete problem the previous level does not solve.**

1. **`ReaderT Env IO` (the `ReaderT` pattern over `IO`) — the sober default.** An `Env` record with the capabilities (DB connection, logger, HTTP client, config) injected through `ReaderT`. Testable by swapping the `Env`. Predictable performance, readable compiler errors, anyone understands it in an afternoon. **The vast majority of services need nothing more.**
2. **`mtl`** (`MonadReader`/`MonadState`/`MonadError` as constraints): useful for polymorphic functions in the domain layer. Cost: the *n²* instance problem when adding your own transformers, and error messages that degrade quickly. Acceptable in small doses on top of pattern (1).
3. **An effect system** (`effectful` as this category's default option — 2.6.1.0, Aug 2025, BSD-3-Clause; `cleff`, `fused-effects` as alternatives): only when there are **several orthogonal effects that need to be interpreted in more than one way** (real / mock / dry-run / instrumented) and that already hurts. Real cost: a structural dependency across all the code, one more curve for every new person, and a fragmented ecosystem. `effectful` is chosen for performance (`IO` + `ReaderT` underneath) and for tolerable type errors; `polysemy` is outside the default for its performance and inference cost unless proven otherwise today (§8).
- Decide on **one** and document it in an ADR. A repo with `ReaderT` in one module, `mtl` in another and `effectful` in a third is the worst possible outcome.
- Cross-cutting rule: the domain layer is **pure and effect-free**; effects live at the boundary. That is what delivers the value, not the framework.

### Concurrency

- GHC's lightweight threads (`forkIO`): cheap, thousands of them are normal. There is no pool to manage.
- **No bare `forkIO`**: every thread has an owner. `async` (`withAsync`, `concurrently`, `race`, `mapConcurrently`) or `ki` for *structured concurrency*; the parent thread observes the child's exception. A `forkIO` whose error nobody sees is a silent loss of work.
- **STM by default for shared state**: `TVar`/`TQueue`/`TBQueue` compose; `MVar` only for simple mutual exclusion or explicit *empty-full*; `IORef` only for state without contention (and with `atomicModifyIORef'`). I/O inside `atomically` is forbidden (the type already prevents it — do not circumvent it with `unsafePerformIO`).
- **Bounded** queues (`TBQueue`, not `TQueue`) by default: explicit backpressure.
- `timeout` on every network operation; asynchronous exceptions respected: `bracket`/`bracketOnError` to release resources even if `ThreadKilled` arrives; `uninterruptibleMask` only in the minimum documented gap.
- Watch out for thunks shared between threads: a lazy `TVar` accumulates the work until someone forces it — `modifyTVar'`, always the primed one.

## 4. Quality and testing

### Formatting and lint

- **fourmolu** (0.20.0.0, BSD-3-Clause) with a versioned `fourmolu.yaml`, or **ormolu** if zero configuration is preferred. Only one per repo; style is not debated, the tool decides it.
- **hlint** with a versioned `.hlint.yaml`. Verified: latest release **3.10 (Feb 2025)** — low cadence; it is still the de facto standard but **verify its state before turning it into a blocking gate** (§8). Custom rules to forbid the partial functions that `base` does not mark (`init`, `last`, `fromJust`, `(!!)`, `read`) — the linter covers the compiler's gap.
- **HLS** on every workstation, with the version tied to the project's GHC via GHCup.

### Warnings: `-Wall -Werror` and which ones

- Base `ghc-options` in the `common` stanza:

```cabal
common warnings
  ghc-options:
    -Wall
    -Wcompat
    -Widentities
    -Wincomplete-record-updates
    -Wincomplete-uni-patterns
    -Wmissing-export-lists
    -Wmissing-home-modules
    -Wpartial-fields
    -Wredundant-constraints
    -Wunused-packages
```

- **`-Werror` in CI, never in the `.cabal` of a published library** (it breaks third-party builds with a newer GHC). In CI it is passed by flag: `cabal build --ghc-options=-Werror`.
- `-Wincomplete-patterns` (inside `-Wall`) is **the most valuable gate in the language**: a non-exhaustive match is a runtime `error`. As an error, no exceptions.
- Verified warning: `-Wall` **changes content between GHC versions** (`-Wincomplete-record-selectors` entered `-Wall` in 9.14). Every GHC upgrade is done on a branch with `-Werror` on and the new items are triaged; never `-Wno-*` in bulk "to make it build".
- `{-# OPTIONS_GHC -Wno-... #-}` always at file level, with a specific lint and a reason comment. `-Wno-x-partial` is **FORBIDDEN** (§3).

### Testing

- Framework: **hspec** (BDD, good output) or **tasty** (aggregates multiple suite types). One per repo.
- **Property-based testing is Haskell's differential value and it is not optional** in code with invariants. **QuickCheck** (random generation, shrinking by type) or **Hedgehog** (integrated generators with shrinking, better by default for properties with preconditions). Mandatory properties where they apply: serialisation round-trip (`decode . encode == id` for JSON/binary/DB), algebraic laws of your own instances (`Functor`, `Monoid`, `Ord` — with `quickcheck-classes` or equivalent), idempotence, invariants of your own data structures.
- **State modelling** (`quickcheck-state-machine`, `hedgehog` state machines) for concurrent or stateful logic: it is the only practical way to find races.
- Conventional unit tests for the happy path and the **edges and errors**: empty lists, numeric limits, invalid decoding, timeouts, cancellation.
- **Golden tests** (`tasty-golden`) for large, stable outputs (renders, generated SQL, specs).
- Integration with real dependencies (`testcontainers-hs` or Docker Compose in CI) with the **same engine version as production**; mock your own boundaries, not the world.
- `doctest` in published libraries: the Haddock examples are verified.
- Every bugfix leaves a regression test that fails before the fix. A flaky test: it is fixed or it is deleted.

### CI gates (they break the build, in order of cost)

```
fourmolu --mode check $(git ls-files '*.hs')
hlint .
cabal build all --ghc-options=-Werror        # -Wall -Werror + -Wunused-packages
cabal test all
cabal check                                   # .cabal sanity (publishable packages)
cabal haddock all                             # the docs build
cabal audit / cabal-audit against security-advisories   # verify name and state (§8)
```

Main always green. GHC matrix in CI: **the production GHC as mandatory** + the next one as *allowed-to-fail* (so the upgrade is not a big bang). Do not build against 5 versions "just because": each one is minutes of CI.

## 5. Stack security

- **`unsafePerformIO` — FORBIDDEN.** It breaks referential transparency, and with it all the compiler's reasoning: the optimiser can duplicate, eliminate or reorder the effect. Single exception: FFI bindings encapsulated in a demonstrably pure API, with `{-# NOINLINE #-}`, a `-- SAFETY:` comment justifying the invariant and a dedicated test. `unsafeDupablePerformIO`, `unsafeInterleaveIO`, `unsafeCoerce` and `accursedUnutterablePerformIO`: the same rule, hardened.
- **`Text.Read.read` over untrusted input — FORBIDDEN.** `read` throws an exception impossible to handle in pure code and its parser is not designed as a trust boundary. Use `readMaybe` and, for real formats, a parser (`attoparsec`, `megaparsec`) with size and depth limits.
- **Uncurated Hackage dependencies**: Hackage reviews nothing. Every dependency outside the Stackage snapshot is a trust decision: look at the latest release, number of maintainers, licence (the real `LICENSE` file, not the `.cabal` field) and transitive tree (`cabal-plan`). Remember that **`Setup.hs` and Template Haskell run arbitrary code at build time** with the CI runner's permissions — every new dependency is supply chain surface, not a free import.
- SCA: the **Haskell Security Response Team** maintains `security-advisories` (advisory-db) and there is an audit tool; **verify the exact name, the integration and its state before pinning it as a gate** (§8). Run it on every PR and on a schedule as well.
- **Deserialisation**: JSON with `aeson` and concrete types + derived `FromJSON`, never decoding to `Value` and navigating by hand. Payload size limits at the HTTP boundary (`wai` middleware / servant) and nesting depth limits. No deserialisation that instantiates arbitrary types.
- **Parameterised SQL only**: `persistent`/`esqueleto`, `hasql`/`rel8` or `postgresql-simple` with placeholders. Building SQL by concatenating `Text` is forbidden; `rawSql` only with parameters.
- **Secrets**: never in `Show`/`Generic`-derived. Wrap credentials in a newtype with a manual `Show` that redacts, or use a secrets library; logs derive from `Show` more often than people think. No secrets in the `.cabal`, in the tree or in the eventlog.
- **Crypto**: `crypton`/`cryptonite`-successor and `tls`/`crypton-connection` maintained; verify maintenance state before pinning (§8). AES-GCM, ChaCha20-Poly1305, Argon2/bcrypt, TLS 1.2+. Security randomness from a CSPRNG (`crypton`'s `getRandomBytes`), **never `System.Random`**.
- **Containers**: multi-stage build, linked and stripped binary (`-split-sections`, `strip`), distroless image or `scratch` if it is static, **non-root**, read-only FS. Check that the dynamic C libraries (libgmp, libpq) are in the final image — the classic failure.
- Long-lived services: **close `-rtsopts` in the production binary** or restrict the accepted flags. `+RTS` from an environment variable controlled by the attacker is arbitrary runtime configuration execution.

## 6. Performance and operability

- **RTS flags: they are compiled in and justified, not copied.** Build with `-threaded -rtsopts "-with-rtsopts=..."`. Starting points to **measure**, not to assume:
  - `-N` with **explicit capabilities matched to the container's CPU limit** (`-N4`), not a bare `-N`: on K8s, `getNumProcessors` sees the physical machine and oversubscribes. This is Haskell's number one operational mistake in containers.
  - `-A` (nursery) larger than the default to reduce minor GCs in services with heavy allocation (e.g. `-A64m`) — measure p99 latency before and after.
  - `-M` (max heap) aligned with the pod's memory limit, so the process dies with a diagnosable heap error instead of by the kernel's OOM-kill.
  - `--nonmoving-gc` as an option for bounded pause latency on large heaps: **only with measurement**, not by default.
- **Observability**: RTS metrics always exported (`GHC.Stats`/`getRTSStats` with `-T`), via a Prometheus exporter or EKG; **verify the maintenance state of the specific library before pinning it** (§8). Minimum series: live bytes, GC wall/cpu time and maximum pause, live threads, capabilities. The **live bytes trend is the leak detector in production**.
- Structured logs (`katip`, `co-log`, or `rio`'s logger) in JSON; traces with OpenTelemetry when the project's ecosystem supports it (verify the binding's maturity — §8). **`putStrLn`/`print`/`trace` are forbidden in service code**; `Debug.Trace` does not get merged.
- **Timeouts and limits at every boundary**: HTTP client (`http-client` with an explicit `responseTimeout` — the default is not enough), bounded DB pool, `timeout` on service calls, request size limit. No defined timeout = bug.
- **Graceful shutdown mandatory**: a `SIGTERM` handler that stops accepting connections, drains in-flight ones with a deadline and closes resources with `bracket`. `warp` with `setInstallShutdownHandler`/`setGracefulShutdownTimeout`. Without this there is no reliable rolling deploy.
- Separate health endpoints: trivial liveness, readiness that checks dependencies.
- **Profile before optimising**: `-prof -fprof-auto` + `+RTS -p` for time, `-h*` for heap, eventlog + `ghc-events`/`eventlog2html` for concurrency and GC. `criterion`/`tasty-bench` for comparable microbenchmarks. Careful: the profiling build **changes the generated code** — confirm findings on the normal binary.
- Optimisation: `-O2` in production (`-O0`/`-O1` in development for build speed). `INLINABLE`/`SPECIALIZE` on polymorphic hot-path functions (GHC 9.14 improved specialisation considerably); list/`vector`/`text` fusion is real but breaks easily — verify with `-ddump-simpl` before claiming it happens.

### Compile times and CI — an operational risk, not an annoyance

Build times are **Haskell's most underestimated recurring cost** and the usual reason a team stops running the full CI.

- Explicit budget: clean build and incremental build measured and watched; if the incremental one exceeds ~2 min, it is an architectural bug of the project.
- Levers, in order: **CI caching of the cabal/stack store and of `dist-newstyle`** (with a key by GHC + dependency plan); splitting into packages to parallelise and bound recompilation; **removing unnecessary `TemplateHaskell`** (it invalidates the cache aggressively and blocks cross-compilation); dropping `-O2` outside release; `-j` matched to the runner's cores; `-fwrite-ide-info` only where it is used.
- `-Wunused-packages` enabled: dead dependencies that keep costing minutes.
- Runners with enough RAM: GHC with `-O2` and TH consumes gigabytes; a compiler OOM is misdiagnosed and suffered for weeks.

## 7. Long-term sustainability

**This is the section that decides whether the project survives.** The dominant risk in Haskell is not technical.

- **Bus factor ≥3 as an entry requirement**, not as an aspiration. With 1 person who masters the code, a Haskell service is a liability from the day that person rotates out. Before approving the stack: name in writing who else can deploy, debug a space leak and upgrade GHC.
- **Hiring**: the market is small and expensive, but high quality; hiring "good people who will learn Haskell" works better than looking for haskellers. Budget **3-6 months** to full productivity for a senior with no prior typed FP experience. The curve is not in the syntax: it is in laziness, `IO`/effects and in reading type errors from libraries with elaborate types.
- **Documentation as bus factor mitigation**: Haddock on every public API, ADRs for the structural decisions (effects, Prelude, build tool, Nix yes/no) and a `CONTRIBUTING` that starts from zero with GHCup. Haskell code is self-explanatory to someone who already knows Haskell — to nobody else.
- **Upgrade cadence**: GHC LTS to GHC LTS, taking advantage of the ~6 month overlap. Point releases (`.2`, `.3`) without delay. Dependencies with grouped Renovate/Dependabot; majors by hand with a changelog. Stackage snapshot: move up an LTS quarterly or at least every six months — leaving the snapshot frozen for two years turns the upgrade into a project.
- **Published libraries**: PVP (not SemVer: in Haskell versioning is `A.B.C.D` with `A.B` as major) and `cabal check` in the gate. Version bands on dependencies, `Cabal.project.freeze` only for your own CI.
- **Conscious debt**: every shortcut with `-- TODO(user): reason — issue`. No `-Wno-*` nor `hlint: ignore` without a comment and a link.

**PROHIBITIONS (require an ADR and approval to make an exception).**
- ❌ `error`, `undefined`, `head`, `tail`, `init`, `last`, `fromJust`, `read`, `(!!)`, `maximum`/`minimum` over lists — in production. The `base` warnings cover only `head`/`tail`: the gate is hlint + review.
- ❌ `unsafePerformIO`, `unsafeDupablePerformIO`, `unsafeInterleaveIO`, `unsafeCoerce`, `Obj`-tricks via FFI — except for the documented exception in §5.
- ❌ `foldl` (use `foldl'`), lazy `Data.Map` and lazy `Control.Monad.State` out of inertia, `modifyIORef` without the prime, an unbounded `TQueue` as a work bus.
- ❌ `String` as the text type in new code; `decodeUtf8` without error handling over untrusted input.
- ❌ `catch`/`handle` over `SomeException` that swallows asynchronous exceptions; resources without `bracket`; `forkIO` without an owner or observation of the result.
- ❌ `-Wno-x-partial` and `-Wno-*` in bulk; `-Werror` in the `.cabal` of a published library; CI without `-Werror`.
- ❌ Mixing Stack and cabal in the same repo; lockfile/freeze outside the VCS; `git:` without rev and hash; dependencies outside the snapshot without justification.
- ❌ Two alternative Preludes in the same tree; a homemade `Prelude` without an owner.
- ❌ More than one effect architecture in the same codebase; moving up to an effect system without a problem that `ReaderT Env IO` does not solve.
- ❌ `UndecidableInstances`/`IncoherentInstances`/`ImpredicativeTypes`/`AllowAmbiguousTypes` without an ADR; new `TemplateHaskell` without measuring its compile cost.
- ❌ `putStrLn`/`print`/`Debug.Trace` in services; secrets reachable through derived `Show`.
- ❌ `-N` without explicit capabilities in a container; a production binary with `-rtsopts` open; deploying a long-lived service without having profiled the heap.
- ❌ GHC out of support (today: ≤9.4) in production; adopting a GHC `.1` in production.
- ❌ A single human capable of maintaining the service.

**When NOT to choose Haskell (honest prohibition).**
- ❌ When the team cannot sustain a bus factor ≥3 nor budget the curve. This alone rules the stack out.
- ❌ CRUD and glue without invariants the type captures: the return on investment does not appear, and the cost does.
- ❌ **Hard latency or real-time** requirements: there is a GC, and the pauses are not trivially boundable → `rust-standards`.
- ❌ Work that depends on an ecosystem where Haskell is weak: ML/data science (→ `python-standards`), web frontend and native mobile, SDK-centric cloud ecosystems (→ `go-standards`/`typescript-standards`).
- ❌ Environments with high staff turnover or delivery by interchangeable external vendors.
- ❌ "Because the team wants to learn Haskell" on a production service. Learning is fine; the vehicle is not a system with an SLA.

## 8. Mandatory web verification

Before pinning a version or flag, or claiming the state of the ecosystem, **verify on the web** (not from memory):

1. **Current GHC and LTS policy**: `haskell.org/ghc` and `discourse.haskell.org` (schedule/LTS announcement at `haskell.org/ghc/blog/20250702-ghc-release-schedules.html`); EOL summary at `endoflife.date/ghc`. Is 9.14 still the LTS? Has the next pre-announced LTS come out? Which versions have entered EOL?
2. **What the language edition brings**: the official users guide, `exts/control.html` for the specific version. Has the default changed from `GHC2021` to `GHC2024`? Is there a new `GHC20xx`?
3. **The target version's `-Wall`**: release notes for the specific GHC — the new warnings that enter `-Wall` break builds with `-Werror`.
4. **State of the partial functions in `base`**: `hackage.haskell.org/package/base/changelog` and the `haskell/core-libraries-committee` issues. Is `x-partial` still in `-Wdefault` (there is pressure to take it out, GHC #24322)? Have `init`/`last` been marked? Has there been a real deprecation?
5. **Stackage snapshot**: `stackage.org/snapshots` — the latest LTS, its GHC and whether an LTS on the GHC you want already exists. **This is where the discrepancy declared in §2 is resolved.**
6. **Build tools**: releases of `commercialhaskell/stack` and `haskell/cabal` (`/releases.atom` feeds; the GitHub API may return 403 without auth). Check that Stack still has recent releases before repeating the myth that it is abandoned — as of Aug 2026 it does: 3.11.1, Jun 2026.
7. **Quality**: `hlint` (latest verified release 3.10, Feb 2025 — **check whether it is still alive before making it a blocking gate**), `fourmolu`/`ormolu`, HLS and its matrix of supported GHCs (`haskell-language-server.readthedocs.io/en/latest/support/ghc-version-support.html`).
8. **Advisories and auditing**: `github.com/haskell/security-advisories` — the exact name of the audit tool (integrated `cabal audit` versus external `cabal-audit`), its state and how it is integrated into CI. **Not verified in detail as of Aug 2026: gap.**
9. **Licences and maintenance** of every library pinned as a default, reading the raw `LICENSE` (`raw.githubusercontent.com`), not the `.cabal` field. Precedents from the catalogue: tools that change licence (Trivy) or declare themselves *feature complete* with a commercial move (gitleaks v2). **Verified as of Aug 2026: hlint BSD-3-Clause, fourmolu BSD-3-Clause, effectful BSD-3-Clause. Not verified: ormolu, relude, rio, aeson, servant, crypton, katip.**

**Declared gaps (not verified as of Aug 2026, do not fill from memory):**
- Current version and `recommended` tag of **GHCup** (the tag is defined by its metadata; check with `ghcup list -t ghc`).
- Versions and maintenance state of **hspec, tasty, QuickCheck, Hedgehog, aeson, servant, persistent/esqueleto, hasql, conduit, warp, http-client**.
- State of **EKG** and of the RTS metrics exporters (several candidates, uneven maintenance) and maturity of the **OpenTelemetry** binding for Haskell.
- Current comparative state of **`cleff`, `fused-effects`, `polysemy`** (the §3 judgement on `polysemy` is historical and must be reconfirmed).
- State of **`crypton`/`tls`** and of `testcontainers-hs`.
- Exact date of the next GHC LTS (the published plan pointed to 9.22 around 2028; it is a plan, not a commitment).

If the web contradicts this document, **the web wins** — flag the discrepancy.

